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