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
|
|
@ -7,8 +7,8 @@ android {
|
|||
applicationId "olcxja.miarven"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 3
|
||||
versionName "0.1.2"
|
||||
versionCode 4
|
||||
versionName "0.1.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 || "?"}`)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Miarven",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "An Amazing Capacitor App",
|
||||
"author": {
|
||||
"name": "",
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
node_modules/@capacitor/android/capacitor/build/outputs/aar/capacitor-android-release.aar
generated
vendored
BIN
node_modules/@capacitor/android/capacitor/build/outputs/aar/capacitor-android-release.aar
generated
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,9 +1,9 @@
|
|||
<libraries>
|
||||
<library
|
||||
name=":@@:capacitor-android::release"
|
||||
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/libs/R.jar"
|
||||
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/libs/R.jar"
|
||||
resolved="android:capacitor-android:unspecified"
|
||||
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out"
|
||||
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out"
|
||||
manifest="AndroidManifest.xml"
|
||||
resFolder="res"
|
||||
assetsFolder="assets"
|
||||
|
|
|
|||
Binary file not shown.
BIN
node_modules/@capacitor/app/android/build/intermediates/local_aar_for_lint/release/out.aar
generated
vendored
BIN
node_modules/@capacitor/app/android/build/intermediates/local_aar_for_lint/release/out.aar
generated
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
node_modules/@capacitor/app/android/build/outputs/aar/capacitor-app-release.aar
generated
vendored
BIN
node_modules/@capacitor/app/android/build/outputs/aar/capacitor-app-release.aar
generated
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,9 +1,9 @@
|
|||
<libraries>
|
||||
<library
|
||||
name=":@@:capacitor-android::release"
|
||||
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out/jars/libs/R.jar"
|
||||
jars="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/classes.jar:/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out/jars/libs/R.jar"
|
||||
resolved="android:capacitor-android:unspecified"
|
||||
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/7687406e09940ba1a8559cc8a3590167/transformed/out"
|
||||
folder="/home/olcxja/.gradle/caches/8.14.3/transforms/a60990329a178b65acb9987d031670ff/transformed/out"
|
||||
manifest="AndroidManifest.xml"
|
||||
resFolder="res"
|
||||
assetsFolder="assets"
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "olcxja.miarven",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Larpix client",
|
||||
"license": "AGPL-3.0",
|
||||
"author": "olcxja's softworks",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"versionCode": 3,
|
||||
"versionName": "0.1.2",
|
||||
"npmVersion": "0.1.2"
|
||||
"versionCode": 4,
|
||||
"versionName": "0.1.3",
|
||||
"npmVersion": "0.1.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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