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 = `
+
+
+ `;
+ // 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 = `
+
+
+ `;
+ // 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 || "?"}`)));
}
}