0.1.3 - voice chat settings

- added voice chat settings
- fixed gestures on popups
This commit is contained in:
olcxja 2026-08-24 10:54:36 +02:00
commit cb067f3785
71 changed files with 2069 additions and 77 deletions

View file

@ -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.

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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 {

View file

@ -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 <body>, 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;

View file

@ -1 +1 @@
const CLIENT_VERSION = "0.1.2";
const CLIENT_VERSION = "0.1.3";

View file

@ -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
// <body> (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 = `
<label>${processBlah('label.voice.sensitivity')} <span id="vds-sens-val">${voiceSettings.sensitivity} dB</span></label>
<div class="vds-sens-meter">
<div class="vds-sens-fill" id="vds-sens-fill"></div>
<div class="vds-sens-mark" id="vds-sens-mark" style="left: ${sensPercent}%"></div>
<input type="range" id="vds-sens-slider" min="-80" max="0" step="1" value="${voiceSettings.sensitivity}">
</div>
`;
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 = `
<div id="vds-header">
<span>${ICONS.settings({width: "1rem", height: "1rem"})} ${processBlah("action.call.settings")}</span>
<button id="vds-close">${ICONS.close({width: "1rem", height: "1rem"})}</button>
</div>
<div id="vds-body"></div>
`;
// 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() {
<button id="voice-btn-screen" class="voice-btn" onclick="toggleVoiceScreen()" title="${processBlah("action.call.screen")}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
</button>
<button id="voice-btn-settings" class="voice-btn" onclick="showVoiceDeviceSettings(event)" title="${processBlah("action.call.settings")}">
${ICONS.settings({width: "1.3rem", height: "1.3rem"})}
</button>
${(isMobileVoiceDevice() && getNativeVoiceRouting()) ? `<button id="voice-btn-speaker" class="voice-btn${isVoiceSpeakerMode ? " voice-btn-active" : ""}" onclick="toggleVoiceSpeakerMode()" title="${processBlah("action.call.speaker")}">
${ICONS.speaker({width: "1.4rem", height: "1.4rem"})}
</button>` : ""}
@ -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 || "?"}`)));
}
}

View file

@ -1,6 +1,6 @@
{
"name": "Miarven",
"version": "0.1.2",
"version": "0.1.3",
"description": "An Amazing Capacitor App",
"author": {
"name": "",

View file

@ -1,9 +1,9 @@
<libraries>
<library
name=":@@:capacitor-android::release"
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/libs/R.jar"
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/libs/R.jar"
resolved="android:capacitor-android:unspecified"
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out"
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out"
manifest="AndroidManifest.xml"
resFolder="res"
assetsFolder="assets"

View file

@ -1,9 +1,9 @@
<libraries>
<library
name=":@@:capacitor-android::release"
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/libs/R.jar"
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/libs/R.jar"
resolved="android:capacitor-android:unspecified"
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out"
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out"
manifest="AndroidManifest.xml"
resFolder="res"
assetsFolder="assets"

View file

@ -1,6 +1,6 @@
{
"name": "olcxja.miarven",
"version": "0.1.2",
"version": "0.1.3",
"description": "Larpix client",
"license": "AGPL-3.0",
"author": "olcxja's softworks",

View file

@ -1,5 +1,5 @@
{
"versionCode": 3,
"versionName": "0.1.2",
"npmVersion": "0.1.2"
"versionCode": 4,
"versionName": "0.1.3",
"npmVersion": "0.1.3"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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 {

View file

@ -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 <body>, 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;

View file

@ -1 +1 @@
const CLIENT_VERSION = "0.1.2";
const CLIENT_VERSION = "0.1.3";

View file

@ -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
// <body> (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 = `
<label>${processBlah('label.voice.sensitivity')} <span id="vds-sens-val">${voiceSettings.sensitivity} dB</span></label>
<div class="vds-sens-meter">
<div class="vds-sens-fill" id="vds-sens-fill"></div>
<div class="vds-sens-mark" id="vds-sens-mark" style="left: ${sensPercent}%"></div>
<input type="range" id="vds-sens-slider" min="-80" max="0" step="1" value="${voiceSettings.sensitivity}">
</div>
`;
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 = `
<div id="vds-header">
<span>${ICONS.settings({width: "1rem", height: "1rem"})} ${processBlah("action.call.settings")}</span>
<button id="vds-close">${ICONS.close({width: "1rem", height: "1rem"})}</button>
</div>
<div id="vds-body"></div>
`;
// 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() {
<button id="voice-btn-screen" class="voice-btn" onclick="toggleVoiceScreen()" title="${processBlah("action.call.screen")}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
</button>
<button id="voice-btn-settings" class="voice-btn" onclick="showVoiceDeviceSettings(event)" title="${processBlah("action.call.settings")}">
${ICONS.settings({width: "1.3rem", height: "1.3rem"})}
</button>
${(isMobileVoiceDevice() && getNativeVoiceRouting()) ? `<button id="voice-btn-speaker" class="voice-btn${isVoiceSpeakerMode ? " voice-btn-active" : ""}" onclick="toggleVoiceSpeakerMode()" title="${processBlah("action.call.speaker")}">
${ICONS.speaker({width: "1.4rem", height: "1.4rem"})}
</button>` : ""}
@ -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 || "?"}`)));
}
}