0.1.3 - voice chat settings
- added voice chat settings - fixed gestures on popups
This commit is contained in:
parent
61e2911706
commit
cb067f3785
71 changed files with 2069 additions and 77 deletions
|
|
@ -310,5 +310,17 @@
|
|||
"error.call.screen": "screen share error: {0} :c",
|
||||
"error.call.screen.unsupported": "ur browser doesn't support screen sharing :c",
|
||||
"label.stream.cam": "cam",
|
||||
"label.stream.screen": "screen"
|
||||
"label.stream.screen": "screen",
|
||||
"action.call.settings": "voice settings :3",
|
||||
"label.voice.microphone": "microphone",
|
||||
"label.voice.speaker": "speaker",
|
||||
"label.voice.camera": "camera",
|
||||
"label.voice.camera.quality": "camera quality",
|
||||
"label.voice.camera.fps": "camera fps",
|
||||
"label.voice.screen.quality": "screen quality",
|
||||
"label.voice.screen.fps": "screen fps",
|
||||
"label.voice.sensitivity": "input sensitivity",
|
||||
"label.voice.default": "default",
|
||||
"error.call.microphone": "microphone error :c",
|
||||
"error.voice.reconnect.failed": "voice connection lost :c"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,5 +319,17 @@
|
|||
"error.call.screen": "Screen share error: {0}",
|
||||
"error.call.screen.unsupported": "Screen sharing is not supported on this browser.",
|
||||
"label.stream.cam": "Cam",
|
||||
"label.stream.screen": "Screen"
|
||||
"label.stream.screen": "Screen",
|
||||
"action.call.settings": "Voice settings",
|
||||
"label.voice.microphone": "Microphone",
|
||||
"label.voice.speaker": "Speaker",
|
||||
"label.voice.camera": "Camera",
|
||||
"label.voice.camera.quality": "Camera quality",
|
||||
"label.voice.camera.fps": "Camera FPS",
|
||||
"label.voice.screen.quality": "Screenshare quality",
|
||||
"label.voice.screen.fps": "Screenshare FPS",
|
||||
"label.voice.sensitivity": "Input sensitivity",
|
||||
"label.voice.default": "Default",
|
||||
"error.call.microphone": "Microphone error",
|
||||
"error.voice.reconnect.failed": "Voice connection lost"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,5 +319,17 @@
|
|||
"error.call.screen": "Błąd udostępniania ekranu: {0}",
|
||||
"error.call.screen.unsupported": "Udostępnianie ekranu nie jest obsługiwane w tej przeglądarce.",
|
||||
"label.stream.cam": "Kamera",
|
||||
"label.stream.screen": "Ekran"
|
||||
"label.stream.screen": "Ekran",
|
||||
"action.call.settings": "Ustawienia rozmowy",
|
||||
"label.voice.microphone": "Mikrofon",
|
||||
"label.voice.speaker": "Głośnik",
|
||||
"label.voice.camera": "Kamera",
|
||||
"label.voice.camera.quality": "Jakość kamery",
|
||||
"label.voice.camera.fps": "FPS kamery",
|
||||
"label.voice.screen.quality": "Jakość ekranu",
|
||||
"label.voice.screen.fps": "FPS ekranu",
|
||||
"label.voice.sensitivity": "Czułość wejścia",
|
||||
"label.voice.default": "Domyślne",
|
||||
"error.call.microphone": "Błąd mikrofonu",
|
||||
"error.voice.reconnect.failed": "Utracono połączenie głosowe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2472,6 +2472,7 @@ let touchEndX = 0;
|
|||
let touchStartY = 0;
|
||||
let touchEndY = 0;
|
||||
let touchMoved = false;
|
||||
let touchStartTarget = null;
|
||||
|
||||
let pressDate;
|
||||
|
||||
|
|
@ -2479,6 +2480,7 @@ document.addEventListener('touchstart', e => {
|
|||
touchStartX = e.changedTouches[0].screenX;
|
||||
touchStartY = e.changedTouches[0].screenY;
|
||||
touchMoved = false;
|
||||
touchStartTarget = e.target;
|
||||
|
||||
activeTouchButton = e.target.closest('button, input, textarea');
|
||||
if (activeTouchButton) {
|
||||
|
|
@ -2558,6 +2560,18 @@ document.addEventListener('contextmenu', e => {
|
|||
}
|
||||
});
|
||||
|
||||
// Swipes that begin inside floating layers (popups, dialogs, dropdowns -
|
||||
// anything rendered with position: fixed) belong to those layers and must
|
||||
// not trigger app navigation gestures.
|
||||
function touchStartedInFloatingLayer() {
|
||||
let el = touchStartTarget;
|
||||
while (el && el !== document.body && el !== document.documentElement) {
|
||||
if (getComputedStyle(el).position === 'fixed') return true;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMobileSwipe() {
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
if (window.innerWidth > 55 * rem) return;
|
||||
|
|
@ -2566,6 +2580,7 @@ function handleMobileSwipe() {
|
|||
let diffY = touchEndY - touchStartY;
|
||||
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 4 * rem) {
|
||||
if (touchStartedInFloatingLayer()) return;
|
||||
if (diffX > 0) {
|
||||
mobileNavBack();
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1715,6 +1715,230 @@ input:checked + .toggle-slider:before {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
/* In-call Voice Device Settings Popup */
|
||||
#voice-device-settings {
|
||||
position: fixed;
|
||||
/* left/top are set from JS (centered, viewport-clamped) so the opening
|
||||
animation cannot shift the popup and the header can drag it directly */
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 3rem);
|
||||
background: var(--main-bg-color);
|
||||
border: var(--border-width) solid var(--light-border-color);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
|
||||
z-index: 10020;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.92);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
#voice-device-settings.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
#vds-header {
|
||||
height: 2.8rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.6rem 0 0.9375rem;
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: var(--border-width) solid var(--light-border-color);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
#vds-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
#vds-header span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
#vds-header svg {
|
||||
pointer-events: none !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#vds-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--darker-text-color);
|
||||
border-radius: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
#vds-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-color);
|
||||
}
|
||||
#vds-body {
|
||||
padding: 0.4rem 0.7rem 0.8rem;
|
||||
overflow-y: auto;
|
||||
touch-action: pan-y;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.vds-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.1rem;
|
||||
}
|
||||
.vds-row > label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--darker-text-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.vds-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.1rem;
|
||||
}
|
||||
.vds-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.vds-cell > label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--darker-text-color);
|
||||
}
|
||||
#voice-device-settings .custom-select {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 2.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Floating option layer for the settings selects - lives on <body>, so it can
|
||||
escape the popup bounds and is unaffected by the popup transform/scrolling.
|
||||
Explicit solid background: never translucent over call content. */
|
||||
.custom-select-options.vds-floating {
|
||||
position: fixed;
|
||||
display: block;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
background-color: var(--main-bg-color);
|
||||
z-index: 10040;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(-0.4rem);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease,
|
||||
visibility 0.15s;
|
||||
}
|
||||
.custom-select-options.vds-floating.vds-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.vds-sens-meter {
|
||||
position: relative;
|
||||
height: 1.9rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.55rem;
|
||||
overflow: hidden;
|
||||
border: var(--border-width) solid var(--light-border-color);
|
||||
}
|
||||
.vds-sens-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transition: width 0.05s linear, background-color 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vds-sens-mark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Settings button keeps its hover look while the settings popup is open */
|
||||
#voice-btn-settings.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
#vds-sens-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
border: 0;
|
||||
border-radius: 0.55rem;
|
||||
/* pan-y on the popup body would eat horizontal touch drags */
|
||||
touch-action: none;
|
||||
z-index: 2;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
/* The app-wide `input:active` press effect must not shrink this slider. */
|
||||
#vds-sens-slider:active,
|
||||
#vds-sens-slider.is-pressed {
|
||||
transform: none !important;
|
||||
}
|
||||
#vds-sens-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
background: var(--text-color);
|
||||
border-radius: 0.125rem;
|
||||
box-shadow: 0 0 0.3rem rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
#vds-sens-slider::-webkit-slider-thumb:hover,
|
||||
#vds-sens-slider::-webkit-slider-thumb:active {
|
||||
transform: none !important;
|
||||
}
|
||||
#vds-sens-slider::-moz-range-thumb {
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
background: var(--text-color);
|
||||
border: none;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
#vds-sens-slider::-moz-range-thumb:hover,
|
||||
#vds-sens-slider::-moz-range-thumb:active {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
#voice-call-popup {
|
||||
width: 16rem;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
const CLIENT_VERSION = "0.1.2";
|
||||
const CLIENT_VERSION = "0.1.3";
|
||||
|
|
|
|||
779
webroot/voice.js
779
webroot/voice.js
|
|
@ -27,6 +27,50 @@ let isVoiceSpeakerMode = false;
|
|||
|
||||
const SPEAKING_HANGOVER_MS = 400;
|
||||
|
||||
// ===== Voice connection resilience =====
|
||||
// Every client broadcasts a tiny encrypted ping so peers can tell "quiet
|
||||
// user" apart from "crashed/half-open socket". Peers silent for too long are
|
||||
// dropped locally; a dead server socket triggers an automatic rejoin.
|
||||
const VOICE_PEER_TIMEOUT_MS = 7000;
|
||||
const VOICE_PING_INTERVAL_MS = 2000;
|
||||
const VOICE_SERVER_TIMEOUT_MS = 10000;
|
||||
const VOICE_MAX_RECONNECTS = 2;
|
||||
let peerLastSeen = {};
|
||||
let deadPeers = new Set();
|
||||
let lastVoiceServerPacketAt = 0;
|
||||
let voicePingTimer = null;
|
||||
let voiceReconnectAttempts = 0;
|
||||
let lastVoiceServerUrl = null;
|
||||
|
||||
function cleanupPeerMedia(userId) {
|
||||
for (const aid of [userId, `${userId}_screen`]) {
|
||||
if (remoteAudio[aid]) {
|
||||
if (audioDecryptChains[aid]) audioDecryptChains[aid].clear();
|
||||
delete remoteAudio[aid];
|
||||
delete audioDecryptChains[aid];
|
||||
}
|
||||
}
|
||||
for (const decoderId of Object.keys(remoteVideos)) {
|
||||
if (decoderId.split('-')[0] === userId) {
|
||||
try { if (remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {}
|
||||
if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
|
||||
delete remoteVideos[decoderId];
|
||||
delete videoDecryptChains[decoderId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendVoicePing() {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
|
||||
currentVoiceWs.send(payload.buffer);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
let voiceDragState = false;
|
||||
let voiceResizeAbort = null;
|
||||
|
||||
|
|
@ -54,6 +98,75 @@ function getStoredVolume(type, clientId) {
|
|||
return v === undefined ? 100 : v;
|
||||
}
|
||||
|
||||
// Voice device/media settings - persisted locally, edited from the in-call
|
||||
// settings popup (microphone, speaker, camera, quality/fps, mic sensitivity).
|
||||
const VOICE_SETTINGS_KEY = "larpix_voice_settings";
|
||||
const VOICE_SENSITIVITY_DEFAULT = -65;
|
||||
const VOICE_RES_OPTIONS = [
|
||||
{ value: "480x360", label: "360p" },
|
||||
{ value: "640x480", label: "480p" },
|
||||
{ value: "1280x720", label: "720p" },
|
||||
{ value: "1920x1080", label: "1080p" },
|
||||
{ value: "source", label: "SOURCE" }
|
||||
];
|
||||
const VOICE_FPS_OPTIONS = [15, 24, 30, 60];
|
||||
let voiceSettings = loadVoiceSettings();
|
||||
|
||||
function loadVoiceSettings() {
|
||||
const defaults = { micId: "", camId: "", speakerId: "", camRes: "1280x720", camFps: 24, screenRes: "1920x1080", screenFps: 30, sensitivity: VOICE_SENSITIVITY_DEFAULT };
|
||||
try {
|
||||
return Object.assign(defaults, JSON.parse(localStorage.getItem(VOICE_SETTINGS_KEY)) || {});
|
||||
} catch (e) {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
function saveVoiceSettings() {
|
||||
try { localStorage.setItem(VOICE_SETTINGS_KEY, JSON.stringify(voiceSettings)); } catch (e) {}
|
||||
}
|
||||
|
||||
function getVoiceMediaConfig(typeId) {
|
||||
return typeId === 1
|
||||
? { res: voiceSettings.camRes, fps: parseInt(voiceSettings.camFps) || 24 }
|
||||
: { res: voiceSettings.screenRes, fps: parseInt(voiceSettings.screenFps) || 30 };
|
||||
}
|
||||
|
||||
// Camera quality presets are treated as single-axis quality tiers (e.g. the
|
||||
// longer edge of "1280x720"): the camera always opens at its NATIVE aspect
|
||||
// ratio and is never steered toward a preset's own ratio like 4:3. Nothing is
|
||||
// remembered - the actual geometry is detected from the live track settings
|
||||
// every time (encoder config follows it automatically).
|
||||
|
||||
function buildVoiceMicConstraints(deviceId) {
|
||||
const constraints = { audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } };
|
||||
if (deviceId) constraints.audio.deviceId = { exact: deviceId };
|
||||
return constraints;
|
||||
}
|
||||
|
||||
async function switchVoiceMic(deviceId) {
|
||||
if (!audioContext || !senderNode) return;
|
||||
let newStream;
|
||||
try {
|
||||
newStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(deviceId));
|
||||
} catch (e) {
|
||||
showBlahNotification("error:call.microphone");
|
||||
return;
|
||||
}
|
||||
if (micStream) micStream.getTracks().forEach(t => t.stop());
|
||||
micStream = newStream;
|
||||
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} }
|
||||
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
||||
micSourceNode.connect(senderNode);
|
||||
}
|
||||
|
||||
// Output device selection only applies where the web layer owns the output.
|
||||
// With the native Android routing the OS owns the call audio path entirely.
|
||||
async function applyVoiceSpeakerSink() {
|
||||
if (getNativeVoiceRouting() || !voiceSettings.speakerId) return;
|
||||
try { if (audioContext && audioContext.setSinkId) await audioContext.setSinkId(voiceSettings.speakerId); } catch (e) {}
|
||||
try { if (voiceOutputAudio && voiceOutputAudio.setSinkId) await voiceOutputAudio.setSinkId(voiceSettings.speakerId); } catch (e) {}
|
||||
}
|
||||
|
||||
let voiceConnectedAt = null;
|
||||
let voiceTimerInterval = null;
|
||||
let voicePingMs = null;
|
||||
|
|
@ -155,6 +268,7 @@ async function replugVoiceOutputElement() {
|
|||
voiceOutputAudio.srcObject = null;
|
||||
voiceOutputAudio.srcObject = voiceRemoteStream;
|
||||
try { await voiceOutputAudio.play(); } catch (e) {}
|
||||
applyVoiceSpeakerSink();
|
||||
}
|
||||
|
||||
function fallbackVoiceToMediaOutput() {
|
||||
|
|
@ -244,6 +358,7 @@ function setupVoiceOutput() {
|
|||
}
|
||||
voiceOutputAudio.srcObject = voiceRemoteStream;
|
||||
voiceOutputAudio.play().catch(() => {});
|
||||
applyVoiceSpeakerSink();
|
||||
};
|
||||
document.addEventListener("click", voicePlayRetry, true);
|
||||
window.addEventListener("voicemodechanged", voiceModeChangedHandler);
|
||||
|
|
@ -377,6 +492,45 @@ setInterval(() => {
|
|||
changed = true;
|
||||
}
|
||||
}
|
||||
// Stale remote audio streams - quiet users send nothing, but their entry
|
||||
// is recreated on their very next packet, so pruning is always safe.
|
||||
for (const aid of Object.keys(remoteAudio)) {
|
||||
if (now - remoteAudio[aid].lastUpdate > 6000) {
|
||||
const rn = aid.endsWith("_screen") ? screenReceiverNode : receiverNode;
|
||||
if (rn) rn.port.postMessage({action: 'remove', clientId: aid});
|
||||
if (audioDecryptChains[aid]) audioDecryptChains[aid].clear();
|
||||
delete remoteAudio[aid];
|
||||
delete audioDecryptChains[aid];
|
||||
}
|
||||
}
|
||||
// Ghost peers: still on the room roster but totally silent (pings count as
|
||||
// life) - drop them locally until the server or the peer proves otherwise.
|
||||
let ghostRemoved = false;
|
||||
for (const u of voiceActiveUsers) {
|
||||
if (u === String(window.id)) continue;
|
||||
if (!peerLastSeen[u]) { peerLastSeen[u] = now; continue; }
|
||||
if (now - peerLastSeen[u] > VOICE_PEER_TIMEOUT_MS && !deadPeers.has(u)) {
|
||||
deadPeers.add(u);
|
||||
cleanupPeerMedia(u);
|
||||
ghostRemoved = true;
|
||||
}
|
||||
}
|
||||
if (ghostRemoved) {
|
||||
voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u));
|
||||
updateVoiceUIUsers();
|
||||
}
|
||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
||||
// Alive peers ping every 2s regardless of mute, so total inbound
|
||||
// silence while others are present means the socket is dead.
|
||||
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
||||
console.warn("Voice: no inbound traffic, forcing reconnect");
|
||||
try { currentVoiceWs.close(); } catch (e) {}
|
||||
} else if (audioContext && audioContext.state === 'suspended') {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (changed) updateVoiceUIUsers();
|
||||
}, 2000);
|
||||
|
||||
|
|
@ -513,6 +667,7 @@ async function initiateVoiceCall(roomId = null) {
|
|||
}
|
||||
|
||||
async function startVoiceSession(serverUrl) {
|
||||
lastVoiceServerUrl = serverUrl;
|
||||
showVoiceUI();
|
||||
updateVoiceUIUsers();
|
||||
setVoiceStatus("connecting");
|
||||
|
|
@ -548,7 +703,13 @@ async function startVoiceSession(serverUrl) {
|
|||
const usersRaw = await res.json();
|
||||
voicePingMs = Math.round(performance.now() - t0);
|
||||
updateVoicePing();
|
||||
const users = usersRaw.map(u => String(u));
|
||||
const rawUsers = usersRaw.map(u => String(u));
|
||||
// Peers we locally declared dead stay hidden until either
|
||||
// they send anything again or the server drops them.
|
||||
for (const gone of [...deadPeers]) {
|
||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||
}
|
||||
const users = rawUsers.filter(u => !deadPeers.has(u));
|
||||
let changed = (users.length !== voiceActiveUsers.length);
|
||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
||||
if(changed) {
|
||||
|
|
@ -559,9 +720,18 @@ async function startVoiceSession(serverUrl) {
|
|||
};
|
||||
fetchUsers();
|
||||
voiceWsHeartbeat = setInterval(fetchUsers, 5000);
|
||||
|
||||
const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}};
|
||||
micStream = await navigator.mediaDevices.getUserMedia(micConstraints);
|
||||
|
||||
voiceReconnectAttempts = 0;
|
||||
lastVoiceServerPacketAt = Date.now();
|
||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
||||
|
||||
// Reconnects reuse the live mic stream - only capture a new one
|
||||
// when the previous is gone (full teardown or track ended).
|
||||
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
||||
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId));
|
||||
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; }
|
||||
}
|
||||
|
||||
const nativeRouting = getNativeVoiceRouting();
|
||||
if (nativeRouting && nativeRouting.startCall) {
|
||||
|
|
@ -578,20 +748,34 @@ async function startVoiceSession(serverUrl) {
|
|||
isAudioWorkletLoaded = true;
|
||||
}
|
||||
if (audioContext.state === 'suspended') await audioContext.resume();
|
||||
applyVoiceSpeakerSink();
|
||||
setupVoiceOutput();
|
||||
|
||||
if (micSourceNode) {
|
||||
try { micSourceNode.disconnect(); } catch (e) {}
|
||||
micSourceNode = null;
|
||||
}
|
||||
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
||||
micSourceNode.connect(senderNode);
|
||||
|
||||
let vadHangover = 0;
|
||||
senderNode.port.onmessage = async (e) => {
|
||||
if (currentVoiceWs.readyState !== WebSocket.OPEN) return;
|
||||
// Backpressure valve: when the socket is congested, drop mic
|
||||
// frames instead of queueing them - queued audio would arrive
|
||||
// minutes late and drown the connection for every stream.
|
||||
if (currentVoiceWs.bufferedAmount > 256 * 1024) {
|
||||
vadHangover = 0;
|
||||
return;
|
||||
}
|
||||
const {audioData, db} = e.data;
|
||||
const sensitivity = -65;
|
||||
const sensitivity = voiceSettings.sensitivity;
|
||||
const percent = Math.max(0, Math.min(100, (db + 80) / 80 * 100));
|
||||
|
||||
let micLevelBar = document.getElementById("voice-mic-level");
|
||||
let vdsLevelFill = document.getElementById("vds-sens-fill");
|
||||
if (micLevelBar) micLevelBar.style.width = percent + '%';
|
||||
if (vdsLevelFill) vdsLevelFill.style.width = percent + '%';
|
||||
|
||||
if (db >= sensitivity) vadHangover = 75;
|
||||
let isSpeaking = vadHangover > 0 && !isMicMuted;
|
||||
|
|
@ -600,6 +784,7 @@ async function startVoiceSession(serverUrl) {
|
|||
if (vadHangover > 0 && !isMicMuted) {
|
||||
vadHangover--;
|
||||
if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)';
|
||||
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
|
||||
|
||||
if (!voiceAesCryptoKey) return;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
|
@ -609,6 +794,7 @@ async function startVoiceSession(serverUrl) {
|
|||
currentVoiceWs.send(payload.buffer);
|
||||
} else {
|
||||
if (micLevelBar) micLevelBar.style.backgroundColor = 'rgba(255, 255, 255, 0.15)';
|
||||
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'rgba(255, 255, 255, 0.15)';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -617,17 +803,24 @@ async function startVoiceSession(serverUrl) {
|
|||
const arrayBuffer = e.data;
|
||||
const view = new DataView(arrayBuffer);
|
||||
let clientId = view.getBigUint64(0, true).toString();
|
||||
|
||||
// Any inbound frame proves this peer (and the socket) are alive.
|
||||
lastVoiceServerPacketAt = Date.now();
|
||||
peerLastSeen[clientId] = lastVoiceServerPacketAt;
|
||||
if (deadPeers.has(clientId)) deadPeers.delete(clientId);
|
||||
|
||||
const type = view.getUint8(8);
|
||||
if (type === 5) return; // liveness ping - presence already recorded
|
||||
|
||||
if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
|
||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
|
||||
voiceActiveUsers = usersRaw.map(u => String(u));
|
||||
voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
|
||||
updateVoiceUIUsers();
|
||||
}).catch(()=>{});
|
||||
}
|
||||
|
||||
const type = view.getUint8(8);
|
||||
const isKeyFrame = view.getUint8(9) === 1;
|
||||
const iv = new Uint8Array(arrayBuffer, 10, 12);
|
||||
const ciphertext = new Uint8Array(arrayBuffer, 22);
|
||||
|
|
@ -739,8 +932,16 @@ async function startVoiceSession(serverUrl) {
|
|||
}
|
||||
};
|
||||
|
||||
currentVoiceWs.onclose = () => {
|
||||
endVoiceSession();
|
||||
currentVoiceWs.onclose = (ev) => {
|
||||
// Server closes with reason "Kicked" when another connection took
|
||||
// our userId slot (second device/tab). Reconnecting would start an
|
||||
// infinite kick-war - give up cleanly instead. Any other close
|
||||
// (network drop, watchdog) is a reconnect candidate.
|
||||
if (ev && ev.reason === "Kicked") {
|
||||
endVoiceSession();
|
||||
return;
|
||||
}
|
||||
scheduleVoiceReconnect();
|
||||
};
|
||||
|
||||
} catch(err) {
|
||||
|
|
@ -756,7 +957,7 @@ function handleCallUpdate(data) {
|
|||
let state = [];
|
||||
if(parts.slice(2).join(":") !== "") {
|
||||
let stateRaw = JSON.parse(parts.slice(2).join(":"));
|
||||
state = stateRaw.map(u => String(u));
|
||||
state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
|
||||
}
|
||||
voiceActiveUsers = state;
|
||||
|
||||
|
|
@ -977,6 +1178,424 @@ async function updateVoiceChatTitle() {
|
|||
if (el && name) el.innerText = name;
|
||||
}
|
||||
|
||||
// ===== In-call voice/device settings popup =====
|
||||
let voiceDevicePopupEl = null;
|
||||
let voiceLabelProbed = { mic: false, cam: false };
|
||||
|
||||
function hideVoiceDeviceSettings() {
|
||||
if (!voiceDevicePopupEl) return;
|
||||
closeOpenVoiceDropdown();
|
||||
document.removeEventListener('click', closeVoiceDeviceSettingsOnOutsideClick);
|
||||
let el = voiceDevicePopupEl;
|
||||
voiceDevicePopupEl = null;
|
||||
el.classList.remove('show');
|
||||
const settingsBtn = document.getElementById('voice-btn-settings');
|
||||
if (settingsBtn) settingsBtn.classList.remove('active');
|
||||
setTimeout(() => el.remove(), 250);
|
||||
}
|
||||
|
||||
function closeVoiceDeviceSettingsOnOutsideClick(ev) {
|
||||
if (!voiceDevicePopupEl) return;
|
||||
const t = ev.target;
|
||||
if (voiceDevicePopupEl.contains(t)) return;
|
||||
// Clicks in the floating dropdown layer or its anchor keep the popup open.
|
||||
if (openVoiceDropdown && (openVoiceDropdown.dd.contains(t) || openVoiceDropdown.anchor.contains(t))) return;
|
||||
hideVoiceDeviceSettings();
|
||||
}
|
||||
|
||||
function categorizeVoiceDevices(devices) {
|
||||
const name = (list, fallbackBase) => list.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `${fallbackBase} ${i + 1}` }));
|
||||
return {
|
||||
mics: name(devices.filter(d => d.kind === 'audioinput'), 'Microphone'),
|
||||
cams: name(devices.filter(d => d.kind === 'videoinput'), 'Camera'),
|
||||
speakers: name(devices.filter(d => d.kind === 'audiooutput'), 'Speaker')
|
||||
};
|
||||
}
|
||||
|
||||
// Fast - safe to call on every popup open, blocks the UI for milliseconds.
|
||||
async function enumerateVoiceDevices() {
|
||||
try {
|
||||
return categorizeVoiceDevices(await navigator.mediaDevices.enumerateDevices());
|
||||
} catch (e) {
|
||||
return { mics: [], cams: [], speakers: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Labels are only exposed after permission was granted at least once. Probes
|
||||
// (a short getUserMedia per kind) can take seconds - callers must render the
|
||||
// popup first and only refresh with this afterwards.
|
||||
async function ensureVoiceDeviceLabels() {
|
||||
let devices = [];
|
||||
try { devices = await navigator.mediaDevices.enumerateDevices(); } catch (e) { return null; }
|
||||
|
||||
const micUnlabeled = devices.some(d => d.kind === 'audioinput' && !d.label);
|
||||
const camUnlabeled = devices.some(d => d.kind === 'videoinput' && !d.label);
|
||||
let probed = false;
|
||||
|
||||
if (micUnlabeled && !voiceLabelProbed.mic) {
|
||||
voiceLabelProbed.mic = true;
|
||||
probed = true;
|
||||
try { const t = await navigator.mediaDevices.getUserMedia({ audio: true }); t.getTracks().forEach(t2 => t2.stop()); } catch (e) {}
|
||||
}
|
||||
if (camUnlabeled && !voiceLabelProbed.cam) {
|
||||
voiceLabelProbed.cam = true;
|
||||
probed = true;
|
||||
try { const t = await navigator.mediaDevices.getUserMedia({ video: true }); t.getTracks().forEach(t2 => t2.stop()); } catch (e) {}
|
||||
}
|
||||
|
||||
return probed ? enumerateVoiceDevices() : null;
|
||||
}
|
||||
|
||||
function restartVoiceCamIfActive() {
|
||||
if (!camControl) return;
|
||||
camControl.stop();
|
||||
// stop() is async (it releases the camera after awaits) - poll until the
|
||||
// control is actually gone before reopening, otherwise Android's camera
|
||||
// HAL is still busy and getUserMedia fails with NotReadableError.
|
||||
const attempt = (tries) => {
|
||||
setTimeout(() => {
|
||||
if (!currentVoiceWs) return;
|
||||
if (camControl) { if (tries < 20) attempt(tries + 1); return; }
|
||||
toggleVoiceCam();
|
||||
}, 300);
|
||||
};
|
||||
attempt(0);
|
||||
}
|
||||
|
||||
// Selects render their option list as a fixed-position layer attached to
|
||||
// <body> (portal) so it can escape the settings popup bounds and is never
|
||||
// clipped by the scrolling popup body or distorted by its transform.
|
||||
let openVoiceDropdown = null;
|
||||
|
||||
const voiceDropdownOutsideCloser = (ev) => {
|
||||
if (!openVoiceDropdown) return;
|
||||
const t = ev.target;
|
||||
if (openVoiceDropdown.dd.contains(t) || openVoiceDropdown.anchor.contains(t)) return;
|
||||
closeOpenVoiceDropdown();
|
||||
};
|
||||
|
||||
function closeOpenVoiceDropdown() {
|
||||
const cur = openVoiceDropdown;
|
||||
if (!cur) return;
|
||||
openVoiceDropdown = null;
|
||||
cur.dd.classList.remove('vds-open');
|
||||
cur.anchor.classList.remove('open');
|
||||
setTimeout(() => cur.dd.remove(), 220);
|
||||
document.removeEventListener('click', voiceDropdownOutsideCloser, true);
|
||||
window.removeEventListener('resize', closeOpenVoiceDropdown);
|
||||
const bodyEl = document.getElementById('vds-body');
|
||||
if (bodyEl) bodyEl.removeEventListener('scroll', closeOpenVoiceDropdown);
|
||||
}
|
||||
|
||||
function openVoiceFloatingDropdown(anchor, dd) {
|
||||
closeOpenVoiceDropdown();
|
||||
document.body.appendChild(dd);
|
||||
|
||||
const margin = 8;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dd.style.width = 'auto';
|
||||
dd.style.minWidth = rect.width + 'px';
|
||||
dd.style.left = Math.min(Math.max(rect.left, margin), Math.max(margin, window.innerWidth - rect.width - margin)) + 'px';
|
||||
|
||||
const ddH = dd.offsetHeight;
|
||||
let top = rect.bottom + 6;
|
||||
if (top + ddH > window.innerHeight - margin) {
|
||||
top = Math.max(margin, rect.top - ddH - 6);
|
||||
}
|
||||
dd.style.top = top + 'px';
|
||||
|
||||
anchor.classList.add('open');
|
||||
requestAnimationFrame(() => dd.classList.add('vds-open'));
|
||||
openVoiceDropdown = { dd, anchor };
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', voiceDropdownOutsideCloser, true);
|
||||
window.addEventListener('resize', closeOpenVoiceDropdown);
|
||||
const bodyEl = document.getElementById('vds-body');
|
||||
if (bodyEl) bodyEl.addEventListener('scroll', closeOpenVoiceDropdown);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function createVoiceSelect(options, selectedValue, onChange) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'custom-select';
|
||||
const value = document.createElement('div');
|
||||
value.className = 'custom-select-value';
|
||||
const span = document.createElement('span');
|
||||
let current = String(options.find(o => String(o.value) === String(selectedValue))?.value ?? '');
|
||||
const currentOption = () => options.find(o => String(o.value) === current) || options[0];
|
||||
span.textContent = currentOption() ? currentOption().label : '';
|
||||
value.appendChild(span);
|
||||
value.insertAdjacentHTML('beforeend', ICONS.chevronDown({ className: 'chevron', style: 'width: 1.1rem; height: 1.1rem; opacity: 0.7;' }));
|
||||
|
||||
value.onclick = () => {
|
||||
if (openVoiceDropdown && openVoiceDropdown.anchor === wrap) {
|
||||
closeOpenVoiceDropdown();
|
||||
return;
|
||||
}
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'custom-select-options vds-floating';
|
||||
for (const opt of options) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'custom-select-option';
|
||||
el.textContent = opt.label;
|
||||
if (String(opt.value) === current) el.classList.add('selected');
|
||||
el.onclick = (ev) => {
|
||||
// Keep the outside-click closer from treating this as an
|
||||
// outside click - the option layer lives outside the popup.
|
||||
ev.stopPropagation();
|
||||
current = String(opt.value);
|
||||
span.textContent = opt.label;
|
||||
closeOpenVoiceDropdown();
|
||||
onChange(current);
|
||||
};
|
||||
dd.appendChild(el);
|
||||
}
|
||||
openVoiceFloatingDropdown(wrap, dd);
|
||||
};
|
||||
|
||||
wrap.appendChild(value);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function voiceSettingRow(labelText, controlEl) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'vds-row';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = labelText;
|
||||
row.appendChild(label);
|
||||
row.appendChild(controlEl);
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildQualityFpsGrid(mediaTypeId) {
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'vds-grid';
|
||||
const resOptions = VOICE_RES_OPTIONS.slice();
|
||||
const fpsOptions = VOICE_FPS_OPTIONS.map(f => ({ value: f, label: `${f} FPS` }));
|
||||
const cfg = getVoiceMediaConfig(mediaTypeId);
|
||||
|
||||
const resWrap = document.createElement('div');
|
||||
resWrap.className = 'vds-cell';
|
||||
const resLabel = document.createElement('label');
|
||||
resLabel.textContent = processBlah(mediaTypeId === 1 ? 'label.voice.camera.quality' : 'label.voice.screen.quality');
|
||||
const resSelect = createVoiceSelect(resOptions, cfg.res, v => {
|
||||
if (mediaTypeId === 1) {
|
||||
voiceSettings.camRes = v;
|
||||
saveVoiceSettings();
|
||||
// Size changes reopen the camera cleanly - mutating capture size
|
||||
// mid-stream makes Android cameras flicker between sensor modes.
|
||||
restartVoiceCamIfActive();
|
||||
} else {
|
||||
voiceSettings.screenRes = v;
|
||||
saveVoiceSettings();
|
||||
}
|
||||
});
|
||||
resWrap.appendChild(resLabel);
|
||||
resWrap.appendChild(resSelect);
|
||||
|
||||
const fpsWrap = document.createElement('div');
|
||||
fpsWrap.className = 'vds-cell';
|
||||
const fpsLabel = document.createElement('label');
|
||||
fpsLabel.textContent = processBlah(mediaTypeId === 1 ? 'label.voice.camera.fps' : 'label.voice.screen.fps');
|
||||
const fpsSelect = createVoiceSelect(fpsOptions, cfg.fps, v => {
|
||||
if (mediaTypeId === 1) {
|
||||
voiceSettings.camFps = parseInt(v);
|
||||
saveVoiceSettings();
|
||||
// Mid-stream frame-rate renegotiation can make Android cameras
|
||||
// wander between sensor modes (aspect ratio flicker) - reopen
|
||||
// cleanly instead, same as resolution changes.
|
||||
restartVoiceCamIfActive();
|
||||
} else {
|
||||
voiceSettings.screenFps = parseInt(v);
|
||||
saveVoiceSettings();
|
||||
}
|
||||
});
|
||||
fpsWrap.appendChild(fpsLabel);
|
||||
fpsWrap.appendChild(fpsSelect);
|
||||
|
||||
grid.appendChild(resWrap);
|
||||
grid.appendChild(fpsWrap);
|
||||
return grid;
|
||||
}
|
||||
|
||||
function clampVoiceDevicePopupToViewport(popup) {
|
||||
const W = window.innerWidth, H = window.innerHeight;
|
||||
const w = popup.offsetWidth, h = popup.offsetHeight;
|
||||
let left = Math.round((W - w) / 2), top = Math.round((H - h) / 2);
|
||||
left = Math.min(Math.max(left, 8), Math.max(8, W - w - 8));
|
||||
top = Math.min(Math.max(top, 8), Math.max(8, H - h - 8));
|
||||
popup.style.left = left + 'px';
|
||||
popup.style.top = top + 'px';
|
||||
}
|
||||
|
||||
// Centered via explicit px coordinates (not CSS translate) so the opening
|
||||
// animation never shifts the popup and dragging is a plain left/top change.
|
||||
function makeVoiceDeviceSettingsDraggable(popup) {
|
||||
const header = popup.querySelector('#vds-header');
|
||||
let dragging = false, startX = 0, startY = 0, startL = 0, startT = 0;
|
||||
|
||||
header.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
if (e.target.closest('#vds-close')) return;
|
||||
closeOpenVoiceDropdown();
|
||||
dragging = true;
|
||||
try { header.setPointerCapture(e.pointerId); } catch (err) {}
|
||||
const rect = popup.getBoundingClientRect();
|
||||
startL = rect.left; startT = rect.top;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
e.preventDefault();
|
||||
});
|
||||
header.addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
const W = window.innerWidth, H = window.innerHeight;
|
||||
let l = startL + (e.clientX - startX);
|
||||
let t = startT + (e.clientY - startY);
|
||||
l = Math.min(Math.max(l, 8), Math.max(8, W - popup.offsetWidth - 8));
|
||||
t = Math.min(Math.max(t, 8), Math.max(8, H - popup.offsetHeight - 8));
|
||||
popup.style.left = l + 'px';
|
||||
popup.style.top = t + 'px';
|
||||
});
|
||||
const stopDrag = () => { dragging = false; };
|
||||
header.addEventListener('pointerup', stopDrag);
|
||||
header.addEventListener('pointercancel', stopDrag);
|
||||
}
|
||||
|
||||
function buildVoiceSettingsBody(body, nativeRouting, devices) {
|
||||
const deviceOptions = (list) => [{ value: '', label: processBlah('label.voice.default') }].concat(list.map(d => ({ value: d.deviceId, label: d.label })));
|
||||
|
||||
// Microphone - switching mid-call swaps the captured track live.
|
||||
body.appendChild(voiceSettingRow(processBlah('label.voice.microphone'),
|
||||
createVoiceSelect(deviceOptions(devices.mics), voiceSettings.micId, v => {
|
||||
voiceSettings.micId = v;
|
||||
saveVoiceSettings();
|
||||
switchVoiceMic(v);
|
||||
})
|
||||
));
|
||||
|
||||
// Output device - not available with native Android routing (the OS owns
|
||||
// the call audio path there).
|
||||
if (!nativeRouting && devices.speakers.length > 0) {
|
||||
body.appendChild(voiceSettingRow(processBlah('label.voice.speaker'),
|
||||
createVoiceSelect(deviceOptions(devices.speakers), voiceSettings.speakerId, v => {
|
||||
voiceSettings.speakerId = v;
|
||||
saveVoiceSettings();
|
||||
applyVoiceSpeakerSink();
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
// Camera device
|
||||
body.appendChild(voiceSettingRow(processBlah('label.voice.camera'),
|
||||
createVoiceSelect(deviceOptions(devices.cams), voiceSettings.camId, v => {
|
||||
voiceSettings.camId = v;
|
||||
saveVoiceSettings();
|
||||
restartVoiceCamIfActive();
|
||||
})
|
||||
));
|
||||
|
||||
// Quality/FPS - picked up live by the encoder config check.
|
||||
body.appendChild(buildQualityFpsGrid(1));
|
||||
|
||||
// Screen share quality/FPS
|
||||
body.appendChild(buildQualityFpsGrid(2));
|
||||
|
||||
// Mic input sensitivity (VAD threshold)
|
||||
const sensPercent = (voiceSettings.sensitivity + 80) / 80 * 100;
|
||||
const sensRow = document.createElement('div');
|
||||
sensRow.className = 'vds-row';
|
||||
sensRow.innerHTML = `
|
||||
<label>${processBlah('label.voice.sensitivity')} <span id="vds-sens-val">${voiceSettings.sensitivity} dB</span></label>
|
||||
<div class="vds-sens-meter">
|
||||
<div class="vds-sens-fill" id="vds-sens-fill"></div>
|
||||
<div class="vds-sens-mark" id="vds-sens-mark" style="left: ${sensPercent}%"></div>
|
||||
<input type="range" id="vds-sens-slider" min="-80" max="0" step="1" value="${voiceSettings.sensitivity}">
|
||||
</div>
|
||||
`;
|
||||
body.appendChild(sensRow);
|
||||
const sliderEl = sensRow.querySelector('#vds-sens-slider');
|
||||
const applySensitivity = (raw) => {
|
||||
const v = Math.max(-80, Math.min(0, Math.round(raw)));
|
||||
voiceSettings.sensitivity = v;
|
||||
saveVoiceSettings();
|
||||
const valEl = document.getElementById('vds-sens-val');
|
||||
const markEl = document.getElementById('vds-sens-mark');
|
||||
if (valEl) valEl.innerText = `${v} dB`;
|
||||
if (markEl) markEl.style.left = ((v + 80) / 80 * 100) + '%';
|
||||
};
|
||||
// Native touch-dragging of range thumbs is unreliable inside the WebView -
|
||||
// drive touch/pen drags explicitly from pointer events. Mouse stays native.
|
||||
const applyFromPointer = (e) => {
|
||||
const rect = sliderEl.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
applySensitivity(-80 + pct * 80);
|
||||
sliderEl.value = String(voiceSettings.sensitivity);
|
||||
};
|
||||
sliderEl.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType === 'mouse') return;
|
||||
e.preventDefault(); // own the gesture - no native range handling
|
||||
try { sliderEl.setPointerCapture(e.pointerId); } catch (err) {}
|
||||
applyFromPointer(e);
|
||||
});
|
||||
sliderEl.addEventListener('pointermove', (e) => {
|
||||
if (e.pointerType === 'mouse') return;
|
||||
try { if (!sliderEl.hasPointerCapture(e.pointerId)) return; } catch (err) { return; }
|
||||
applyFromPointer(e);
|
||||
});
|
||||
sliderEl.addEventListener('pointerup', (e) => {
|
||||
try { if (sliderEl.hasPointerCapture(e.pointerId)) sliderEl.releasePointerCapture(e.pointerId); } catch (err) {}
|
||||
});
|
||||
sliderEl.oninput = function () { applySensitivity(parseInt(this.value)); };
|
||||
}
|
||||
|
||||
window.showVoiceDeviceSettings = async function (e) {
|
||||
if (e) e.stopPropagation();
|
||||
if (voiceDevicePopupEl) { hideVoiceDeviceSettings(); return; }
|
||||
if (!voiceUiContainer) return;
|
||||
|
||||
const nativeRouting = getNativeVoiceRouting();
|
||||
const popup = document.createElement('div');
|
||||
popup.id = 'voice-device-settings';
|
||||
popup.innerHTML = `
|
||||
<div id="vds-header">
|
||||
<span>${ICONS.settings({width: "1rem", height: "1rem"})} ${processBlah("action.call.settings")}</span>
|
||||
<button id="vds-close">${ICONS.close({width: "1rem", height: "1rem"})}</button>
|
||||
</div>
|
||||
<div id="vds-body"></div>
|
||||
`;
|
||||
// Hidden until positioned: base CSS keeps opacity 0 without .show.
|
||||
popup.style.left = '0px';
|
||||
popup.style.top = '0px';
|
||||
document.body.appendChild(popup);
|
||||
voiceDevicePopupEl = popup;
|
||||
makeVoiceDeviceSettingsDraggable(popup);
|
||||
popup.querySelector('#vds-close').onclick = hideVoiceDeviceSettings;
|
||||
|
||||
const body = popup.querySelector('#vds-body');
|
||||
|
||||
// Show immediately with a fast enumeration - never block on getUserMedia
|
||||
// label probes here, otherwise taps land on nothing and feel ignored.
|
||||
let devices = await enumerateVoiceDevices();
|
||||
if (!voiceDevicePopupEl || voiceDevicePopupEl !== popup) { popup.remove(); return; }
|
||||
|
||||
buildVoiceSettingsBody(body, nativeRouting, devices);
|
||||
|
||||
clampVoiceDevicePopupToViewport(popup);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => popup.classList.add('show')));
|
||||
const settingsBtn = document.getElementById('voice-btn-settings');
|
||||
if (settingsBtn) settingsBtn.classList.add('active');
|
||||
document.addEventListener('click', closeVoiceDeviceSettingsOnOutsideClick);
|
||||
|
||||
// Upgrade generic labels ("Camera 1") in the background once probed.
|
||||
const refreshed = await ensureVoiceDeviceLabels();
|
||||
if (refreshed && voiceDevicePopupEl === popup) {
|
||||
closeOpenVoiceDropdown();
|
||||
body.innerHTML = '';
|
||||
buildVoiceSettingsBody(body, nativeRouting, refreshed);
|
||||
}
|
||||
};
|
||||
|
||||
function showVoiceUI() {
|
||||
if (voiceUiContainer) return;
|
||||
|
||||
|
|
@ -1009,6 +1628,9 @@ function showVoiceUI() {
|
|||
<button id="voice-btn-screen" class="voice-btn" onclick="toggleVoiceScreen()" title="${processBlah("action.call.screen")}">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
</button>
|
||||
<button id="voice-btn-settings" class="voice-btn" onclick="showVoiceDeviceSettings(event)" title="${processBlah("action.call.settings")}">
|
||||
${ICONS.settings({width: "1.3rem", height: "1.3rem"})}
|
||||
</button>
|
||||
${(isMobileVoiceDevice() && getNativeVoiceRouting()) ? `<button id="voice-btn-speaker" class="voice-btn${isVoiceSpeakerMode ? " voice-btn-active" : ""}" onclick="toggleVoiceSpeakerMode()" title="${processBlah("action.call.speaker")}">
|
||||
${ICONS.speaker({width: "1.4rem", height: "1.4rem"})}
|
||||
</button>` : ""}
|
||||
|
|
@ -1463,8 +2085,60 @@ function toggleVoiceMic() {
|
|||
}
|
||||
}
|
||||
|
||||
// Tears down only the transport-level state (socket, remote streams, key),
|
||||
// keeping the UI, mic stream and AudioContext alive for an automatic rejoin.
|
||||
function teardownVoiceTransport() {
|
||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||
if (camControl) { try { camControl.stop(); } catch (e) {} camControl = null; }
|
||||
if (screenControl) { try { screenControl.stop(); } catch (e) {} screenControl = null; }
|
||||
if (currentVoiceWs) {
|
||||
try { currentVoiceWs.onclose = null; currentVoiceWs.close(); } catch (e) {}
|
||||
currentVoiceWs = null;
|
||||
}
|
||||
for (const decoderId of Object.keys(remoteVideos)) {
|
||||
try { if (remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {}
|
||||
}
|
||||
remoteVideos = {};
|
||||
videoDecryptChains = {};
|
||||
remoteAudio = {};
|
||||
audioDecryptChains = {};
|
||||
peerLastSeen = {};
|
||||
deadPeers.clear();
|
||||
if (receiverNode) receiverNode.port.postMessage({action: 'clear'});
|
||||
if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'});
|
||||
voiceAesKeyBytes = null;
|
||||
voiceAesCryptoKey = null;
|
||||
lastVoiceServerPacketAt = Date.now();
|
||||
}
|
||||
|
||||
function scheduleVoiceReconnect() {
|
||||
if (!currentVoiceCallId || !lastVoiceServerUrl) {
|
||||
// Real hangup already ran its cleanup.
|
||||
return;
|
||||
}
|
||||
if (voiceReconnectAttempts >= VOICE_MAX_RECONNECTS) {
|
||||
showBlahNotification("error:voice.reconnect.failed");
|
||||
endVoiceSession();
|
||||
return;
|
||||
}
|
||||
voiceReconnectAttempts++;
|
||||
setVoiceStatus("connecting");
|
||||
teardownVoiceTransport();
|
||||
setTimeout(async () => {
|
||||
if (!currentVoiceCallId || !lastVoiceServerUrl) return;
|
||||
try {
|
||||
await startVoiceSession(lastVoiceServerUrl);
|
||||
} catch (err) {
|
||||
console.warn("Voice reconnect failed", err);
|
||||
scheduleVoiceReconnect();
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function endVoiceSession() {
|
||||
setVoiceStatus("disconnected");
|
||||
hideVoiceDeviceSettings();
|
||||
|
||||
const nativeRouting = getNativeVoiceRouting();
|
||||
if (nativeRouting && nativeRouting.stopCall) {
|
||||
|
|
@ -1490,10 +2164,10 @@ function endVoiceSession() {
|
|||
if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
|
||||
if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; }
|
||||
|
||||
if (window.fetchUsersInterval) {
|
||||
clearInterval(window.fetchUsersInterval);
|
||||
window.fetchUsersInterval = null;
|
||||
}
|
||||
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||
peerLastSeen = {};
|
||||
deadPeers.clear();
|
||||
|
||||
for (let id in audioDecoders) {
|
||||
try { audioDecoders[id].close(); } catch(e){}
|
||||
|
|
@ -1572,6 +2246,8 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
||||
screenSenderNode.port.onmessage = async (e) => {
|
||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
|
||||
// Same backpressure valve as the mic sender.
|
||||
if (currentVoiceWs.bufferedAmount > 256 * 1024) return;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData);
|
||||
const payload = new Uint8Array(14 + cipher.byteLength);
|
||||
|
|
@ -1583,8 +2259,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
}
|
||||
|
||||
const getCurrentConfig = () => {
|
||||
const fps = typeId === 1 ? 24 : 30;
|
||||
const resVal = typeId === 1 ? "1280x720" : "1920x1080";
|
||||
const mediaCfg = getVoiceMediaConfig(typeId);
|
||||
const fps = mediaCfg.fps;
|
||||
const resVal = mediaCfg.res;
|
||||
const settings = videoTrack.getSettings();
|
||||
let sourceWidth = settings.width || 1280, sourceHeight = settings.height || 720, width, height;
|
||||
if (resVal === "source") { width = sourceWidth; height = sourceHeight; } else {
|
||||
|
|
@ -1593,7 +2270,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
if (ratio < 1) { width = sourceWidth * ratio; height = sourceHeight * ratio; } else { width = sourceWidth; height = sourceHeight; }
|
||||
}
|
||||
width = Math.floor(width / 2) * 2; height = Math.floor(height / 2) * 2;
|
||||
return { width, height, fps, bitrate: Math.floor(width * height * fps * 0.15) };
|
||||
// A WebSocket relay saturates fast: uncapped w*h*fps bitrates
|
||||
// (several Mb/s) choke the shared call socket, which stalls audio
|
||||
// for everyone. Keep generous-but-safe ceilings per stream type.
|
||||
const bitrate = Math.min(Math.floor(width * height * fps * 0.07), typeId === 1 ? 2_000_000 : 3_000_000);
|
||||
return { width, height, fps, bitrate };
|
||||
};
|
||||
|
||||
let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
|
||||
|
|
@ -1610,16 +2291,30 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
});
|
||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||
|
||||
const encodeLoop = () => {
|
||||
if (!isRunning) return;
|
||||
const newConfig = getCurrentConfig();
|
||||
let forceKeyframe = false;
|
||||
if (newConfig.width !== currentConfig.width || newConfig.height !== currentConfig.height || newConfig.fps !== currentConfig.fps) {
|
||||
currentConfig = newConfig; forceKeyframe = true; frameCount = 0;
|
||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||
}
|
||||
// Transient capture-mode wobbles must not thrash the encoder -
|
||||
// commit only after ~300 ms stable. Camera size/fps changes arrive
|
||||
// via a clean reopen (see buildQualityFpsGrid); the screen share
|
||||
// surface is never re-constrained, the encoder just rescales.
|
||||
let pendingCfg = null, pendingAt = 0;
|
||||
const cfgEquals = (a, b) => !!a && !!b && a.width === b.width && a.height === b.height && a.fps === b.fps;
|
||||
|
||||
if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 512 && videoEl.readyState >= 2) {
|
||||
const encodeLoop = () => {
|
||||
if (!isRunning) return;
|
||||
const newConfig = getCurrentConfig();
|
||||
let forceKeyframe = false;
|
||||
if (!cfgEquals(newConfig, currentConfig)) {
|
||||
if (!cfgEquals(newConfig, pendingCfg)) {
|
||||
pendingCfg = newConfig;
|
||||
pendingAt = performance.now();
|
||||
} else if (performance.now() - pendingAt > 300) {
|
||||
currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null;
|
||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||
}
|
||||
} else if (pendingCfg) {
|
||||
pendingCfg = null; // wiggle reverted on its own - ignore
|
||||
}
|
||||
|
||||
if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && videoEl.readyState >= 2) {
|
||||
let frame = null;
|
||||
try {
|
||||
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)});
|
||||
|
|
@ -1664,14 +2359,40 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
} catch (e) { console.error(e); alert(processBlah(`:error.call.unsupported:${typeLabel}`)); return null; }
|
||||
}
|
||||
|
||||
// Opens the camera honoring the saved device/quality. A saved deviceId can
|
||||
// become stale (device list changes across sessions) - fall back to the
|
||||
// default camera instead of failing with OverconstrainedError.
|
||||
async function getVoiceCamStream() {
|
||||
const camCfg = getVoiceMediaConfig(1);
|
||||
const constraintsFor = (deviceId) => {
|
||||
const video = {frameRate: {ideal: camCfg.fps}};
|
||||
if (camCfg.res !== "source") {
|
||||
const [w, h] = camCfg.res.split('x').map(Number);
|
||||
// Single-axis ideal: no implied aspect ratio, the camera keeps its
|
||||
// native one and only the resolution tier changes.
|
||||
video.width = {ideal: Math.max(w, h)};
|
||||
}
|
||||
if (deviceId) video.deviceId = {exact: deviceId};
|
||||
return {video};
|
||||
};
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(constraintsFor(voiceSettings.camId));
|
||||
} catch (e) {
|
||||
if (!voiceSettings.camId) throw e;
|
||||
voiceSettings.camId = "";
|
||||
saveVoiceSettings();
|
||||
return navigator.mediaDevices.getUserMedia(constraintsFor(undefined));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVoiceCam() {
|
||||
let btn = document.getElementById("voice-btn-cam");
|
||||
if (camControl) {
|
||||
camControl.stop();
|
||||
} else {
|
||||
navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => {
|
||||
getVoiceCamStream().then(stream => {
|
||||
startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl);
|
||||
}).catch(err => alert(processBlah(`:error.call.camera:${err.message}`)));
|
||||
}).catch(err => alert(processBlah(`:error.call.camera:${err.message || err.name || "?"}`)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1685,6 +2406,6 @@ function toggleVoiceScreen() {
|
|||
video: true, audio: { autoGainControl: false, echoCancellation: false, noiseSuppression: false }, systemAudio: "include", selfBrowserSurface: "exclude"
|
||||
}).then(stream => {
|
||||
startHardwareVideo(btn, Promise.resolve(stream), "Screen", 2).then(ctrl => screenControl = ctrl);
|
||||
}).catch(err => alert(processBlah(`:error.call.screen:${err.message}`)));
|
||||
}).catch(err => alert(processBlah(`:error.call.screen:${err.message || err.name || "?"}`)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue