diff --git a/android/app/build.gradle b/android/app/build.gradle index 1a35b416..dca32d25 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 3 - versionName "0.1.2" + versionCode 4 + versionName "0.1.3" 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 3fba6c40..11a435fd 100644 --- a/android/app/src/main/assets/public/blah/en-cat.json +++ b/android/app/src/main/assets/public/blah/en-cat.json @@ -310,5 +310,17 @@ "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" + "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" } 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 20cfaee5..79530aeb 100644 --- a/android/app/src/main/assets/public/blah/en-us.json +++ b/android/app/src/main/assets/public/blah/en-us.json @@ -319,5 +319,17 @@ "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" + "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" } 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 0df5e471..c951e60e 100644 --- a/android/app/src/main/assets/public/blah/pl-pl.json +++ b/android/app/src/main/assets/public/blah/pl-pl.json @@ -319,5 +319,17 @@ "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" + "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" } diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index f514589c..ff092762 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -2472,6 +2472,7 @@ let touchEndX = 0; let touchStartY = 0; let touchEndY = 0; let touchMoved = false; +let touchStartTarget = null; let pressDate; @@ -2479,6 +2480,7 @@ 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) { @@ -2558,6 +2560,18 @@ 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; @@ -2566,6 +2580,7 @@ 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 d7bc91be..3247c504 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -1715,6 +1715,230 @@ 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 e9331a1b..5b1bb6df 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.2"; +const CLIENT_VERSION = "0.1.3"; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index 837776ea..f1257296 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -27,6 +27,50 @@ 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 lastVoiceServerUrl = null; + +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; @@ -54,6 +98,75 @@ 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; @@ -155,6 +268,7 @@ async function replugVoiceOutputElement() { voiceOutputAudio.srcObject = null; voiceOutputAudio.srcObject = voiceRemoteStream; try { await voiceOutputAudio.play(); } catch (e) {} + applyVoiceSpeakerSink(); } function fallbackVoiceToMediaOutput() { @@ -244,6 +358,7 @@ function setupVoiceOutput() { } voiceOutputAudio.srcObject = voiceRemoteStream; voiceOutputAudio.play().catch(() => {}); + applyVoiceSpeakerSink(); }; document.addEventListener("click", voicePlayRetry, true); window.addEventListener("voicemodechanged", voiceModeChangedHandler); @@ -377,6 +492,45 @@ 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); @@ -513,6 +667,7 @@ async function initiateVoiceCall(roomId = null) { } async function startVoiceSession(serverUrl) { + lastVoiceServerUrl = serverUrl; showVoiceUI(); updateVoiceUIUsers(); setVoiceStatus("connecting"); @@ -548,7 +703,13 @@ async function startVoiceSession(serverUrl) { const usersRaw = await res.json(); voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); - const users = usersRaw.map(u => String(u)); + 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)); let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -559,9 +720,18 @@ async function startVoiceSession(serverUrl) { }; fetchUsers(); voiceWsHeartbeat = setInterval(fetchUsers, 5000); - - const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}}; - micStream = await navigator.mediaDevices.getUserMedia(micConstraints); + + 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 nativeRouting = getNativeVoiceRouting(); if (nativeRouting && nativeRouting.startCall) { @@ -578,20 +748,34 @@ async function startVoiceSession(serverUrl) { 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.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; + } const {audioData, db} = e.data; - const sensitivity = -65; + const sensitivity = voiceSettings.sensitivity; 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; @@ -600,6 +784,7 @@ async function startVoiceSession(serverUrl) { 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)); @@ -609,6 +794,7 @@ async function startVoiceSession(serverUrl) { 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)'; } }; }; @@ -617,17 +803,24 @@ async function startVoiceSession(serverUrl) { 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)); + voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(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); @@ -739,8 +932,16 @@ async function startVoiceSession(serverUrl) { } }; - currentVoiceWs.onclose = () => { - endVoiceSession(); + currentVoiceWs.onclose = (ev) => { + // 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; + } + scheduleVoiceReconnect(); }; } catch(err) { @@ -756,7 +957,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)); + state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); } voiceActiveUsers = state; @@ -977,6 +1178,424 @@ 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; @@ -1009,6 +1628,9 @@ function showVoiceUI() { + ${(isMobileVoiceDevice() && getNativeVoiceRouting()) ? `` : ""} @@ -1463,8 +2085,60 @@ 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 || !lastVoiceServerUrl) { + // 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 || !lastVoiceServerUrl) return; + try { + await startVoiceSession(lastVoiceServerUrl); + } catch (err) { + console.warn("Voice reconnect failed", err); + scheduleVoiceReconnect(); + } + }, 800); +} + function endVoiceSession() { setVoiceStatus("disconnected"); + hideVoiceDeviceSettings(); const nativeRouting = getNativeVoiceRouting(); if (nativeRouting && nativeRouting.stopCall) { @@ -1490,10 +2164,10 @@ function endVoiceSession() { if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; } if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; } - if (window.fetchUsersInterval) { - clearInterval(window.fetchUsersInterval); - window.fetchUsersInterval = null; - } + if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } + if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } + peerLastSeen = {}; + deadPeers.clear(); for (let id in audioDecoders) { try { audioDecoders[id].close(); } catch(e){} @@ -1572,6 +2246,8 @@ 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); @@ -1583,8 +2259,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { } const getCurrentConfig = () => { - const fps = typeId === 1 ? 24 : 30; - const resVal = typeId === 1 ? "1280x720" : "1920x1080"; + const mediaCfg = getVoiceMediaConfig(typeId); + const fps = mediaCfg.fps; + const resVal = mediaCfg.res; const settings = videoTrack.getSettings(); let sourceWidth = settings.width || 1280, sourceHeight = settings.height || 720, width, height; if (resVal === "source") { width = sourceWidth; height = sourceHeight; } else { @@ -1593,7 +2270,11 @@ 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; - return { width, height, fps, bitrate: Math.floor(width * height * fps * 0.15) }; + // 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 }; }; let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; @@ -1610,16 +2291,30 @@ 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'}); - 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'}); - } + // 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; - if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 512 && videoEl.readyState >= 2) { + 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) { let frame = null; try { frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)}); @@ -1664,14 +2359,40 @@ 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 { - navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => { + getVoiceCamStream().then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl); - }).catch(err => alert(processBlah(`:error.call.camera:${err.message}`))); + }).catch(err => alert(processBlah(`:error.call.camera:${err.message || err.name || "?"}`))); } } @@ -1685,6 +2406,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}`))); + }).catch(err => alert(processBlah(`:error.call.screen:${err.message || err.name || "?"}`))); } } diff --git a/electron/package.json b/electron/package.json index 0c4b8636..54499e4e 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "Miarven", - "version": "0.1.2", + "version": "0.1.3", "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 306cc218..91f146a7 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 2e49c819..1da30ac8 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 2998eaa6..cc65ba99 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 cecd34b5..77e2f54b 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 1c8a122b..716acfed 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 7c2ae85f..6b0b8f3f 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 8f05de9f..a5259da6 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 dec4de0a..1016c798 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 544cf3c3..a819218d 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 a2f9e0ab..5f127147 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 fa4c8689..fe915a96 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 f4c10616..c052ced2 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 559f1fee..873124dc 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 8a786ece..60533c63 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 4f43add4..22c5a8ea 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 bee24b99..e4614aa9 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 3c96cf93..4cdc9649 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 7e700c51..5e2646de 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 ccd58342..381e26dd 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 200270d3..452ac2c6 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 f4c10616..c052ced2 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 559f1fee..873124dc 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 8a786ece..60533c63 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 4f43add4..22c5a8ea 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 bee24b99..e4614aa9 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 3c96cf93..4cdc9649 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 7e700c51..5e2646de 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 ccd58342..381e26dd 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 a98c6520..80767010 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 b4c6d4da..c47e7aba 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 bc2cd00b..c11fe808 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 349b690c..ca8dd2ba 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 30b3fc75..3af13e3a 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 66bcd0c7..a369165b 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 51db8efc..2acdc18a 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) { @@ -2558,6 +2560,18 @@ 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; @@ -2566,6 +2580,7 @@ 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 d7bc91be..3247c504 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -1715,6 +1715,230 @@ 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 e9331a1b..5b1bb6df 100644 --- a/webroot/version.js +++ b/webroot/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.1.2"; +const CLIENT_VERSION = "0.1.3"; diff --git a/webroot/voice.js b/webroot/voice.js index 837776ea..f1257296 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -27,6 +27,50 @@ 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 lastVoiceServerUrl = null; + +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; @@ -54,6 +98,75 @@ 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; @@ -155,6 +268,7 @@ async function replugVoiceOutputElement() { voiceOutputAudio.srcObject = null; voiceOutputAudio.srcObject = voiceRemoteStream; try { await voiceOutputAudio.play(); } catch (e) {} + applyVoiceSpeakerSink(); } function fallbackVoiceToMediaOutput() { @@ -244,6 +358,7 @@ function setupVoiceOutput() { } voiceOutputAudio.srcObject = voiceRemoteStream; voiceOutputAudio.play().catch(() => {}); + applyVoiceSpeakerSink(); }; document.addEventListener("click", voicePlayRetry, true); window.addEventListener("voicemodechanged", voiceModeChangedHandler); @@ -377,6 +492,45 @@ 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); @@ -513,6 +667,7 @@ async function initiateVoiceCall(roomId = null) { } async function startVoiceSession(serverUrl) { + lastVoiceServerUrl = serverUrl; showVoiceUI(); updateVoiceUIUsers(); setVoiceStatus("connecting"); @@ -548,7 +703,13 @@ async function startVoiceSession(serverUrl) { const usersRaw = await res.json(); voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); - const users = usersRaw.map(u => String(u)); + 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)); let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -559,9 +720,18 @@ async function startVoiceSession(serverUrl) { }; fetchUsers(); voiceWsHeartbeat = setInterval(fetchUsers, 5000); - - const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}}; - micStream = await navigator.mediaDevices.getUserMedia(micConstraints); + + 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 nativeRouting = getNativeVoiceRouting(); if (nativeRouting && nativeRouting.startCall) { @@ -578,20 +748,34 @@ async function startVoiceSession(serverUrl) { 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.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; + } const {audioData, db} = e.data; - const sensitivity = -65; + const sensitivity = voiceSettings.sensitivity; 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; @@ -600,6 +784,7 @@ async function startVoiceSession(serverUrl) { 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)); @@ -609,6 +794,7 @@ async function startVoiceSession(serverUrl) { 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)'; } }; }; @@ -617,17 +803,24 @@ async function startVoiceSession(serverUrl) { 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)); + voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(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); @@ -739,8 +932,16 @@ async function startVoiceSession(serverUrl) { } }; - currentVoiceWs.onclose = () => { - endVoiceSession(); + currentVoiceWs.onclose = (ev) => { + // 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; + } + scheduleVoiceReconnect(); }; } catch(err) { @@ -756,7 +957,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)); + state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); } voiceActiveUsers = state; @@ -977,6 +1178,424 @@ 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; @@ -1009,6 +1628,9 @@ function showVoiceUI() { + ${(isMobileVoiceDevice() && getNativeVoiceRouting()) ? `` : ""} @@ -1463,8 +2085,60 @@ 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 || !lastVoiceServerUrl) { + // 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 || !lastVoiceServerUrl) return; + try { + await startVoiceSession(lastVoiceServerUrl); + } catch (err) { + console.warn("Voice reconnect failed", err); + scheduleVoiceReconnect(); + } + }, 800); +} + function endVoiceSession() { setVoiceStatus("disconnected"); + hideVoiceDeviceSettings(); const nativeRouting = getNativeVoiceRouting(); if (nativeRouting && nativeRouting.stopCall) { @@ -1490,10 +2164,10 @@ function endVoiceSession() { if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; } if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; } - if (window.fetchUsersInterval) { - clearInterval(window.fetchUsersInterval); - window.fetchUsersInterval = null; - } + if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } + if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } + peerLastSeen = {}; + deadPeers.clear(); for (let id in audioDecoders) { try { audioDecoders[id].close(); } catch(e){} @@ -1572,6 +2246,8 @@ 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); @@ -1583,8 +2259,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) { } const getCurrentConfig = () => { - const fps = typeId === 1 ? 24 : 30; - const resVal = typeId === 1 ? "1280x720" : "1920x1080"; + const mediaCfg = getVoiceMediaConfig(typeId); + const fps = mediaCfg.fps; + const resVal = mediaCfg.res; const settings = videoTrack.getSettings(); let sourceWidth = settings.width || 1280, sourceHeight = settings.height || 720, width, height; if (resVal === "source") { width = sourceWidth; height = sourceHeight; } else { @@ -1593,7 +2270,11 @@ 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; - return { width, height, fps, bitrate: Math.floor(width * height * fps * 0.15) }; + // 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 }; }; let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; @@ -1610,16 +2291,30 @@ 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'}); - 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'}); - } + // 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; - if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 512 && videoEl.readyState >= 2) { + 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) { let frame = null; try { frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)}); @@ -1664,14 +2359,40 @@ 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 { - navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => { + getVoiceCamStream().then(stream => { startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl); - }).catch(err => alert(processBlah(`:error.call.camera:${err.message}`))); + }).catch(err => alert(processBlah(`:error.call.camera:${err.message || err.name || "?"}`))); } } @@ -1685,6 +2406,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}`))); + }).catch(err => alert(processBlah(`:error.call.screen:${err.message || err.name || "?"}`))); } }