diff --git a/android/app/build.gradle b/android/app/build.gradle index efa09414..1a35b416 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "olcxja.miarven" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 5 - versionName "0.1.4" + versionCode 3 + versionName "0.1.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json index 01a784ae..3fba6c40 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -310,19 +310,5 @@ "error.call.screen": "screen share error: {0} :c", "error.call.screen.unsupported": "ur browser doesn't support screen sharing :c", "label.stream.cam": "cam", - "label.stream.screen": "screen", - "action.call.settings": "voice settings :3", - "label.voice.microphone": "microphone", - "label.voice.speaker": "speaker", - "label.voice.camera": "camera", - "label.voice.camera.quality": "camera quality", - "label.voice.camera.fps": "camera fps", - "label.voice.screen.quality": "screen quality", - "label.voice.screen.fps": "screen fps", - "label.voice.sensitivity": "input sensitivity", - "label.voice.default": "default", - "error.call.microphone": "microphone error :c", - "error.voice.reconnect.failed": "voice connection lost :c", - "error.voice.ticket.rejected": "voice auth failed :c check VOICE_KEY/server version", - "error.voice.not.configured": "voice server not configured :c missing VOICE_KEY" + "label.stream.screen": "screen" } diff --git a/android/app/src/main/assets/public/blah/en-us.json b/android/app/src/main/assets/public/blah/en-us.json index fa67cf49..20cfaee5 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -319,19 +319,5 @@ "error.call.screen": "Screen share error: {0}", "error.call.screen.unsupported": "Screen sharing is not supported on this browser.", "label.stream.cam": "Cam", - "label.stream.screen": "Screen", - "action.call.settings": "Voice settings", - "label.voice.microphone": "Microphone", - "label.voice.speaker": "Speaker", - "label.voice.camera": "Camera", - "label.voice.camera.quality": "Camera quality", - "label.voice.camera.fps": "Camera FPS", - "label.voice.screen.quality": "Screenshare quality", - "label.voice.screen.fps": "Screenshare FPS", - "label.voice.sensitivity": "Input sensitivity", - "label.voice.default": "Default", - "error.call.microphone": "Microphone error", - "error.voice.reconnect.failed": "Voice connection lost", - "error.voice.ticket.rejected": "Voice authentication failed - check VOICE_KEY/server version", - "error.voice.not.configured": "Voice server is not configured (missing VOICE_KEY)" + "label.stream.screen": "Screen" } diff --git a/android/app/src/main/assets/public/blah/pl-pl.json b/android/app/src/main/assets/public/blah/pl-pl.json index 68bed510..0df5e471 100644 --- a/android/app/src/main/assets/public/blah/pl-pl.json +++ b/android/app/src/main/assets/public/blah/pl-pl.json @@ -319,19 +319,5 @@ "error.call.screen": "Błąd udostępniania ekranu: {0}", "error.call.screen.unsupported": "Udostępnianie ekranu nie jest obsługiwane w tej przeglądarce.", "label.stream.cam": "Kamera", - "label.stream.screen": "Ekran", - "action.call.settings": "Ustawienia rozmowy", - "label.voice.microphone": "Mikrofon", - "label.voice.speaker": "Głośnik", - "label.voice.camera": "Kamera", - "label.voice.camera.quality": "Jakość kamery", - "label.voice.camera.fps": "FPS kamery", - "label.voice.screen.quality": "Jakość ekranu", - "label.voice.screen.fps": "FPS ekranu", - "label.voice.sensitivity": "Czułość wejścia", - "label.voice.default": "Domyślne", - "error.call.microphone": "Błąd mikrofonu", - "error.voice.reconnect.failed": "Utracono połączenie głosowe", - "error.voice.ticket.rejected": "Autoryzacja głosu nieudana - sprawdź VOICE_KEY/wersję serwera", - "error.voice.not.configured": "Serwer głosowy nieskonfigurowany (brak VOICE_KEY)" + "label.stream.screen": "Ekran" } diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index ff092762..f514589c 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -2472,7 +2472,6 @@ let touchEndX = 0; let touchStartY = 0; let touchEndY = 0; let touchMoved = false; -let touchStartTarget = null; let pressDate; @@ -2480,7 +2479,6 @@ document.addEventListener('touchstart', e => { touchStartX = e.changedTouches[0].screenX; touchStartY = e.changedTouches[0].screenY; touchMoved = false; - touchStartTarget = e.target; activeTouchButton = e.target.closest('button, input, textarea'); if (activeTouchButton) { @@ -2560,18 +2558,6 @@ document.addEventListener('contextmenu', e => { } }); -// Swipes that begin inside floating layers (popups, dialogs, dropdowns - -// anything rendered with position: fixed) belong to those layers and must -// not trigger app navigation gestures. -function touchStartedInFloatingLayer() { - let el = touchStartTarget; - while (el && el !== document.body && el !== document.documentElement) { - if (getComputedStyle(el).position === 'fixed') return true; - el = el.parentElement; - } - return false; -} - function handleMobileSwipe() { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth > 55 * rem) return; @@ -2580,7 +2566,6 @@ function handleMobileSwipe() { let diffY = touchEndY - touchStartY; if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 4 * rem) { - if (touchStartedInFloatingLayer()) return; if (diffX > 0) { mobileNavBack(); } else { diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css index 3247c504..d7bc91be 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -1715,230 +1715,6 @@ input:checked + .toggle-slider:before { outline: none; } -/* In-call Voice Device Settings Popup */ -#voice-device-settings { - position: fixed; - /* left/top are set from JS (centered, viewport-clamped) so the opening - animation cannot shift the popup and the header can drag it directly */ - width: min(24rem, calc(100vw - 2rem)); - max-height: calc(100vh - 3rem); - background: var(--main-bg-color); - border: var(--border-width) solid var(--light-border-color); - border-radius: 0.9rem; - box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5); - z-index: 10020; - display: flex; - flex-direction: column; - overflow: hidden; - opacity: 0; - pointer-events: none; - transform: scale(0.92); - transition: - opacity 0.15s ease, - transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} -#voice-device-settings.show { - opacity: 1; - pointer-events: auto; - transform: scale(1); -} -#vds-header { - height: 2.8rem; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 0.6rem 0 0.9375rem; - font-weight: bold; - font-size: 0.95rem; - background: rgba(255, 255, 255, 0.03); - border-bottom: var(--border-width) solid var(--light-border-color); - user-select: none; - -webkit-user-select: none; - cursor: grab; - touch-action: none; -} -#vds-header:active { - cursor: grabbing; -} -#vds-header span { - display: flex; - align-items: center; - gap: 0.5rem; - min-width: 0; -} -#vds-header svg { - pointer-events: none !important; - flex-shrink: 0; -} -#vds-close { - width: 2rem; - height: 2rem; - flex-shrink: 0; - margin: 0; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: var(--darker-text-color); - border-radius: 0.6rem; - cursor: pointer; -} -#vds-close:hover { - background: rgba(255, 255, 255, 0.08); - color: var(--text-color); -} -#vds-body { - padding: 0.4rem 0.7rem 0.8rem; - overflow-y: auto; - touch-action: pan-y; - display: flex; - flex-direction: column; - gap: 0.15rem; -} -.vds-row { - display: flex; - flex-direction: column; - gap: 0.3rem; - padding: 0.3rem 0.1rem; -} -.vds-row > label { - font-size: 0.75rem; - font-weight: 600; - color: var(--darker-text-color); - display: flex; - justify-content: space-between; -} -.vds-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.5rem; - padding: 0.3rem 0.1rem; -} -.vds-cell { - display: flex; - flex-direction: column; - gap: 0.3rem; - min-width: 0; -} -.vds-cell > label { - font-size: 0.75rem; - font-weight: 600; - color: var(--darker-text-color); -} -#voice-device-settings .custom-select { - margin: 0; - width: 100%; - height: 2.3rem; - font-size: 0.85rem; -} - -/* Floating option layer for the settings selects - lives on , so it can - escape the popup bounds and is unaffected by the popup transform/scrolling. - Explicit solid background: never translucent over call content. */ -.custom-select-options.vds-floating { - position: fixed; - display: block; - width: auto; - margin: 0; - background-color: var(--main-bg-color); - z-index: 10040; - opacity: 0; - visibility: hidden; - pointer-events: none; - transform: translateY(-0.4rem); - transition: - opacity 0.15s ease, - transform 0.15s ease, - visibility 0.15s; -} -.custom-select-options.vds-floating.vds-open { - opacity: 1; - visibility: visible; - pointer-events: auto; - transform: translateY(0); -} -.vds-sens-meter { - position: relative; - height: 1.9rem; - background: rgba(255, 255, 255, 0.06); - border-radius: 0.55rem; - overflow: hidden; - border: var(--border-width) solid var(--light-border-color); -} -.vds-sens-fill { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 0%; - background: rgba(255, 255, 255, 0.15); - transition: width 0.05s linear, background-color 0.2s; - pointer-events: none; -} -.vds-sens-mark { - position: absolute; - top: 0; - bottom: 0; - width: 2px; - margin-left: -1px; - background: rgba(255, 255, 255, 0.65); - border-radius: 1px; - pointer-events: none; -} -/* Settings button keeps its hover look while the settings popup is open */ -#voice-btn-settings.active { - background: rgba(255, 255, 255, 0.1); -} -#vds-sens-slider { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - -webkit-appearance: none; - appearance: none; - background: transparent; - outline: none; - border: 0; - border-radius: 0.55rem; - /* pan-y on the popup body would eat horizontal touch drags */ - touch-action: none; - z-index: 2; - cursor: ew-resize; -} -/* The app-wide `input:active` press effect must not shrink this slider. */ -#vds-sens-slider:active, -#vds-sens-slider.is-pressed { - transform: none !important; -} -#vds-sens-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 0.25rem; - height: 100%; - background: var(--text-color); - border-radius: 0.125rem; - box-shadow: 0 0 0.3rem rgba(0, 0, 0, 0.8); -} -#vds-sens-slider::-webkit-slider-thumb:hover, -#vds-sens-slider::-webkit-slider-thumb:active { - transform: none !important; -} -#vds-sens-slider::-moz-range-thumb { - width: 0.25rem; - height: 100%; - background: var(--text-color); - border: none; - border-radius: 0.125rem; -} -#vds-sens-slider::-moz-range-thumb:hover, -#vds-sens-slider::-moz-range-thumb:active { - transform: none !important; -} - @media (max-width: 55rem) { #voice-call-popup { width: 16rem; diff --git a/android/app/src/main/assets/public/version.js b/android/app/src/main/assets/public/version.js index ca181587..e9331a1b 100644 --- a/android/app/src/main/assets/public/version.js +++ b/android/app/src/main/assets/public/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.1.4"; +const CLIENT_VERSION = "0.1.2"; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index d4cf5d78..837776ea 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -27,50 +27,6 @@ let isVoiceSpeakerMode = false; const SPEAKING_HANGOVER_MS = 400; -// ===== Voice connection resilience ===== -// Every client broadcasts a tiny encrypted ping so peers can tell "quiet -// user" apart from "crashed/half-open socket". Peers silent for too long are -// dropped locally; a dead server socket triggers an automatic rejoin. -const VOICE_PEER_TIMEOUT_MS = 7000; -const VOICE_PING_INTERVAL_MS = 2000; -const VOICE_SERVER_TIMEOUT_MS = 10000; -const VOICE_MAX_RECONNECTS = 2; -let peerLastSeen = {}; -let deadPeers = new Set(); -let lastVoiceServerPacketAt = 0; -let voicePingTimer = null; -let voiceReconnectAttempts = 0; -let voiceSocketOpenedAt = 0; - -function cleanupPeerMedia(userId) { - for (const aid of [userId, `${userId}_screen`]) { - if (remoteAudio[aid]) { - if (audioDecryptChains[aid]) audioDecryptChains[aid].clear(); - delete remoteAudio[aid]; - delete audioDecryptChains[aid]; - } - } - for (const decoderId of Object.keys(remoteVideos)) { - if (decoderId.split('-')[0] === userId) { - try { if (remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {} - if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear(); - delete remoteVideos[decoderId]; - delete videoDecryptChains[decoderId]; - } - } -} - -function sendVoicePing() { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; - const iv = crypto.getRandomValues(new Uint8Array(12)); - crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; - const payload = new Uint8Array(14 + cipher.byteLength); - payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); - currentVoiceWs.send(payload.buffer); - }).catch(() => {}); -} - let voiceDragState = false; let voiceResizeAbort = null; @@ -98,75 +54,6 @@ function getStoredVolume(type, clientId) { return v === undefined ? 100 : v; } -// Voice device/media settings - persisted locally, edited from the in-call -// settings popup (microphone, speaker, camera, quality/fps, mic sensitivity). -const VOICE_SETTINGS_KEY = "larpix_voice_settings"; -const VOICE_SENSITIVITY_DEFAULT = -65; -const VOICE_RES_OPTIONS = [ - { value: "480x360", label: "360p" }, - { value: "640x480", label: "480p" }, - { value: "1280x720", label: "720p" }, - { value: "1920x1080", label: "1080p" }, - { value: "source", label: "SOURCE" } -]; -const VOICE_FPS_OPTIONS = [15, 24, 30, 60]; -let voiceSettings = loadVoiceSettings(); - -function loadVoiceSettings() { - const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT }; - try { - return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {}); - } catch (e) { - return defaults; - } -} - -function saveVoiceSettings() { - try { localStorage.setItem(VOICE_SETTINGS_KEY, JSON.stringify(voiceSettings)); } catch (e) {} -} - -function getVoiceMediaConfig(typeId) { - return typeId === 1 - ? { res: voiceSettings.camRes, fps: parseInt(voiceSettings.camFps) || 24 } - : { res: voiceSettings.screenRes, fps: parseInt(voiceSettings.screenFps) || 30 }; -} - -// Camera quality presets are treated as single-axis quality tiers (e.g. the -// longer edge of "1280x720"): the camera always opens at its NATIVE aspect -// ratio and is never steered toward a preset's own ratio like 4:3. Nothing is -// remembered - the actual geometry is detected from the live track settings -// every time (encoder config follows it automatically). - -function buildVoiceMicConstraints(deviceId) { - const constraints = { audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } }; - if (deviceId) constraints.audio.deviceId = { exact: deviceId }; - return constraints; -} - -async function switchVoiceMic(deviceId) { - if (!audioContext || !senderNode) return; - let newStream; - try { - newStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(deviceId)); - } catch (e) { - showBlahNotification("error:call.microphone"); - return; - } - if (micStream) micStream.getTracks().forEach(t => t.stop()); - micStream = newStream; - if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} } - micSourceNode = audioContext.createMediaStreamSource(micStream); - micSourceNode.connect(senderNode); -} - -// Output device selection only applies where the web layer owns the output. -// With the native Android routing the OS owns the call audio path entirely. -async function applyVoiceSpeakerSink() { - if (getNativeVoiceRouting() || !voiceSettings.speakerId) return; - try { if (audioContext && audioContext.setSinkId) await audioContext.setSinkId(voiceSettings.speakerId); } catch (e) {} - try { if (voiceOutputAudio && voiceOutputAudio.setSinkId) await voiceOutputAudio.setSinkId(voiceSettings.speakerId); } catch (e) {} -} - let voiceConnectedAt = null; let voiceTimerInterval = null; let voicePingMs = null; @@ -268,7 +155,6 @@ async function replugVoiceOutputElement() { voiceOutputAudio.srcObject = null; voiceOutputAudio.srcObject = voiceRemoteStream; try { await voiceOutputAudio.play(); } catch (e) {} - applyVoiceSpeakerSink(); } function fallbackVoiceToMediaOutput() { @@ -358,7 +244,6 @@ function setupVoiceOutput() { } voiceOutputAudio.srcObject = voiceRemoteStream; voiceOutputAudio.play().catch(() => {}); - applyVoiceSpeakerSink(); }; document.addEventListener("click", voicePlayRetry, true); window.addEventListener("voicemodechanged", voiceModeChangedHandler); @@ -492,45 +377,6 @@ setInterval(() => { changed = true; } } - // Stale remote audio streams - quiet users send nothing, but their entry - // is recreated on their very next packet, so pruning is always safe. - for (const aid of Object.keys(remoteAudio)) { - if (now - remoteAudio[aid].lastUpdate > 6000) { - const rn = aid.endsWith("_screen") ? screenReceiverNode : receiverNode; - if (rn) rn.port.postMessage({action: 'remove', clientId: aid}); - if (audioDecryptChains[aid]) audioDecryptChains[aid].clear(); - delete remoteAudio[aid]; - delete audioDecryptChains[aid]; - } - } - // Ghost peers: still on the room roster but totally silent (pings count as - // life) - drop them locally until the server or the peer proves otherwise. - let ghostRemoved = false; - for (const u of voiceActiveUsers) { - if (u === String(window.id)) continue; - if (!peerLastSeen[u]) { peerLastSeen[u] = now; continue; } - if (now - peerLastSeen[u] > VOICE_PEER_TIMEOUT_MS && !deadPeers.has(u)) { - deadPeers.add(u); - cleanupPeerMedia(u); - ghostRemoved = true; - } - } - if (ghostRemoved) { - voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u)); - updateVoiceUIUsers(); - } - // Half-open socket watchdog + backgrounded-tab audio recovery. - if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { - const others = voiceActiveUsers.filter(u => u !== String(window.id)).length; - // Alive peers ping every 2s regardless of mute, so total inbound - // silence while others are present means the socket is dead. - if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { - console.warn("Voice: no inbound traffic, forcing reconnect"); - try { currentVoiceWs.close(); } catch (e) {} - } else if (audioContext && audioContext.state === 'suspended') { - audioContext.resume().catch(() => {}); - } - } if (changed) updateVoiceUIUsers(); }, 2000); @@ -640,90 +486,33 @@ class TaskQueue { clear() { this.queue = []; } } -let voiceJoinInFlight = false; - async function initiateVoiceCall(roomId = null) { - if (currentVoiceWs || voiceJoinInFlight) { + if (currentVoiceWs) { showBlahNotification("error:already.in.call"); return; } currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms"; currentVoiceCallId = roomId || window.currentDmId; - voiceJoinInFlight = true; - - try { - showAction("action.call.init", "callinit"); - const joined = await requestVoiceJoin(); - clearAction("callinit"); - - if (joined) { - await startVoiceSession(joined.serverUrl, joined.ticket); - } - } finally { - voiceJoinInFlight = false; - } -} - -// Asks the main server for a voice server IP plus a fresh single-use ticket. -// The join payload carries publicId/connKey/proof - the client encrypts the -// proof with connKey (AES-GCM) so the wire never contains a usable credential. -async function requestVoiceJoin() { + + showAction("action.call.init", "callinit"); let res = await fetchEncrypted("room/voice/join", JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId })); - if (!res.startsWith("success:")) { + clearAction("callinit"); + + if (res.startsWith("success:")) { + let bestIp = res.split("success:")[1]; + if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) { + bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp; + } + await startVoiceSession(bestIp); + } else { showBlahNotification(res); - return null; - } - const payload = res.substring("success:".length); - const parts = payload.split("|"); - if (parts.length !== 4) return null; - - let serverUrl = parts[0]; - const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]]; - if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) { - serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl; - } - - try { - const keyBytes = b64uToBytes(connKey); - const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ct = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, b64uToBytes(proof))); - const ticket = `${publicId}.${bytesToB64u(iv)}.${bytesToB64u(ct)}`; - return {serverUrl, ticket}; - } catch (e) { - console.warn("Voice: failed to build ticket frame", e); - return null; } } -function b64uToBytes(s) { - const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/")); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function bytesToB64u(a) { - let bin = ""; - for (const byte of a) bin += String.fromCharCode(byte); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -async function startVoiceSession(serverUrl, ticket) { - if (!ticket) throw new Error("missing voice ticket"); - // Single-session invariant: a stale socket (e.g. a double-fired join) - // must be replaced BEFORE its onopen sends the same ticket twice - - // otherwise the duplicate validation burns the single-use ticket. - if (currentVoiceWs) { - try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {} - currentVoiceWs = null; - } - const myTicket = ticket; // captured PER INVOCATION - the global may be - // overwritten by a racing second join, and reading it inside onopen made - // both sockets send the same (already consumed) ticket. +async function startVoiceSession(serverUrl) { showVoiceUI(); updateVoiceUIUsers(); setVoiceStatus("connecting"); @@ -736,17 +525,12 @@ async function startVoiceSession(serverUrl, ticket) { } wsUrl.searchParams.set("room", currentVoiceCallId); wsUrl.searchParams.set("userId", window.id); - + wsUrl.searchParams.set("secret", "none"); + currentVoiceWs = new WebSocket(wsUrl.toString()); currentVoiceWs.binaryType = 'arraybuffer'; - + currentVoiceWs.onopen = async () => { - // Ticket must be the very first frame - the voice server - // authenticates the connection before joining it to the room. - voiceSocketOpenedAt = Date.now(); - console.debug("Voice: sending ticket", String(myTicket).split(".")[0]); - currentVoiceWs.send(myTicket); - setVoiceStatus("connected"); let btn = document.getElementById("voice-btn-mic"); if (btn) { @@ -764,13 +548,7 @@ async function startVoiceSession(serverUrl, ticket) { const usersRaw = await res.json(); voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); - const rawUsers = usersRaw.map(u => String(u)); - // Peers we locally declared dead stay hidden until either - // they send anything again or the server drops them. - for (const gone of [...deadPeers]) { - if (!rawUsers.includes(gone)) deadPeers.delete(gone); - } - const users = rawUsers.filter(u => !deadPeers.has(u)); + const users = usersRaw.map(u => String(u)); let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -781,18 +559,9 @@ async function startVoiceSession(serverUrl, ticket) { }; fetchUsers(); voiceWsHeartbeat = setInterval(fetchUsers, 5000); - - voiceReconnectAttempts = 0; - lastVoiceServerPacketAt = Date.now(); - if (voicePingTimer) clearInterval(voicePingTimer); - voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); - - // Reconnects reuse the live mic stream - only capture a new one - // when the previous is gone (full teardown or track ended). - if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { - micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); - if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } - } + + const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}}; + micStream = await navigator.mediaDevices.getUserMedia(micConstraints); const nativeRouting = getNativeVoiceRouting(); if (nativeRouting && nativeRouting.startCall) { @@ -809,34 +578,20 @@ async function startVoiceSession(serverUrl, ticket) { isAudioWorkletLoaded = true; } if (audioContext.state === 'suspended') await audioContext.resume(); - applyVoiceSpeakerSink(); setupVoiceOutput(); - if (micSourceNode) { - try { micSourceNode.disconnect(); } catch (e) {} - micSourceNode = null; - } micSourceNode = audioContext.createMediaStreamSource(micStream); micSourceNode.connect(senderNode); let vadHangover = 0; senderNode.port.onmessage = async (e) => { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; - // Backpressure valve: when the socket is congested, drop mic - // frames instead of queueing them - queued audio would arrive - // minutes late and drown the connection for every stream. - if (currentVoiceWs.bufferedAmount > 256 * 1024) { - vadHangover = 0; - return; - } + if (currentVoiceWs.readyState !== WebSocket.OPEN) return; const {audioData, db} = e.data; - const sensitivity = voiceSettings.sensitivity; + const sensitivity = -65; const percent = Math.max(0, Math.min(100, (db + 80) / 80 * 100)); let micLevelBar = document.getElementById("voice-mic-level"); - let vdsLevelFill = document.getElementById("vds-sens-fill"); if (micLevelBar) micLevelBar.style.width = percent + '%'; - if (vdsLevelFill) vdsLevelFill.style.width = percent + '%'; if (db >= sensitivity) vadHangover = 75; let isSpeaking = vadHangover > 0 && !isMicMuted; @@ -845,7 +600,6 @@ async function startVoiceSession(serverUrl, ticket) { if (vadHangover > 0 && !isMicMuted) { vadHangover--; if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)'; - if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (!voiceAesCryptoKey) return; const iv = crypto.getRandomValues(new Uint8Array(12)); @@ -855,7 +609,6 @@ async function startVoiceSession(serverUrl, ticket) { currentVoiceWs.send(payload.buffer); } else { if (micLevelBar) micLevelBar.style.backgroundColor = 'rgba(255, 255, 255, 0.15)'; - if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'rgba(255, 255, 255, 0.15)'; } }; }; @@ -864,24 +617,17 @@ async function startVoiceSession(serverUrl, ticket) { const arrayBuffer = e.data; const view = new DataView(arrayBuffer); let clientId = view.getBigUint64(0, true).toString(); - - // Any inbound frame proves this peer (and the socket) are alive. - lastVoiceServerPacketAt = Date.now(); - peerLastSeen[clientId] = lastVoiceServerPacketAt; - if (deadPeers.has(clientId)) deadPeers.delete(clientId); - - const type = view.getUint8(8); - if (type === 5) return; // liveness ping - presence already recorded if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) { const baseUrl = currentVoiceWs.url.split('/ws')[0]; const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => { - voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); + voiceActiveUsers = usersRaw.map(u => String(u)); updateVoiceUIUsers(); }).catch(()=>{}); } + const type = view.getUint8(8); const isKeyFrame = view.getUint8(9) === 1; const iv = new Uint8Array(arrayBuffer, 10, 12); const ciphertext = new Uint8Array(arrayBuffer, 22); @@ -993,25 +739,8 @@ async function startVoiceSession(serverUrl, ticket) { } }; - currentVoiceWs.onclose = (ev) => { - console.debug("Voice: socket closed", JSON.stringify({ reason: ev && ev.reason, code: ev && ev.code, aliveForMs: Date.now() - voiceSocketOpenedAt })); - // Server closes with reason "Kicked" when another connection took - // our userId slot (second device/tab). Reconnecting would start an - // infinite kick-war - give up cleanly instead. Any other close - // (network drop, watchdog) is a reconnect candidate. - if (ev && ev.reason === "Kicked") { - endVoiceSession(); - return; - } - // Rejected right after opening = ticket/server-key problem. - // Retrying cannot succeed - surface it instead of looping. - if (ev && ev.reason && Date.now() - voiceSocketOpenedAt < 6000) { - console.error("Voice: connection rejected by server:", ev.reason); - showBlahNotification("error:voice.ticket.rejected"); - endVoiceSession(); - return; - } - scheduleVoiceReconnect(); + currentVoiceWs.onclose = () => { + endVoiceSession(); }; } catch(err) { @@ -1027,7 +756,7 @@ function handleCallUpdate(data) { let state = []; if(parts.slice(2).join(":") !== "") { let stateRaw = JSON.parse(parts.slice(2).join(":")); - state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); + state = stateRaw.map(u => String(u)); } voiceActiveUsers = state; @@ -1128,12 +857,10 @@ function showIncomingCallUI(roomId) { let incomingUi = document.createElement("div"); incomingUi.id = "incoming-call-popup"; - // Remote-controlled values (username/avatar) are NEVER interpolated into - // HTML - they are assigned via textContent/src after the skeleton. incomingUi.innerHTML = ` - +
- + ${cachedName || "..."} ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
@@ -1145,8 +872,6 @@ function showIncomingCallUI(roomId) {
`; - incomingUi.querySelector(".incoming-call-name").textContent = cachedName || "..."; - incomingUi.querySelector(".incoming-call-avatar").src = cachedPfp || "assets/default_avatar.png"; document.body.appendChild(incomingUi); requestAnimationFrame(() => { requestAnimationFrame(() => incomingUi.classList.add("show")); @@ -1252,424 +977,6 @@ async function updateVoiceChatTitle() { if (el && name) el.innerText = name; } -// ===== In-call voice/device settings popup ===== -let voiceDevicePopupEl = null; -let voiceLabelProbed = { mic: false, cam: false }; - -function hideVoiceDeviceSettings() { - if (!voiceDevicePopupEl) return; - closeOpenVoiceDropdown(); - document.removeEventListener('click', closeVoiceDeviceSettingsOnOutsideClick); - let el = voiceDevicePopupEl; - voiceDevicePopupEl = null; - el.classList.remove('show'); - const settingsBtn = document.getElementById('voice-btn-settings'); - if (settingsBtn) settingsBtn.classList.remove('active'); - setTimeout(() => el.remove(), 250); -} - -function closeVoiceDeviceSettingsOnOutsideClick(ev) { - if (!voiceDevicePopupEl) return; - const t = ev.target; - if (voiceDevicePopupEl.contains(t)) return; - // Clicks in the floating dropdown layer or its anchor keep the popup open. - if (openVoiceDropdown && (openVoiceDropdown.dd.contains(t) || openVoiceDropdown.anchor.contains(t))) return; - hideVoiceDeviceSettings(); -} - -function categorizeVoiceDevices(devices) { - const name = (list, fallbackBase) => list.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `${fallbackBase} ${i + 1}` })); - return { - mics: name(devices.filter(d => d.kind === 'audioinput'), 'Microphone'), - cams: name(devices.filter(d => d.kind === 'videoinput'), 'Camera'), - speakers: name(devices.filter(d => d.kind === 'audiooutput'), 'Speaker') - }; -} - -// Fast - safe to call on every popup open, blocks the UI for milliseconds. -async function enumerateVoiceDevices() { - try { - return categorizeVoiceDevices(await navigator.mediaDevices.enumerateDevices()); - } catch (e) { - return { mics: [], cams: [], speakers: [] }; - } -} - -// Labels are only exposed after permission was granted at least once. Probes -// (a short getUserMedia per kind) can take seconds - callers must render the -// popup first and only refresh with this afterwards. -async function ensureVoiceDeviceLabels() { - let devices = []; - try { devices = await navigator.mediaDevices.enumerateDevices(); } catch (e) { return null; } - - const micUnlabeled = devices.some(d => d.kind === 'audioinput' && !d.label); - const camUnlabeled = devices.some(d => d.kind === 'videoinput' && !d.label); - let probed = false; - - if (micUnlabeled && !voiceLabelProbed.mic) { - voiceLabelProbed.mic = true; - probed = true; - try { const t = await navigator.mediaDevices.getUserMedia({ audio: true }); t.getTracks().forEach(t2 => t2.stop()); } catch (e) {} - } - if (camUnlabeled && !voiceLabelProbed.cam) { - voiceLabelProbed.cam = true; - probed = true; - try { const t = await navigator.mediaDevices.getUserMedia({ video: true }); t.getTracks().forEach(t2 => t2.stop()); } catch (e) {} - } - - return probed ? enumerateVoiceDevices() : null; -} - -function restartVoiceCamIfActive() { - if (!camControl) return; - camControl.stop(); - // stop() is async (it releases the camera after awaits) - poll until the - // control is actually gone before reopening, otherwise Android's camera - // HAL is still busy and getUserMedia fails with NotReadableError. - const attempt = (tries) => { - setTimeout(() => { - if (!currentVoiceWs) return; - if (camControl) { if (tries < 20) attempt(tries + 1); return; } - toggleVoiceCam(); - }, 300); - }; - attempt(0); -} - -// Selects render their option list as a fixed-position layer attached to -// (portal) so it can escape the settings popup bounds and is never -// clipped by the scrolling popup body or distorted by its transform. -let openVoiceDropdown = null; - -const voiceDropdownOutsideCloser = (ev) => { - if (!openVoiceDropdown) return; - const t = ev.target; - if (openVoiceDropdown.dd.contains(t) || openVoiceDropdown.anchor.contains(t)) return; - closeOpenVoiceDropdown(); -}; - -function closeOpenVoiceDropdown() { - const cur = openVoiceDropdown; - if (!cur) return; - openVoiceDropdown = null; - cur.dd.classList.remove('vds-open'); - cur.anchor.classList.remove('open'); - setTimeout(() => cur.dd.remove(), 220); - document.removeEventListener('click', voiceDropdownOutsideCloser, true); - window.removeEventListener('resize', closeOpenVoiceDropdown); - const bodyEl = document.getElementById('vds-body'); - if (bodyEl) bodyEl.removeEventListener('scroll', closeOpenVoiceDropdown); -} - -function openVoiceFloatingDropdown(anchor, dd) { - closeOpenVoiceDropdown(); - document.body.appendChild(dd); - - const margin = 8; - const rect = anchor.getBoundingClientRect(); - dd.style.width = 'auto'; - dd.style.minWidth = rect.width + 'px'; - dd.style.left = Math.min(Math.max(rect.left, margin), Math.max(margin, window.innerWidth - rect.width - margin)) + 'px'; - - const ddH = dd.offsetHeight; - let top = rect.bottom + 6; - if (top + ddH > window.innerHeight - margin) { - top = Math.max(margin, rect.top - ddH - 6); - } - dd.style.top = top + 'px'; - - anchor.classList.add('open'); - requestAnimationFrame(() => dd.classList.add('vds-open')); - openVoiceDropdown = { dd, anchor }; - - setTimeout(() => { - document.addEventListener('click', voiceDropdownOutsideCloser, true); - window.addEventListener('resize', closeOpenVoiceDropdown); - const bodyEl = document.getElementById('vds-body'); - if (bodyEl) bodyEl.addEventListener('scroll', closeOpenVoiceDropdown); - }, 0); -} - -function createVoiceSelect(options, selectedValue, onChange) { - const wrap = document.createElement('div'); - wrap.className = 'custom-select'; - const value = document.createElement('div'); - value.className = 'custom-select-value'; - const span = document.createElement('span'); - let current = String(options.find(o => String(o.value) === String(selectedValue))?.value ?? ''); - const currentOption = () => options.find(o => String(o.value) === current) || options[0]; - span.textContent = currentOption() ? currentOption().label : ''; - value.appendChild(span); - value.insertAdjacentHTML('beforeend', ICONS.chevronDown({ className: 'chevron', style: 'width: 1.1rem; height: 1.1rem; opacity: 0.7;' })); - - value.onclick = () => { - if (openVoiceDropdown && openVoiceDropdown.anchor === wrap) { - closeOpenVoiceDropdown(); - return; - } - const dd = document.createElement('div'); - dd.className = 'custom-select-options vds-floating'; - for (const opt of options) { - const el = document.createElement('div'); - el.className = 'custom-select-option'; - el.textContent = opt.label; - if (String(opt.value) === current) el.classList.add('selected'); - el.onclick = (ev) => { - // Keep the outside-click closer from treating this as an - // outside click - the option layer lives outside the popup. - ev.stopPropagation(); - current = String(opt.value); - span.textContent = opt.label; - closeOpenVoiceDropdown(); - onChange(current); - }; - dd.appendChild(el); - } - openVoiceFloatingDropdown(wrap, dd); - }; - - wrap.appendChild(value); - return wrap; -} - -function voiceSettingRow(labelText, controlEl) { - const row = document.createElement('div'); - row.className = 'vds-row'; - const label = document.createElement('label'); - label.textContent = labelText; - row.appendChild(label); - row.appendChild(controlEl); - return row; -} - -function buildQualityFpsGrid(mediaTypeId) { - const grid = document.createElement('div'); - grid.className = 'vds-grid'; - const resOptions = VOICE_RES_OPTIONS.slice(); - const fpsOptions = VOICE_FPS_OPTIONS.map(f => ({ value: f, label: `${f} FPS` })); - const cfg = getVoiceMediaConfig(mediaTypeId); - - const resWrap = document.createElement('div'); - resWrap.className = 'vds-cell'; - const resLabel = document.createElement('label'); - resLabel.textContent = processBlah(mediaTypeId === 1 ? 'label.voice.camera.quality' : 'label.voice.screen.quality'); - const resSelect = createVoiceSelect(resOptions, cfg.res, v => { - if (mediaTypeId === 1) { - voiceSettings.camRes = v; - saveVoiceSettings(); - // Size changes reopen the camera cleanly - mutating capture size - // mid-stream makes Android cameras flicker between sensor modes. - restartVoiceCamIfActive(); - } else { - voiceSettings.screenRes = v; - saveVoiceSettings(); - } - }); - resWrap.appendChild(resLabel); - resWrap.appendChild(resSelect); - - const fpsWrap = document.createElement('div'); - fpsWrap.className = 'vds-cell'; - const fpsLabel = document.createElement('label'); - fpsLabel.textContent = processBlah(mediaTypeId === 1 ? 'label.voice.camera.fps' : 'label.voice.screen.fps'); - const fpsSelect = createVoiceSelect(fpsOptions, cfg.fps, v => { - if (mediaTypeId === 1) { - voiceSettings.camFps = parseInt(v); - saveVoiceSettings(); - // Mid-stream frame-rate renegotiation can make Android cameras - // wander between sensor modes (aspect ratio flicker) - reopen - // cleanly instead, same as resolution changes. - restartVoiceCamIfActive(); - } else { - voiceSettings.screenFps = parseInt(v); - saveVoiceSettings(); - } - }); - fpsWrap.appendChild(fpsLabel); - fpsWrap.appendChild(fpsSelect); - - grid.appendChild(resWrap); - grid.appendChild(fpsWrap); - return grid; -} - -function clampVoiceDevicePopupToViewport(popup) { - const W = window.innerWidth, H = window.innerHeight; - const w = popup.offsetWidth, h = popup.offsetHeight; - let left = Math.round((W - w) / 2), top = Math.round((H - h) / 2); - left = Math.min(Math.max(left, 8), Math.max(8, W - w - 8)); - top = Math.min(Math.max(top, 8), Math.max(8, H - h - 8)); - popup.style.left = left + 'px'; - popup.style.top = top + 'px'; -} - -// Centered via explicit px coordinates (not CSS translate) so the opening -// animation never shifts the popup and dragging is a plain left/top change. -function makeVoiceDeviceSettingsDraggable(popup) { - const header = popup.querySelector('#vds-header'); - let dragging = false, startX = 0, startY = 0, startL = 0, startT = 0; - - header.addEventListener('pointerdown', (e) => { - if (e.button !== undefined && e.button !== 0) return; - if (e.target.closest('#vds-close')) return; - closeOpenVoiceDropdown(); - dragging = true; - try { header.setPointerCapture(e.pointerId); } catch (err) {} - const rect = popup.getBoundingClientRect(); - startL = rect.left; startT = rect.top; - startX = e.clientX; startY = e.clientY; - e.preventDefault(); - }); - header.addEventListener('pointermove', (e) => { - if (!dragging) return; - const W = window.innerWidth, H = window.innerHeight; - let l = startL + (e.clientX - startX); - let t = startT + (e.clientY - startY); - l = Math.min(Math.max(l, 8), Math.max(8, W - popup.offsetWidth - 8)); - t = Math.min(Math.max(t, 8), Math.max(8, H - popup.offsetHeight - 8)); - popup.style.left = l + 'px'; - popup.style.top = t + 'px'; - }); - const stopDrag = () => { dragging = false; }; - header.addEventListener('pointerup', stopDrag); - header.addEventListener('pointercancel', stopDrag); -} - -function buildVoiceSettingsBody(body, nativeRouting, devices) { - const deviceOptions = (list) => [{ value: '', label: processBlah('label.voice.default') }].concat(list.map(d => ({ value: d.deviceId, label: d.label }))); - - // Microphone - switching mid-call swaps the captured track live. - body.appendChild(voiceSettingRow(processBlah('label.voice.microphone'), - createVoiceSelect(deviceOptions(devices.mics), voiceSettings.micId, v => { - voiceSettings.micId = v; - saveVoiceSettings(); - switchVoiceMic(v); - }) - )); - - // Output device - not available with native Android routing (the OS owns - // the call audio path there). - if (!nativeRouting && devices.speakers.length > 0) { - body.appendChild(voiceSettingRow(processBlah('label.voice.speaker'), - createVoiceSelect(deviceOptions(devices.speakers), voiceSettings.speakerId, v => { - voiceSettings.speakerId = v; - saveVoiceSettings(); - applyVoiceSpeakerSink(); - }) - )); - } - - // Camera device - body.appendChild(voiceSettingRow(processBlah('label.voice.camera'), - createVoiceSelect(deviceOptions(devices.cams), voiceSettings.camId, v => { - voiceSettings.camId = v; - saveVoiceSettings(); - restartVoiceCamIfActive(); - }) - )); - - // Quality/FPS - picked up live by the encoder config check. - body.appendChild(buildQualityFpsGrid(1)); - - // Screen share quality/FPS - body.appendChild(buildQualityFpsGrid(2)); - - // Mic input sensitivity (VAD threshold) - const sensPercent = (voiceSettings.sensitivity + 80) / 80 * 100; - const sensRow = document.createElement('div'); - sensRow.className = 'vds-row'; - sensRow.innerHTML = ` - -
-
-
- -
- `; - body.appendChild(sensRow); - const sliderEl = sensRow.querySelector('#vds-sens-slider'); - const applySensitivity = (raw) => { - const v = Math.max(-80, Math.min(0, Math.round(raw))); - voiceSettings.sensitivity = v; - saveVoiceSettings(); - const valEl = document.getElementById('vds-sens-val'); - const markEl = document.getElementById('vds-sens-mark'); - if (valEl) valEl.innerText = `${v} dB`; - if (markEl) markEl.style.left = ((v + 80) / 80 * 100) + '%'; - }; - // Native touch-dragging of range thumbs is unreliable inside the WebView - - // drive touch/pen drags explicitly from pointer events. Mouse stays native. - const applyFromPointer = (e) => { - const rect = sliderEl.getBoundingClientRect(); - if (rect.width <= 0) return; - const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); - applySensitivity(-80 + pct * 80); - sliderEl.value = String(voiceSettings.sensitivity); - }; - sliderEl.addEventListener('pointerdown', (e) => { - if (e.pointerType === 'mouse') return; - e.preventDefault(); // own the gesture - no native range handling - try { sliderEl.setPointerCapture(e.pointerId); } catch (err) {} - applyFromPointer(e); - }); - sliderEl.addEventListener('pointermove', (e) => { - if (e.pointerType === 'mouse') return; - try { if (!sliderEl.hasPointerCapture(e.pointerId)) return; } catch (err) { return; } - applyFromPointer(e); - }); - sliderEl.addEventListener('pointerup', (e) => { - try { if (sliderEl.hasPointerCapture(e.pointerId)) sliderEl.releasePointerCapture(e.pointerId); } catch (err) {} - }); - sliderEl.oninput = function () { applySensitivity(parseInt(this.value)); }; -} - -window.showVoiceDeviceSettings = async function (e) { - if (e) e.stopPropagation(); - if (voiceDevicePopupEl) { hideVoiceDeviceSettings(); return; } - if (!voiceUiContainer) return; - - const nativeRouting = getNativeVoiceRouting(); - const popup = document.createElement('div'); - popup.id = 'voice-device-settings'; - popup.innerHTML = ` -
- ${ICONS.settings({width: "1rem", height: "1rem"})} ${processBlah("action.call.settings")} - -
-
- `; - // Hidden until positioned: base CSS keeps opacity 0 without .show. - popup.style.left = '0px'; - popup.style.top = '0px'; - document.body.appendChild(popup); - voiceDevicePopupEl = popup; - makeVoiceDeviceSettingsDraggable(popup); - popup.querySelector('#vds-close').onclick = hideVoiceDeviceSettings; - - const body = popup.querySelector('#vds-body'); - - // Show immediately with a fast enumeration - never block on getUserMedia - // label probes here, otherwise taps land on nothing and feel ignored. - let devices = await enumerateVoiceDevices(); - if (!voiceDevicePopupEl || voiceDevicePopupEl !== popup) { popup.remove(); return; } - - buildVoiceSettingsBody(body, nativeRouting, devices); - - clampVoiceDevicePopupToViewport(popup); - requestAnimationFrame(() => requestAnimationFrame(() => popup.classList.add('show'))); - const settingsBtn = document.getElementById('voice-btn-settings'); - if (settingsBtn) settingsBtn.classList.add('active'); - document.addEventListener('click', closeVoiceDeviceSettingsOnOutsideClick); - - // Upgrade generic labels ("Camera 1") in the background once probed. - const refreshed = await ensureVoiceDeviceLabels(); - if (refreshed && voiceDevicePopupEl === popup) { - closeOpenVoiceDropdown(); - body.innerHTML = ''; - buildVoiceSettingsBody(body, nativeRouting, refreshed); - } -}; - function showVoiceUI() { if (voiceUiContainer) return; @@ -1702,9 +1009,6 @@ function showVoiceUI() { - ${(isMobileVoiceDevice() && getNativeVoiceRouting()) ? `` : ""} @@ -2159,63 +1463,8 @@ function toggleVoiceMic() { } } -// Tears down only the transport-level state (socket, remote streams, key), -// keeping the UI, mic stream and AudioContext alive for an automatic rejoin. -function teardownVoiceTransport() { - if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } - if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } - if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; } - if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; } - if (currentVoiceWs) { - try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {} - currentVoiceWs = null; - } - for (const decoderId of Object.keys(remoteVideos)) { - try { if (remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {} - } - remoteVideos = {}; - videoDecryptChains = {}; - remoteAudio = {}; - audioDecryptChains = {}; - peerLastSeen = {}; - deadPeers.clear(); - if (receiverNode) receiverNode.port.postMessage({action: 'clear'}); - if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'}); - voiceAesKeyBytes = null; - voiceAesCryptoKey = null; - lastVoiceServerPacketAt = Date.now(); -} - -function scheduleVoiceReconnect() { - if (!currentVoiceCallId || !currentVoiceCallType) { - // Real hangup already ran its cleanup. - return; - } - if (voiceReconnectAttempts >= VOICE_MAX_RECONNECTS) { - showBlahNotification("error:voice.reconnect.failed"); - endVoiceSession(); - return; - } - voiceReconnectAttempts++; - setVoiceStatus("connecting"); - teardownVoiceTransport(); - setTimeout(async () => { - if (!currentVoiceCallId || !currentVoiceCallType) return; - try { - // Tickets are single-use - every reconnect needs a fresh one. - const joined = await requestVoiceJoin(); - if (!joined) { endVoiceSession(); return; } - await startVoiceSession(joined.serverUrl, joined.ticket); - } catch (err) { - console.warn("Voice reconnect failed", err); - scheduleVoiceReconnect(); - } - }, 800); -} - function endVoiceSession() { setVoiceStatus("disconnected"); - hideVoiceDeviceSettings(); const nativeRouting = getNativeVoiceRouting(); if (nativeRouting && nativeRouting.stopCall) { @@ -2241,10 +1490,10 @@ function endVoiceSession() { if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; } if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; } - if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } - if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } - peerLastSeen = {}; - deadPeers.clear(); + if (window.fetchUsersInterval) { + clearInterval(window.fetchUsersInterval); + window.fetchUsersInterval = null; + } for (let id in audioDecoders) { try { audioDecoders[id].close(); } catch(e){} @@ -2323,8 +1572,6 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode.port.onmessage = async (e) => { if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; - // Same backpressure valve as the mic sender. - if (currentVoiceWs.bufferedAmount > 256 * 1024) return; const iv = crypto.getRandomValues(new Uint8Array(12)); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData); const payload = new Uint8Array(14 + cipher.byteLength); @@ -2336,9 +1583,8 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { } const getCurrentConfig = () => { - const mediaCfg = getVoiceMediaConfig(typeId); - const fps = mediaCfg.fps; - const resVal = mediaCfg.res; + const fps = typeId === 1 ? 24 : 30; + const resVal = typeId === 1 ? "1280x720" : "1920x1080"; const settings = videoTrack.getSettings(); let sourceWidth = settings.width || 1280, sourceHeight = settings.height || 720, width, height; if (resVal === "source") { width = sourceWidth; height = sourceHeight; } else { @@ -2347,11 +1593,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { if (ratio < 1) { width = sourceWidth * ratio; height = sourceHeight * ratio; } else { width = sourceWidth; height = sourceHeight; } } width = Math.floor(width / 2) * 2; height = Math.floor(height / 2) * 2; - // A WebSocket relay saturates fast: uncapped w*h*fps bitrates - // (several Mb/s) choke the shared call socket, which stalls audio - // for everyone. Keep generous-but-safe ceilings per stream type. - const bitrate = Math.min(Math.floor(width * height * fps * 0.07), typeId === 1 ? 2_000_000 : 3_000_000); - return { width, height, fps, bitrate }; + return { width, height, fps, bitrate: Math.floor(width * height * fps * 0.15) }; }; let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; @@ -2368,30 +1610,16 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { }); encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); - // Transient capture-mode wobbles must not thrash the encoder - - // commit only after ~300 ms stable. Camera size/fps changes arrive - // via a clean reopen (see buildQualityFpsGrid); the screen share - // surface is never re-constrained, the encoder just rescales. - let pendingCfg = null, pendingAt = 0; - const cfgEquals = (a, b) => !!a && !!b && a.width === b.width && a.height === b.height && a.fps === b.fps; + const encodeLoop = () => { + if (!isRunning) return; + const newConfig = getCurrentConfig(); + let forceKeyframe = false; + if (newConfig.width !== currentConfig.width || newConfig.height !== currentConfig.height || newConfig.fps !== currentConfig.fps) { + currentConfig = newConfig; forceKeyframe = true; frameCount = 0; + encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); + } - const encodeLoop = () => { - if (!isRunning) return; - const newConfig = getCurrentConfig(); - let forceKeyframe = false; - if (!cfgEquals(newConfig, currentConfig)) { - if (!cfgEquals(newConfig, pendingCfg)) { - pendingCfg = newConfig; - pendingAt = performance.now(); - } else if (performance.now() - pendingAt > 300) { - currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null; - encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); - } - } else if (pendingCfg) { - pendingCfg = null; // wiggle reverted on its own - ignore - } - - if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) { + if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 512 && videoEl.readyState >= 2) { let frame = null; try { frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)}); @@ -2436,40 +1664,14 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { } catch (e) { console.error(e); alert(processBlah(`:error.call.unsupported:${typeLabel}`)); return null; } } -// Opens the camera honoring the saved device/quality. A saved deviceId can -// become stale (device list changes across sessions) - fall back to the -// default camera instead of failing with OverconstrainedError. -async function getVoiceCamStream() { - const camCfg = getVoiceMediaConfig(1); - const constraintsFor = (deviceId) => { - const video = {frameRate: {ideal: camCfg.fps}}; - if (camCfg.res !== "source") { - const [w, h] = camCfg.res.split('x').map(Number); - // Single-axis ideal: no implied aspect ratio, the camera keeps its - // native one and only the resolution tier changes. - video.width = {ideal: Math.max(w, h)}; - } - if (deviceId) video.deviceId = {exact: deviceId}; - return {video}; - }; - try { - return await navigator.mediaDevices.getUserMedia(constraintsFor(voiceSettings.camId)); - } catch (e) { - if (!voiceSettings.camId) throw e; - voiceSettings.camId = ""; - saveVoiceSettings(); - return navigator.mediaDevices.getUserMedia(constraintsFor(undefined)); - } -} - function toggleVoiceCam() { let btn = document.getElementById("voice-btn-cam"); if (camControl) { camControl.stop(); } else { - getVoiceCamStream().then(stream => { + navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl); - }).catch(err => alert(processBlah(`:error.call.camera:${err.message || err.name || "?"}`))); + }).catch(err => alert(processBlah(`:error.call.camera:${err.message}`))); } } @@ -2483,6 +1685,6 @@ function toggleVoiceScreen() { video: true, audio: { autoGainControl: false, echoCancellation: false, noiseSuppression: false }, systemAudio: "include", selfBrowserSurface: "exclude" }).then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Screen", 2).then(ctrl => screenControl = ctrl); - }).catch(err => alert(processBlah(`:error.call.screen:${err.message || err.name || "?"}`))); + }).catch(err => alert(processBlah(`:error.call.screen:${err.message}`))); } } diff --git a/electron/package.json b/electron/package.json index 4b890f9c..0c4b8636 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "Miarven", - "version": "0.1.4", + "version": "0.1.2", "description": "An Amazing Capacitor App", "author": { "name": "", diff --git a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/Bridge.dex b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/Bridge.dex index 91f146a7..306cc218 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/Bridge.dex and b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/Bridge.dex differ diff --git a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/BridgeWebChromeClient.dex b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/BridgeWebChromeClient.dex index 1da30ac8..2e49c819 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/BridgeWebChromeClient.dex and b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/BridgeWebChromeClient.dex differ diff --git a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/MessageHandler.dex b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/MessageHandler.dex index cc65ba99..2998eaa6 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/MessageHandler.dex and b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/MessageHandler.dex differ diff --git a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/WebViewLocalServer$1.dex b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/WebViewLocalServer$1.dex index 77e2f54b..cecd34b5 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/WebViewLocalServer$1.dex and b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/WebViewLocalServer$1.dex differ diff --git a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/cordova/MockCordovaWebViewImpl.dex b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/cordova/MockCordovaWebViewImpl.dex index 716acfed..1c8a122b 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/cordova/MockCordovaWebViewImpl.dex and b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/cordova/MockCordovaWebViewImpl.dex differ diff --git a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/plugin/CapacitorHttp$1.dex b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/plugin/CapacitorHttp$1.dex index 6b0b8f3f..7c2ae85f 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/plugin/CapacitorHttp$1.dex and b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/plugin/CapacitorHttp$1.dex differ diff --git a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/plugin/SystemBars.dex b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/plugin/SystemBars.dex index a5259da6..8f05de9f 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/plugin/SystemBars.dex and b/node_modules/@capacitor/android/capacitor/build/.transforms/1276a3e332abd14d335321a6d0415747/transformed/bundleLibRuntimeToDirRelease/bundleLibRuntimeToDirRelease_dex/com/getcapacitor/plugin/SystemBars.dex differ diff --git a/node_modules/@capacitor/android/capacitor/build/.transforms/f09febc18048fab5f1299aee3efc9209/transformed/classes/classes_dex/classes.dex b/node_modules/@capacitor/android/capacitor/build/.transforms/f09febc18048fab5f1299aee3efc9209/transformed/classes/classes_dex/classes.dex index 1016c798..dec4de0a 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/.transforms/f09febc18048fab5f1299aee3efc9209/transformed/classes/classes_dex/classes.dex and b/node_modules/@capacitor/android/capacitor/build/.transforms/f09febc18048fab5f1299aee3efc9209/transformed/classes/classes_dex/classes.dex differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar b/node_modules/@capacitor/android/capacitor/build/intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar index a819218d..544cf3c3 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar and b/node_modules/@capacitor/android/capacitor/build/intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar b/node_modules/@capacitor/android/capacitor/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar index 5f127147..a2f9e0ab 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar and b/node_modules/@capacitor/android/capacitor/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/full_jar/release/createFullJarRelease/full.jar b/node_modules/@capacitor/android/capacitor/build/intermediates/full_jar/release/createFullJarRelease/full.jar index fe915a96..fa4c8689 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/full_jar/release/createFullJarRelease/full.jar and b/node_modules/@capacitor/android/capacitor/build/intermediates/full_jar/release/createFullJarRelease/full.jar differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/Bridge.class b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/Bridge.class index c052ced2..f4c10616 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/Bridge.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/Bridge.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/BridgeWebChromeClient.class b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/BridgeWebChromeClient.class index 873124dc..559f1fee 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/BridgeWebChromeClient.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/BridgeWebChromeClient.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/MessageHandler.class b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/MessageHandler.class index 60533c63..8a786ece 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/MessageHandler.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/MessageHandler.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/WebViewLocalServer$1.class b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/WebViewLocalServer$1.class index 22c5a8ea..4f43add4 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/WebViewLocalServer$1.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/WebViewLocalServer$1.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/cordova/MockCordovaWebViewImpl.class b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/cordova/MockCordovaWebViewImpl.class index e4614aa9..bee24b99 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/cordova/MockCordovaWebViewImpl.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/cordova/MockCordovaWebViewImpl.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/CapacitorCookies.class b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/CapacitorCookies.class index 4cdc9649..3c96cf93 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/CapacitorCookies.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/CapacitorCookies.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/CapacitorHttp$1.class b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/CapacitorHttp$1.class index 5e2646de..7e700c51 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/CapacitorHttp$1.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/CapacitorHttp$1.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/SystemBars.class b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/SystemBars.class index 381e26dd..ccd58342 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/SystemBars.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes/com/getcapacitor/plugin/SystemBars.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/local_aar_for_lint/release/out.aar b/node_modules/@capacitor/android/capacitor/build/intermediates/local_aar_for_lint/release/out.aar index 452ac2c6..200270d3 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/local_aar_for_lint/release/out.aar and b/node_modules/@capacitor/android/capacitor/build/intermediates/local_aar_for_lint/release/out.aar differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/Bridge.class b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/Bridge.class index c052ced2..f4c10616 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/Bridge.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/Bridge.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/BridgeWebChromeClient.class b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/BridgeWebChromeClient.class index 873124dc..559f1fee 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/BridgeWebChromeClient.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/BridgeWebChromeClient.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/MessageHandler.class b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/MessageHandler.class index 60533c63..8a786ece 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/MessageHandler.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/MessageHandler.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/WebViewLocalServer$1.class b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/WebViewLocalServer$1.class index 22c5a8ea..4f43add4 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/WebViewLocalServer$1.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/WebViewLocalServer$1.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/cordova/MockCordovaWebViewImpl.class b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/cordova/MockCordovaWebViewImpl.class index e4614aa9..bee24b99 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/cordova/MockCordovaWebViewImpl.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/cordova/MockCordovaWebViewImpl.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/CapacitorCookies.class b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/CapacitorCookies.class index 4cdc9649..3c96cf93 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/CapacitorCookies.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/CapacitorCookies.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/CapacitorHttp$1.class b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/CapacitorHttp$1.class index 5e2646de..7e700c51 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/CapacitorHttp$1.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/CapacitorHttp$1.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/SystemBars.class b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/SystemBars.class index 381e26dd..ccd58342 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/SystemBars.class and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_dir/release/bundleLibRuntimeToDirRelease/com/getcapacitor/plugin/SystemBars.class differ diff --git a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_jar/release/bundleLibRuntimeToJarRelease/classes.jar b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_jar/release/bundleLibRuntimeToJarRelease/classes.jar index 80767010..a98c6520 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_jar/release/bundleLibRuntimeToJarRelease/classes.jar and b/node_modules/@capacitor/android/capacitor/build/intermediates/runtime_library_classes_jar/release/bundleLibRuntimeToJarRelease/classes.jar differ diff --git a/node_modules/@capacitor/android/capacitor/build/outputs/aar/capacitor-android-release.aar b/node_modules/@capacitor/android/capacitor/build/outputs/aar/capacitor-android-release.aar index c47e7aba..b4c6d4da 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/outputs/aar/capacitor-android-release.aar and b/node_modules/@capacitor/android/capacitor/build/outputs/aar/capacitor-android-release.aar differ diff --git a/node_modules/@capacitor/android/capacitor/build/tmp/compileReleaseJavaWithJavac/previous-compilation-data.bin b/node_modules/@capacitor/android/capacitor/build/tmp/compileReleaseJavaWithJavac/previous-compilation-data.bin index c11fe808..bc2cd00b 100644 Binary files a/node_modules/@capacitor/android/capacitor/build/tmp/compileReleaseJavaWithJavac/previous-compilation-data.bin and b/node_modules/@capacitor/android/capacitor/build/tmp/compileReleaseJavaWithJavac/previous-compilation-data.bin differ diff --git a/node_modules/@capacitor/app/android/build/intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar b/node_modules/@capacitor/app/android/build/intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar index ca8dd2ba..349b690c 100644 Binary files a/node_modules/@capacitor/app/android/build/intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar and b/node_modules/@capacitor/app/android/build/intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar differ diff --git a/node_modules/@capacitor/app/android/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar b/node_modules/@capacitor/app/android/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar index 3af13e3a..30b3fc75 100644 Binary files a/node_modules/@capacitor/app/android/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar and b/node_modules/@capacitor/app/android/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar differ diff --git a/node_modules/@capacitor/app/android/build/intermediates/full_jar/release/createFullJarRelease/full.jar b/node_modules/@capacitor/app/android/build/intermediates/full_jar/release/createFullJarRelease/full.jar index a369165b..66bcd0c7 100644 Binary files a/node_modules/@capacitor/app/android/build/intermediates/full_jar/release/createFullJarRelease/full.jar and b/node_modules/@capacitor/app/android/build/intermediates/full_jar/release/createFullJarRelease/full.jar differ diff --git a/node_modules/@capacitor/app/android/build/intermediates/incremental/lintVitalAnalyzeRelease/release-artifact-libraries.xml b/node_modules/@capacitor/app/android/build/intermediates/incremental/lintVitalAnalyzeRelease/release-artifact-libraries.xml index 2acdc18a..51db8efc 100644 --- a/node_modules/@capacitor/app/android/build/intermediates/incremental/lintVitalAnalyzeRelease/release-artifact-libraries.xml +++ b/node_modules/@capacitor/app/android/build/intermediates/incremental/lintVitalAnalyzeRelease/release-artifact-libraries.xml @@ -1,9 +1,9 @@ { touchStartX = e.changedTouches[0].screenX; touchStartY = e.changedTouches[0].screenY; touchMoved = false; - touchStartTarget = e.target; activeTouchButton = e.target.closest('button, input, textarea'); if (activeTouchButton) { @@ -2560,18 +2558,6 @@ document.addEventListener('contextmenu', e => { } }); -// Swipes that begin inside floating layers (popups, dialogs, dropdowns - -// anything rendered with position: fixed) belong to those layers and must -// not trigger app navigation gestures. -function touchStartedInFloatingLayer() { - let el = touchStartTarget; - while (el && el !== document.body && el !== document.documentElement) { - if (getComputedStyle(el).position === 'fixed') return true; - el = el.parentElement; - } - return false; -} - function handleMobileSwipe() { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); if (window.innerWidth > 55 * rem) return; @@ -2580,7 +2566,6 @@ function handleMobileSwipe() { let diffY = touchEndY - touchStartY; if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 4 * rem) { - if (touchStartedInFloatingLayer()) return; if (diffX > 0) { mobileNavBack(); } else { diff --git a/webroot/style.css b/webroot/style.css index 3247c504..d7bc91be 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -1715,230 +1715,6 @@ input:checked + .toggle-slider:before { outline: none; } -/* In-call Voice Device Settings Popup */ -#voice-device-settings { - position: fixed; - /* left/top are set from JS (centered, viewport-clamped) so the opening - animation cannot shift the popup and the header can drag it directly */ - width: min(24rem, calc(100vw - 2rem)); - max-height: calc(100vh - 3rem); - background: var(--main-bg-color); - border: var(--border-width) solid var(--light-border-color); - border-radius: 0.9rem; - box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5); - z-index: 10020; - display: flex; - flex-direction: column; - overflow: hidden; - opacity: 0; - pointer-events: none; - transform: scale(0.92); - transition: - opacity 0.15s ease, - transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} -#voice-device-settings.show { - opacity: 1; - pointer-events: auto; - transform: scale(1); -} -#vds-header { - height: 2.8rem; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 0.6rem 0 0.9375rem; - font-weight: bold; - font-size: 0.95rem; - background: rgba(255, 255, 255, 0.03); - border-bottom: var(--border-width) solid var(--light-border-color); - user-select: none; - -webkit-user-select: none; - cursor: grab; - touch-action: none; -} -#vds-header:active { - cursor: grabbing; -} -#vds-header span { - display: flex; - align-items: center; - gap: 0.5rem; - min-width: 0; -} -#vds-header svg { - pointer-events: none !important; - flex-shrink: 0; -} -#vds-close { - width: 2rem; - height: 2rem; - flex-shrink: 0; - margin: 0; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: var(--darker-text-color); - border-radius: 0.6rem; - cursor: pointer; -} -#vds-close:hover { - background: rgba(255, 255, 255, 0.08); - color: var(--text-color); -} -#vds-body { - padding: 0.4rem 0.7rem 0.8rem; - overflow-y: auto; - touch-action: pan-y; - display: flex; - flex-direction: column; - gap: 0.15rem; -} -.vds-row { - display: flex; - flex-direction: column; - gap: 0.3rem; - padding: 0.3rem 0.1rem; -} -.vds-row > label { - font-size: 0.75rem; - font-weight: 600; - color: var(--darker-text-color); - display: flex; - justify-content: space-between; -} -.vds-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.5rem; - padding: 0.3rem 0.1rem; -} -.vds-cell { - display: flex; - flex-direction: column; - gap: 0.3rem; - min-width: 0; -} -.vds-cell > label { - font-size: 0.75rem; - font-weight: 600; - color: var(--darker-text-color); -} -#voice-device-settings .custom-select { - margin: 0; - width: 100%; - height: 2.3rem; - font-size: 0.85rem; -} - -/* Floating option layer for the settings selects - lives on , so it can - escape the popup bounds and is unaffected by the popup transform/scrolling. - Explicit solid background: never translucent over call content. */ -.custom-select-options.vds-floating { - position: fixed; - display: block; - width: auto; - margin: 0; - background-color: var(--main-bg-color); - z-index: 10040; - opacity: 0; - visibility: hidden; - pointer-events: none; - transform: translateY(-0.4rem); - transition: - opacity 0.15s ease, - transform 0.15s ease, - visibility 0.15s; -} -.custom-select-options.vds-floating.vds-open { - opacity: 1; - visibility: visible; - pointer-events: auto; - transform: translateY(0); -} -.vds-sens-meter { - position: relative; - height: 1.9rem; - background: rgba(255, 255, 255, 0.06); - border-radius: 0.55rem; - overflow: hidden; - border: var(--border-width) solid var(--light-border-color); -} -.vds-sens-fill { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 0%; - background: rgba(255, 255, 255, 0.15); - transition: width 0.05s linear, background-color 0.2s; - pointer-events: none; -} -.vds-sens-mark { - position: absolute; - top: 0; - bottom: 0; - width: 2px; - margin-left: -1px; - background: rgba(255, 255, 255, 0.65); - border-radius: 1px; - pointer-events: none; -} -/* Settings button keeps its hover look while the settings popup is open */ -#voice-btn-settings.active { - background: rgba(255, 255, 255, 0.1); -} -#vds-sens-slider { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - -webkit-appearance: none; - appearance: none; - background: transparent; - outline: none; - border: 0; - border-radius: 0.55rem; - /* pan-y on the popup body would eat horizontal touch drags */ - touch-action: none; - z-index: 2; - cursor: ew-resize; -} -/* The app-wide `input:active` press effect must not shrink this slider. */ -#vds-sens-slider:active, -#vds-sens-slider.is-pressed { - transform: none !important; -} -#vds-sens-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 0.25rem; - height: 100%; - background: var(--text-color); - border-radius: 0.125rem; - box-shadow: 0 0 0.3rem rgba(0, 0, 0, 0.8); -} -#vds-sens-slider::-webkit-slider-thumb:hover, -#vds-sens-slider::-webkit-slider-thumb:active { - transform: none !important; -} -#vds-sens-slider::-moz-range-thumb { - width: 0.25rem; - height: 100%; - background: var(--text-color); - border: none; - border-radius: 0.125rem; -} -#vds-sens-slider::-moz-range-thumb:hover, -#vds-sens-slider::-moz-range-thumb:active { - transform: none !important; -} - @media (max-width: 55rem) { #voice-call-popup { width: 16rem; diff --git a/webroot/version.js b/webroot/version.js index ca181587..e9331a1b 100644 --- a/webroot/version.js +++ b/webroot/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.1.4"; +const CLIENT_VERSION = "0.1.2"; diff --git a/webroot/voice.js b/webroot/voice.js index d4cf5d78..837776ea 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -27,50 +27,6 @@ let isVoiceSpeakerMode = false; const SPEAKING_HANGOVER_MS = 400; -// ===== Voice connection resilience ===== -// Every client broadcasts a tiny encrypted ping so peers can tell "quiet -// user" apart from "crashed/half-open socket". Peers silent for too long are -// dropped locally; a dead server socket triggers an automatic rejoin. -const VOICE_PEER_TIMEOUT_MS = 7000; -const VOICE_PING_INTERVAL_MS = 2000; -const VOICE_SERVER_TIMEOUT_MS = 10000; -const VOICE_MAX_RECONNECTS = 2; -let peerLastSeen = {}; -let deadPeers = new Set(); -let lastVoiceServerPacketAt = 0; -let voicePingTimer = null; -let voiceReconnectAttempts = 0; -let voiceSocketOpenedAt = 0; - -function cleanupPeerMedia(userId) { - for (const aid of [userId, `${userId}_screen`]) { - if (remoteAudio[aid]) { - if (audioDecryptChains[aid]) audioDecryptChains[aid].clear(); - delete remoteAudio[aid]; - delete audioDecryptChains[aid]; - } - } - for (const decoderId of Object.keys(remoteVideos)) { - if (decoderId.split('-')[0] === userId) { - try { if (remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {} - if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear(); - delete remoteVideos[decoderId]; - delete videoDecryptChains[decoderId]; - } - } -} - -function sendVoicePing() { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; - const iv = crypto.getRandomValues(new Uint8Array(12)); - crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; - const payload = new Uint8Array(14 + cipher.byteLength); - payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); - currentVoiceWs.send(payload.buffer); - }).catch(() => {}); -} - let voiceDragState = false; let voiceResizeAbort = null; @@ -98,75 +54,6 @@ function getStoredVolume(type, clientId) { return v === undefined ? 100 : v; } -// Voice device/media settings - persisted locally, edited from the in-call -// settings popup (microphone, speaker, camera, quality/fps, mic sensitivity). -const VOICE_SETTINGS_KEY = "larpix_voice_settings"; -const VOICE_SENSITIVITY_DEFAULT = -65; -const VOICE_RES_OPTIONS = [ - { value: "480x360", label: "360p" }, - { value: "640x480", label: "480p" }, - { value: "1280x720", label: "720p" }, - { value: "1920x1080", label: "1080p" }, - { value: "source", label: "SOURCE" } -]; -const VOICE_FPS_OPTIONS = [15, 24, 30, 60]; -let voiceSettings = loadVoiceSettings(); - -function loadVoiceSettings() { - const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT }; - try { - return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {}); - } catch (e) { - return defaults; - } -} - -function saveVoiceSettings() { - try { localStorage.setItem(VOICE_SETTINGS_KEY, JSON.stringify(voiceSettings)); } catch (e) {} -} - -function getVoiceMediaConfig(typeId) { - return typeId === 1 - ? { res: voiceSettings.camRes, fps: parseInt(voiceSettings.camFps) || 24 } - : { res: voiceSettings.screenRes, fps: parseInt(voiceSettings.screenFps) || 30 }; -} - -// Camera quality presets are treated as single-axis quality tiers (e.g. the -// longer edge of "1280x720"): the camera always opens at its NATIVE aspect -// ratio and is never steered toward a preset's own ratio like 4:3. Nothing is -// remembered - the actual geometry is detected from the live track settings -// every time (encoder config follows it automatically). - -function buildVoiceMicConstraints(deviceId) { - const constraints = { audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } }; - if (deviceId) constraints.audio.deviceId = { exact: deviceId }; - return constraints; -} - -async function switchVoiceMic(deviceId) { - if (!audioContext || !senderNode) return; - let newStream; - try { - newStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(deviceId)); - } catch (e) { - showBlahNotification("error:call.microphone"); - return; - } - if (micStream) micStream.getTracks().forEach(t => t.stop()); - micStream = newStream; - if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} } - micSourceNode = audioContext.createMediaStreamSource(micStream); - micSourceNode.connect(senderNode); -} - -// Output device selection only applies where the web layer owns the output. -// With the native Android routing the OS owns the call audio path entirely. -async function applyVoiceSpeakerSink() { - if (getNativeVoiceRouting() || !voiceSettings.speakerId) return; - try { if (audioContext && audioContext.setSinkId) await audioContext.setSinkId(voiceSettings.speakerId); } catch (e) {} - try { if (voiceOutputAudio && voiceOutputAudio.setSinkId) await voiceOutputAudio.setSinkId(voiceSettings.speakerId); } catch (e) {} -} - let voiceConnectedAt = null; let voiceTimerInterval = null; let voicePingMs = null; @@ -268,7 +155,6 @@ async function replugVoiceOutputElement() { voiceOutputAudio.srcObject = null; voiceOutputAudio.srcObject = voiceRemoteStream; try { await voiceOutputAudio.play(); } catch (e) {} - applyVoiceSpeakerSink(); } function fallbackVoiceToMediaOutput() { @@ -358,7 +244,6 @@ function setupVoiceOutput() { } voiceOutputAudio.srcObject = voiceRemoteStream; voiceOutputAudio.play().catch(() => {}); - applyVoiceSpeakerSink(); }; document.addEventListener("click", voicePlayRetry, true); window.addEventListener("voicemodechanged", voiceModeChangedHandler); @@ -492,45 +377,6 @@ setInterval(() => { changed = true; } } - // Stale remote audio streams - quiet users send nothing, but their entry - // is recreated on their very next packet, so pruning is always safe. - for (const aid of Object.keys(remoteAudio)) { - if (now - remoteAudio[aid].lastUpdate > 6000) { - const rn = aid.endsWith("_screen") ? screenReceiverNode : receiverNode; - if (rn) rn.port.postMessage({action: 'remove', clientId: aid}); - if (audioDecryptChains[aid]) audioDecryptChains[aid].clear(); - delete remoteAudio[aid]; - delete audioDecryptChains[aid]; - } - } - // Ghost peers: still on the room roster but totally silent (pings count as - // life) - drop them locally until the server or the peer proves otherwise. - let ghostRemoved = false; - for (const u of voiceActiveUsers) { - if (u === String(window.id)) continue; - if (!peerLastSeen[u]) { peerLastSeen[u] = now; continue; } - if (now - peerLastSeen[u] > VOICE_PEER_TIMEOUT_MS && !deadPeers.has(u)) { - deadPeers.add(u); - cleanupPeerMedia(u); - ghostRemoved = true; - } - } - if (ghostRemoved) { - voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u)); - updateVoiceUIUsers(); - } - // Half-open socket watchdog + backgrounded-tab audio recovery. - if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { - const others = voiceActiveUsers.filter(u => u !== String(window.id)).length; - // Alive peers ping every 2s regardless of mute, so total inbound - // silence while others are present means the socket is dead. - if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { - console.warn("Voice: no inbound traffic, forcing reconnect"); - try { currentVoiceWs.close(); } catch (e) {} - } else if (audioContext && audioContext.state === 'suspended') { - audioContext.resume().catch(() => {}); - } - } if (changed) updateVoiceUIUsers(); }, 2000); @@ -640,90 +486,33 @@ class TaskQueue { clear() { this.queue = []; } } -let voiceJoinInFlight = false; - async function initiateVoiceCall(roomId = null) { - if (currentVoiceWs || voiceJoinInFlight) { + if (currentVoiceWs) { showBlahNotification("error:already.in.call"); return; } currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms"; currentVoiceCallId = roomId || window.currentDmId; - voiceJoinInFlight = true; - - try { - showAction("action.call.init", "callinit"); - const joined = await requestVoiceJoin(); - clearAction("callinit"); - - if (joined) { - await startVoiceSession(joined.serverUrl, joined.ticket); - } - } finally { - voiceJoinInFlight = false; - } -} - -// Asks the main server for a voice server IP plus a fresh single-use ticket. -// The join payload carries publicId/connKey/proof - the client encrypts the -// proof with connKey (AES-GCM) so the wire never contains a usable credential. -async function requestVoiceJoin() { + + showAction("action.call.init", "callinit"); let res = await fetchEncrypted("room/voice/join", JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId })); - if (!res.startsWith("success:")) { + clearAction("callinit"); + + if (res.startsWith("success:")) { + let bestIp = res.split("success:")[1]; + if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) { + bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp; + } + await startVoiceSession(bestIp); + } else { showBlahNotification(res); - return null; - } - const payload = res.substring("success:".length); - const parts = payload.split("|"); - if (parts.length !== 4) return null; - - let serverUrl = parts[0]; - const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]]; - if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) { - serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl; - } - - try { - const keyBytes = b64uToBytes(connKey); - const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ct = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, b64uToBytes(proof))); - const ticket = `${publicId}.${bytesToB64u(iv)}.${bytesToB64u(ct)}`; - return {serverUrl, ticket}; - } catch (e) { - console.warn("Voice: failed to build ticket frame", e); - return null; } } -function b64uToBytes(s) { - const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/")); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -function bytesToB64u(a) { - let bin = ""; - for (const byte of a) bin += String.fromCharCode(byte); - return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -async function startVoiceSession(serverUrl, ticket) { - if (!ticket) throw new Error("missing voice ticket"); - // Single-session invariant: a stale socket (e.g. a double-fired join) - // must be replaced BEFORE its onopen sends the same ticket twice - - // otherwise the duplicate validation burns the single-use ticket. - if (currentVoiceWs) { - try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {} - currentVoiceWs = null; - } - const myTicket = ticket; // captured PER INVOCATION - the global may be - // overwritten by a racing second join, and reading it inside onopen made - // both sockets send the same (already consumed) ticket. +async function startVoiceSession(serverUrl) { showVoiceUI(); updateVoiceUIUsers(); setVoiceStatus("connecting"); @@ -736,17 +525,12 @@ async function startVoiceSession(serverUrl, ticket) { } wsUrl.searchParams.set("room", currentVoiceCallId); wsUrl.searchParams.set("userId", window.id); - + wsUrl.searchParams.set("secret", "none"); + currentVoiceWs = new WebSocket(wsUrl.toString()); currentVoiceWs.binaryType = 'arraybuffer'; - + currentVoiceWs.onopen = async () => { - // Ticket must be the very first frame - the voice server - // authenticates the connection before joining it to the room. - voiceSocketOpenedAt = Date.now(); - console.debug("Voice: sending ticket", String(myTicket).split(".")[0]); - currentVoiceWs.send(myTicket); - setVoiceStatus("connected"); let btn = document.getElementById("voice-btn-mic"); if (btn) { @@ -764,13 +548,7 @@ async function startVoiceSession(serverUrl, ticket) { const usersRaw = await res.json(); voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); - const rawUsers = usersRaw.map(u => String(u)); - // Peers we locally declared dead stay hidden until either - // they send anything again or the server drops them. - for (const gone of [...deadPeers]) { - if (!rawUsers.includes(gone)) deadPeers.delete(gone); - } - const users = rawUsers.filter(u => !deadPeers.has(u)); + const users = usersRaw.map(u => String(u)); let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -781,18 +559,9 @@ async function startVoiceSession(serverUrl, ticket) { }; fetchUsers(); voiceWsHeartbeat = setInterval(fetchUsers, 5000); - - voiceReconnectAttempts = 0; - lastVoiceServerPacketAt = Date.now(); - if (voicePingTimer) clearInterval(voicePingTimer); - voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); - - // Reconnects reuse the live mic stream - only capture a new one - // when the previous is gone (full teardown or track ended). - if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { - micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); - if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } - } + + const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}}; + micStream = await navigator.mediaDevices.getUserMedia(micConstraints); const nativeRouting = getNativeVoiceRouting(); if (nativeRouting && nativeRouting.startCall) { @@ -809,34 +578,20 @@ async function startVoiceSession(serverUrl, ticket) { isAudioWorkletLoaded = true; } if (audioContext.state === 'suspended') await audioContext.resume(); - applyVoiceSpeakerSink(); setupVoiceOutput(); - if (micSourceNode) { - try { micSourceNode.disconnect(); } catch (e) {} - micSourceNode = null; - } micSourceNode = audioContext.createMediaStreamSource(micStream); micSourceNode.connect(senderNode); let vadHangover = 0; senderNode.port.onmessage = async (e) => { - if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; - // Backpressure valve: when the socket is congested, drop mic - // frames instead of queueing them - queued audio would arrive - // minutes late and drown the connection for every stream. - if (currentVoiceWs.bufferedAmount > 256 * 1024) { - vadHangover = 0; - return; - } + if (currentVoiceWs.readyState !== WebSocket.OPEN) return; const {audioData, db} = e.data; - const sensitivity = voiceSettings.sensitivity; + const sensitivity = -65; const percent = Math.max(0, Math.min(100, (db + 80) / 80 * 100)); let micLevelBar = document.getElementById("voice-mic-level"); - let vdsLevelFill = document.getElementById("vds-sens-fill"); if (micLevelBar) micLevelBar.style.width = percent + '%'; - if (vdsLevelFill) vdsLevelFill.style.width = percent + '%'; if (db >= sensitivity) vadHangover = 75; let isSpeaking = vadHangover > 0 && !isMicMuted; @@ -845,7 +600,6 @@ async function startVoiceSession(serverUrl, ticket) { if (vadHangover > 0 && !isMicMuted) { vadHangover--; if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)'; - if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (!voiceAesCryptoKey) return; const iv = crypto.getRandomValues(new Uint8Array(12)); @@ -855,7 +609,6 @@ async function startVoiceSession(serverUrl, ticket) { currentVoiceWs.send(payload.buffer); } else { if (micLevelBar) micLevelBar.style.backgroundColor = 'rgba(255, 255, 255, 0.15)'; - if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'rgba(255, 255, 255, 0.15)'; } }; }; @@ -864,24 +617,17 @@ async function startVoiceSession(serverUrl, ticket) { const arrayBuffer = e.data; const view = new DataView(arrayBuffer); let clientId = view.getBigUint64(0, true).toString(); - - // Any inbound frame proves this peer (and the socket) are alive. - lastVoiceServerPacketAt = Date.now(); - peerLastSeen[clientId] = lastVoiceServerPacketAt; - if (deadPeers.has(clientId)) deadPeers.delete(clientId); - - const type = view.getUint8(8); - if (type === 5) return; // liveness ping - presence already recorded if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) { const baseUrl = currentVoiceWs.url.split('/ws')[0]; const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => { - voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); + voiceActiveUsers = usersRaw.map(u => String(u)); updateVoiceUIUsers(); }).catch(()=>{}); } + const type = view.getUint8(8); const isKeyFrame = view.getUint8(9) === 1; const iv = new Uint8Array(arrayBuffer, 10, 12); const ciphertext = new Uint8Array(arrayBuffer, 22); @@ -993,25 +739,8 @@ async function startVoiceSession(serverUrl, ticket) { } }; - currentVoiceWs.onclose = (ev) => { - console.debug("Voice: socket closed", JSON.stringify({ reason: ev && ev.reason, code: ev && ev.code, aliveForMs: Date.now() - voiceSocketOpenedAt })); - // Server closes with reason "Kicked" when another connection took - // our userId slot (second device/tab). Reconnecting would start an - // infinite kick-war - give up cleanly instead. Any other close - // (network drop, watchdog) is a reconnect candidate. - if (ev && ev.reason === "Kicked") { - endVoiceSession(); - return; - } - // Rejected right after opening = ticket/server-key problem. - // Retrying cannot succeed - surface it instead of looping. - if (ev && ev.reason && Date.now() - voiceSocketOpenedAt < 6000) { - console.error("Voice: connection rejected by server:", ev.reason); - showBlahNotification("error:voice.ticket.rejected"); - endVoiceSession(); - return; - } - scheduleVoiceReconnect(); + currentVoiceWs.onclose = () => { + endVoiceSession(); }; } catch(err) { @@ -1027,7 +756,7 @@ function handleCallUpdate(data) { let state = []; if(parts.slice(2).join(":") !== "") { let stateRaw = JSON.parse(parts.slice(2).join(":")); - state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); + state = stateRaw.map(u => String(u)); } voiceActiveUsers = state; @@ -1128,12 +857,10 @@ function showIncomingCallUI(roomId) { let incomingUi = document.createElement("div"); incomingUi.id = "incoming-call-popup"; - // Remote-controlled values (username/avatar) are NEVER interpolated into - // HTML - they are assigned via textContent/src after the skeleton. incomingUi.innerHTML = ` - +
- + ${cachedName || "..."} ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
@@ -1145,8 +872,6 @@ function showIncomingCallUI(roomId) {
`; - incomingUi.querySelector(".incoming-call-name").textContent = cachedName || "..."; - incomingUi.querySelector(".incoming-call-avatar").src = cachedPfp || "assets/default_avatar.png"; document.body.appendChild(incomingUi); requestAnimationFrame(() => { requestAnimationFrame(() => incomingUi.classList.add("show")); @@ -1252,424 +977,6 @@ async function updateVoiceChatTitle() { if (el && name) el.innerText = name; } -// ===== In-call voice/device settings popup ===== -let voiceDevicePopupEl = null; -let voiceLabelProbed = { mic: false, cam: false }; - -function hideVoiceDeviceSettings() { - if (!voiceDevicePopupEl) return; - closeOpenVoiceDropdown(); - document.removeEventListener('click', closeVoiceDeviceSettingsOnOutsideClick); - let el = voiceDevicePopupEl; - voiceDevicePopupEl = null; - el.classList.remove('show'); - const settingsBtn = document.getElementById('voice-btn-settings'); - if (settingsBtn) settingsBtn.classList.remove('active'); - setTimeout(() => el.remove(), 250); -} - -function closeVoiceDeviceSettingsOnOutsideClick(ev) { - if (!voiceDevicePopupEl) return; - const t = ev.target; - if (voiceDevicePopupEl.contains(t)) return; - // Clicks in the floating dropdown layer or its anchor keep the popup open. - if (openVoiceDropdown && (openVoiceDropdown.dd.contains(t) || openVoiceDropdown.anchor.contains(t))) return; - hideVoiceDeviceSettings(); -} - -function categorizeVoiceDevices(devices) { - const name = (list, fallbackBase) => list.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `${fallbackBase} ${i + 1}` })); - return { - mics: name(devices.filter(d => d.kind === 'audioinput'), 'Microphone'), - cams: name(devices.filter(d => d.kind === 'videoinput'), 'Camera'), - speakers: name(devices.filter(d => d.kind === 'audiooutput'), 'Speaker') - }; -} - -// Fast - safe to call on every popup open, blocks the UI for milliseconds. -async function enumerateVoiceDevices() { - try { - return categorizeVoiceDevices(await navigator.mediaDevices.enumerateDevices()); - } catch (e) { - return { mics: [], cams: [], speakers: [] }; - } -} - -// Labels are only exposed after permission was granted at least once. Probes -// (a short getUserMedia per kind) can take seconds - callers must render the -// popup first and only refresh with this afterwards. -async function ensureVoiceDeviceLabels() { - let devices = []; - try { devices = await navigator.mediaDevices.enumerateDevices(); } catch (e) { return null; } - - const micUnlabeled = devices.some(d => d.kind === 'audioinput' && !d.label); - const camUnlabeled = devices.some(d => d.kind === 'videoinput' && !d.label); - let probed = false; - - if (micUnlabeled && !voiceLabelProbed.mic) { - voiceLabelProbed.mic = true; - probed = true; - try { const t = await navigator.mediaDevices.getUserMedia({ audio: true }); t.getTracks().forEach(t2 => t2.stop()); } catch (e) {} - } - if (camUnlabeled && !voiceLabelProbed.cam) { - voiceLabelProbed.cam = true; - probed = true; - try { const t = await navigator.mediaDevices.getUserMedia({ video: true }); t.getTracks().forEach(t2 => t2.stop()); } catch (e) {} - } - - return probed ? enumerateVoiceDevices() : null; -} - -function restartVoiceCamIfActive() { - if (!camControl) return; - camControl.stop(); - // stop() is async (it releases the camera after awaits) - poll until the - // control is actually gone before reopening, otherwise Android's camera - // HAL is still busy and getUserMedia fails with NotReadableError. - const attempt = (tries) => { - setTimeout(() => { - if (!currentVoiceWs) return; - if (camControl) { if (tries < 20) attempt(tries + 1); return; } - toggleVoiceCam(); - }, 300); - }; - attempt(0); -} - -// Selects render their option list as a fixed-position layer attached to -// (portal) so it can escape the settings popup bounds and is never -// clipped by the scrolling popup body or distorted by its transform. -let openVoiceDropdown = null; - -const voiceDropdownOutsideCloser = (ev) => { - if (!openVoiceDropdown) return; - const t = ev.target; - if (openVoiceDropdown.dd.contains(t) || openVoiceDropdown.anchor.contains(t)) return; - closeOpenVoiceDropdown(); -}; - -function closeOpenVoiceDropdown() { - const cur = openVoiceDropdown; - if (!cur) return; - openVoiceDropdown = null; - cur.dd.classList.remove('vds-open'); - cur.anchor.classList.remove('open'); - setTimeout(() => cur.dd.remove(), 220); - document.removeEventListener('click', voiceDropdownOutsideCloser, true); - window.removeEventListener('resize', closeOpenVoiceDropdown); - const bodyEl = document.getElementById('vds-body'); - if (bodyEl) bodyEl.removeEventListener('scroll', closeOpenVoiceDropdown); -} - -function openVoiceFloatingDropdown(anchor, dd) { - closeOpenVoiceDropdown(); - document.body.appendChild(dd); - - const margin = 8; - const rect = anchor.getBoundingClientRect(); - dd.style.width = 'auto'; - dd.style.minWidth = rect.width + 'px'; - dd.style.left = Math.min(Math.max(rect.left, margin), Math.max(margin, window.innerWidth - rect.width - margin)) + 'px'; - - const ddH = dd.offsetHeight; - let top = rect.bottom + 6; - if (top + ddH > window.innerHeight - margin) { - top = Math.max(margin, rect.top - ddH - 6); - } - dd.style.top = top + 'px'; - - anchor.classList.add('open'); - requestAnimationFrame(() => dd.classList.add('vds-open')); - openVoiceDropdown = { dd, anchor }; - - setTimeout(() => { - document.addEventListener('click', voiceDropdownOutsideCloser, true); - window.addEventListener('resize', closeOpenVoiceDropdown); - const bodyEl = document.getElementById('vds-body'); - if (bodyEl) bodyEl.addEventListener('scroll', closeOpenVoiceDropdown); - }, 0); -} - -function createVoiceSelect(options, selectedValue, onChange) { - const wrap = document.createElement('div'); - wrap.className = 'custom-select'; - const value = document.createElement('div'); - value.className = 'custom-select-value'; - const span = document.createElement('span'); - let current = String(options.find(o => String(o.value) === String(selectedValue))?.value ?? ''); - const currentOption = () => options.find(o => String(o.value) === current) || options[0]; - span.textContent = currentOption() ? currentOption().label : ''; - value.appendChild(span); - value.insertAdjacentHTML('beforeend', ICONS.chevronDown({ className: 'chevron', style: 'width: 1.1rem; height: 1.1rem; opacity: 0.7;' })); - - value.onclick = () => { - if (openVoiceDropdown && openVoiceDropdown.anchor === wrap) { - closeOpenVoiceDropdown(); - return; - } - const dd = document.createElement('div'); - dd.className = 'custom-select-options vds-floating'; - for (const opt of options) { - const el = document.createElement('div'); - el.className = 'custom-select-option'; - el.textContent = opt.label; - if (String(opt.value) === current) el.classList.add('selected'); - el.onclick = (ev) => { - // Keep the outside-click closer from treating this as an - // outside click - the option layer lives outside the popup. - ev.stopPropagation(); - current = String(opt.value); - span.textContent = opt.label; - closeOpenVoiceDropdown(); - onChange(current); - }; - dd.appendChild(el); - } - openVoiceFloatingDropdown(wrap, dd); - }; - - wrap.appendChild(value); - return wrap; -} - -function voiceSettingRow(labelText, controlEl) { - const row = document.createElement('div'); - row.className = 'vds-row'; - const label = document.createElement('label'); - label.textContent = labelText; - row.appendChild(label); - row.appendChild(controlEl); - return row; -} - -function buildQualityFpsGrid(mediaTypeId) { - const grid = document.createElement('div'); - grid.className = 'vds-grid'; - const resOptions = VOICE_RES_OPTIONS.slice(); - const fpsOptions = VOICE_FPS_OPTIONS.map(f => ({ value: f, label: `${f} FPS` })); - const cfg = getVoiceMediaConfig(mediaTypeId); - - const resWrap = document.createElement('div'); - resWrap.className = 'vds-cell'; - const resLabel = document.createElement('label'); - resLabel.textContent = processBlah(mediaTypeId === 1 ? 'label.voice.camera.quality' : 'label.voice.screen.quality'); - const resSelect = createVoiceSelect(resOptions, cfg.res, v => { - if (mediaTypeId === 1) { - voiceSettings.camRes = v; - saveVoiceSettings(); - // Size changes reopen the camera cleanly - mutating capture size - // mid-stream makes Android cameras flicker between sensor modes. - restartVoiceCamIfActive(); - } else { - voiceSettings.screenRes = v; - saveVoiceSettings(); - } - }); - resWrap.appendChild(resLabel); - resWrap.appendChild(resSelect); - - const fpsWrap = document.createElement('div'); - fpsWrap.className = 'vds-cell'; - const fpsLabel = document.createElement('label'); - fpsLabel.textContent = processBlah(mediaTypeId === 1 ? 'label.voice.camera.fps' : 'label.voice.screen.fps'); - const fpsSelect = createVoiceSelect(fpsOptions, cfg.fps, v => { - if (mediaTypeId === 1) { - voiceSettings.camFps = parseInt(v); - saveVoiceSettings(); - // Mid-stream frame-rate renegotiation can make Android cameras - // wander between sensor modes (aspect ratio flicker) - reopen - // cleanly instead, same as resolution changes. - restartVoiceCamIfActive(); - } else { - voiceSettings.screenFps = parseInt(v); - saveVoiceSettings(); - } - }); - fpsWrap.appendChild(fpsLabel); - fpsWrap.appendChild(fpsSelect); - - grid.appendChild(resWrap); - grid.appendChild(fpsWrap); - return grid; -} - -function clampVoiceDevicePopupToViewport(popup) { - const W = window.innerWidth, H = window.innerHeight; - const w = popup.offsetWidth, h = popup.offsetHeight; - let left = Math.round((W - w) / 2), top = Math.round((H - h) / 2); - left = Math.min(Math.max(left, 8), Math.max(8, W - w - 8)); - top = Math.min(Math.max(top, 8), Math.max(8, H - h - 8)); - popup.style.left = left + 'px'; - popup.style.top = top + 'px'; -} - -// Centered via explicit px coordinates (not CSS translate) so the opening -// animation never shifts the popup and dragging is a plain left/top change. -function makeVoiceDeviceSettingsDraggable(popup) { - const header = popup.querySelector('#vds-header'); - let dragging = false, startX = 0, startY = 0, startL = 0, startT = 0; - - header.addEventListener('pointerdown', (e) => { - if (e.button !== undefined && e.button !== 0) return; - if (e.target.closest('#vds-close')) return; - closeOpenVoiceDropdown(); - dragging = true; - try { header.setPointerCapture(e.pointerId); } catch (err) {} - const rect = popup.getBoundingClientRect(); - startL = rect.left; startT = rect.top; - startX = e.clientX; startY = e.clientY; - e.preventDefault(); - }); - header.addEventListener('pointermove', (e) => { - if (!dragging) return; - const W = window.innerWidth, H = window.innerHeight; - let l = startL + (e.clientX - startX); - let t = startT + (e.clientY - startY); - l = Math.min(Math.max(l, 8), Math.max(8, W - popup.offsetWidth - 8)); - t = Math.min(Math.max(t, 8), Math.max(8, H - popup.offsetHeight - 8)); - popup.style.left = l + 'px'; - popup.style.top = t + 'px'; - }); - const stopDrag = () => { dragging = false; }; - header.addEventListener('pointerup', stopDrag); - header.addEventListener('pointercancel', stopDrag); -} - -function buildVoiceSettingsBody(body, nativeRouting, devices) { - const deviceOptions = (list) => [{ value: '', label: processBlah('label.voice.default') }].concat(list.map(d => ({ value: d.deviceId, label: d.label }))); - - // Microphone - switching mid-call swaps the captured track live. - body.appendChild(voiceSettingRow(processBlah('label.voice.microphone'), - createVoiceSelect(deviceOptions(devices.mics), voiceSettings.micId, v => { - voiceSettings.micId = v; - saveVoiceSettings(); - switchVoiceMic(v); - }) - )); - - // Output device - not available with native Android routing (the OS owns - // the call audio path there). - if (!nativeRouting && devices.speakers.length > 0) { - body.appendChild(voiceSettingRow(processBlah('label.voice.speaker'), - createVoiceSelect(deviceOptions(devices.speakers), voiceSettings.speakerId, v => { - voiceSettings.speakerId = v; - saveVoiceSettings(); - applyVoiceSpeakerSink(); - }) - )); - } - - // Camera device - body.appendChild(voiceSettingRow(processBlah('label.voice.camera'), - createVoiceSelect(deviceOptions(devices.cams), voiceSettings.camId, v => { - voiceSettings.camId = v; - saveVoiceSettings(); - restartVoiceCamIfActive(); - }) - )); - - // Quality/FPS - picked up live by the encoder config check. - body.appendChild(buildQualityFpsGrid(1)); - - // Screen share quality/FPS - body.appendChild(buildQualityFpsGrid(2)); - - // Mic input sensitivity (VAD threshold) - const sensPercent = (voiceSettings.sensitivity + 80) / 80 * 100; - const sensRow = document.createElement('div'); - sensRow.className = 'vds-row'; - sensRow.innerHTML = ` - -
-
-
- -
- `; - body.appendChild(sensRow); - const sliderEl = sensRow.querySelector('#vds-sens-slider'); - const applySensitivity = (raw) => { - const v = Math.max(-80, Math.min(0, Math.round(raw))); - voiceSettings.sensitivity = v; - saveVoiceSettings(); - const valEl = document.getElementById('vds-sens-val'); - const markEl = document.getElementById('vds-sens-mark'); - if (valEl) valEl.innerText = `${v} dB`; - if (markEl) markEl.style.left = ((v + 80) / 80 * 100) + '%'; - }; - // Native touch-dragging of range thumbs is unreliable inside the WebView - - // drive touch/pen drags explicitly from pointer events. Mouse stays native. - const applyFromPointer = (e) => { - const rect = sliderEl.getBoundingClientRect(); - if (rect.width <= 0) return; - const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); - applySensitivity(-80 + pct * 80); - sliderEl.value = String(voiceSettings.sensitivity); - }; - sliderEl.addEventListener('pointerdown', (e) => { - if (e.pointerType === 'mouse') return; - e.preventDefault(); // own the gesture - no native range handling - try { sliderEl.setPointerCapture(e.pointerId); } catch (err) {} - applyFromPointer(e); - }); - sliderEl.addEventListener('pointermove', (e) => { - if (e.pointerType === 'mouse') return; - try { if (!sliderEl.hasPointerCapture(e.pointerId)) return; } catch (err) { return; } - applyFromPointer(e); - }); - sliderEl.addEventListener('pointerup', (e) => { - try { if (sliderEl.hasPointerCapture(e.pointerId)) sliderEl.releasePointerCapture(e.pointerId); } catch (err) {} - }); - sliderEl.oninput = function () { applySensitivity(parseInt(this.value)); }; -} - -window.showVoiceDeviceSettings = async function (e) { - if (e) e.stopPropagation(); - if (voiceDevicePopupEl) { hideVoiceDeviceSettings(); return; } - if (!voiceUiContainer) return; - - const nativeRouting = getNativeVoiceRouting(); - const popup = document.createElement('div'); - popup.id = 'voice-device-settings'; - popup.innerHTML = ` -
- ${ICONS.settings({width: "1rem", height: "1rem"})} ${processBlah("action.call.settings")} - -
-
- `; - // Hidden until positioned: base CSS keeps opacity 0 without .show. - popup.style.left = '0px'; - popup.style.top = '0px'; - document.body.appendChild(popup); - voiceDevicePopupEl = popup; - makeVoiceDeviceSettingsDraggable(popup); - popup.querySelector('#vds-close').onclick = hideVoiceDeviceSettings; - - const body = popup.querySelector('#vds-body'); - - // Show immediately with a fast enumeration - never block on getUserMedia - // label probes here, otherwise taps land on nothing and feel ignored. - let devices = await enumerateVoiceDevices(); - if (!voiceDevicePopupEl || voiceDevicePopupEl !== popup) { popup.remove(); return; } - - buildVoiceSettingsBody(body, nativeRouting, devices); - - clampVoiceDevicePopupToViewport(popup); - requestAnimationFrame(() => requestAnimationFrame(() => popup.classList.add('show'))); - const settingsBtn = document.getElementById('voice-btn-settings'); - if (settingsBtn) settingsBtn.classList.add('active'); - document.addEventListener('click', closeVoiceDeviceSettingsOnOutsideClick); - - // Upgrade generic labels ("Camera 1") in the background once probed. - const refreshed = await ensureVoiceDeviceLabels(); - if (refreshed && voiceDevicePopupEl === popup) { - closeOpenVoiceDropdown(); - body.innerHTML = ''; - buildVoiceSettingsBody(body, nativeRouting, refreshed); - } -}; - function showVoiceUI() { if (voiceUiContainer) return; @@ -1702,9 +1009,6 @@ function showVoiceUI() { - ${(isMobileVoiceDevice() && getNativeVoiceRouting()) ? `` : ""} @@ -2159,63 +1463,8 @@ function toggleVoiceMic() { } } -// Tears down only the transport-level state (socket, remote streams, key), -// keeping the UI, mic stream and AudioContext alive for an automatic rejoin. -function teardownVoiceTransport() { - if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } - if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } - if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; } - if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; } - if (currentVoiceWs) { - try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {} - currentVoiceWs = null; - } - for (const decoderId of Object.keys(remoteVideos)) { - try { if (remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {} - } - remoteVideos = {}; - videoDecryptChains = {}; - remoteAudio = {}; - audioDecryptChains = {}; - peerLastSeen = {}; - deadPeers.clear(); - if (receiverNode) receiverNode.port.postMessage({action: 'clear'}); - if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'}); - voiceAesKeyBytes = null; - voiceAesCryptoKey = null; - lastVoiceServerPacketAt = Date.now(); -} - -function scheduleVoiceReconnect() { - if (!currentVoiceCallId || !currentVoiceCallType) { - // Real hangup already ran its cleanup. - return; - } - if (voiceReconnectAttempts >= VOICE_MAX_RECONNECTS) { - showBlahNotification("error:voice.reconnect.failed"); - endVoiceSession(); - return; - } - voiceReconnectAttempts++; - setVoiceStatus("connecting"); - teardownVoiceTransport(); - setTimeout(async () => { - if (!currentVoiceCallId || !currentVoiceCallType) return; - try { - // Tickets are single-use - every reconnect needs a fresh one. - const joined = await requestVoiceJoin(); - if (!joined) { endVoiceSession(); return; } - await startVoiceSession(joined.serverUrl, joined.ticket); - } catch (err) { - console.warn("Voice reconnect failed", err); - scheduleVoiceReconnect(); - } - }, 800); -} - function endVoiceSession() { setVoiceStatus("disconnected"); - hideVoiceDeviceSettings(); const nativeRouting = getNativeVoiceRouting(); if (nativeRouting && nativeRouting.stopCall) { @@ -2241,10 +1490,10 @@ function endVoiceSession() { if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; } if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; } - if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } - if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } - peerLastSeen = {}; - deadPeers.clear(); + if (window.fetchUsersInterval) { + clearInterval(window.fetchUsersInterval); + window.fetchUsersInterval = null; + } for (let id in audioDecoders) { try { audioDecoders[id].close(); } catch(e){} @@ -2323,8 +1572,6 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode.port.onmessage = async (e) => { if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return; - // Same backpressure valve as the mic sender. - if (currentVoiceWs.bufferedAmount > 256 * 1024) return; const iv = crypto.getRandomValues(new Uint8Array(12)); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData); const payload = new Uint8Array(14 + cipher.byteLength); @@ -2336,9 +1583,8 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { } const getCurrentConfig = () => { - const mediaCfg = getVoiceMediaConfig(typeId); - const fps = mediaCfg.fps; - const resVal = mediaCfg.res; + const fps = typeId === 1 ? 24 : 30; + const resVal = typeId === 1 ? "1280x720" : "1920x1080"; const settings = videoTrack.getSettings(); let sourceWidth = settings.width || 1280, sourceHeight = settings.height || 720, width, height; if (resVal === "source") { width = sourceWidth; height = sourceHeight; } else { @@ -2347,11 +1593,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { if (ratio < 1) { width = sourceWidth * ratio; height = sourceHeight * ratio; } else { width = sourceWidth; height = sourceHeight; } } width = Math.floor(width / 2) * 2; height = Math.floor(height / 2) * 2; - // A WebSocket relay saturates fast: uncapped w*h*fps bitrates - // (several Mb/s) choke the shared call socket, which stalls audio - // for everyone. Keep generous-but-safe ceilings per stream type. - const bitrate = Math.min(Math.floor(width * height * fps * 0.07), typeId === 1 ? 2_000_000 : 3_000_000); - return { width, height, fps, bitrate }; + return { width, height, fps, bitrate: Math.floor(width * height * fps * 0.15) }; }; let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; @@ -2368,30 +1610,16 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { }); encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); - // Transient capture-mode wobbles must not thrash the encoder - - // commit only after ~300 ms stable. Camera size/fps changes arrive - // via a clean reopen (see buildQualityFpsGrid); the screen share - // surface is never re-constrained, the encoder just rescales. - let pendingCfg = null, pendingAt = 0; - const cfgEquals = (a, b) => !!a && !!b && a.width === b.width && a.height === b.height && a.fps === b.fps; + const encodeLoop = () => { + if (!isRunning) return; + const newConfig = getCurrentConfig(); + let forceKeyframe = false; + if (newConfig.width !== currentConfig.width || newConfig.height !== currentConfig.height || newConfig.fps !== currentConfig.fps) { + currentConfig = newConfig; forceKeyframe = true; frameCount = 0; + encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); + } - const encodeLoop = () => { - if (!isRunning) return; - const newConfig = getCurrentConfig(); - let forceKeyframe = false; - if (!cfgEquals(newConfig, currentConfig)) { - if (!cfgEquals(newConfig, pendingCfg)) { - pendingCfg = newConfig; - pendingAt = performance.now(); - } else if (performance.now() - pendingAt > 300) { - currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null; - encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'}); - } - } else if (pendingCfg) { - pendingCfg = null; // wiggle reverted on its own - ignore - } - - if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) { + if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 512 && videoEl.readyState >= 2) { let frame = null; try { frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)}); @@ -2436,40 +1664,14 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { } catch (e) { console.error(e); alert(processBlah(`:error.call.unsupported:${typeLabel}`)); return null; } } -// Opens the camera honoring the saved device/quality. A saved deviceId can -// become stale (device list changes across sessions) - fall back to the -// default camera instead of failing with OverconstrainedError. -async function getVoiceCamStream() { - const camCfg = getVoiceMediaConfig(1); - const constraintsFor = (deviceId) => { - const video = {frameRate: {ideal: camCfg.fps}}; - if (camCfg.res !== "source") { - const [w, h] = camCfg.res.split('x').map(Number); - // Single-axis ideal: no implied aspect ratio, the camera keeps its - // native one and only the resolution tier changes. - video.width = {ideal: Math.max(w, h)}; - } - if (deviceId) video.deviceId = {exact: deviceId}; - return {video}; - }; - try { - return await navigator.mediaDevices.getUserMedia(constraintsFor(voiceSettings.camId)); - } catch (e) { - if (!voiceSettings.camId) throw e; - voiceSettings.camId = ""; - saveVoiceSettings(); - return navigator.mediaDevices.getUserMedia(constraintsFor(undefined)); - } -} - function toggleVoiceCam() { let btn = document.getElementById("voice-btn-cam"); if (camControl) { camControl.stop(); } else { - getVoiceCamStream().then(stream => { + navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl); - }).catch(err => alert(processBlah(`:error.call.camera:${err.message || err.name || "?"}`))); + }).catch(err => alert(processBlah(`:error.call.camera:${err.message}`))); } } @@ -2483,6 +1685,6 @@ function toggleVoiceScreen() { video: true, audio: { autoGainControl: false, echoCancellation: false, noiseSuppression: false }, systemAudio: "include", selfBrowserSurface: "exclude" }).then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Screen", 2).then(ctrl => screenControl = ctrl); - }).catch(err => alert(processBlah(`:error.call.screen:${err.message || err.name || "?"}`))); + }).catch(err => alert(processBlah(`:error.call.screen:${err.message}`))); } }