better android app voice chat integration
All checks were successful
Android Build / publish (push) Successful in 47s
Linux Build / publish (push) Successful in 1m2s

This commit is contained in:
olcxja 2026-08-23 21:09:59 +02:00
commit 4fcc268e80
182 changed files with 1403 additions and 62 deletions

View file

@ -298,6 +298,7 @@
"action.call.camera": "camera",
"action.call.screen": "share screen",
"action.call.disconnect": "disconnect :3",
"action.call.speaker": "speaker :3",
"action.call.join": "join :3",
"action.call.decline": "decline :c",
"title.incoming.call": "incoming voice chat :3",

View file

@ -306,6 +306,7 @@
"action.call.mute": "Mute Microphone",
"action.call.camera": "Camera",
"action.call.screen": "Share Screen",
"action.call.speaker": "Speaker",
"action.call.disconnect": "Disconnect",
"action.call.join": "Join",
"action.call.decline": "Decline",

View file

@ -306,6 +306,7 @@
"action.call.mute": "Wycisz mikrofon",
"action.call.camera": "Kamera",
"action.call.screen": "Udostępnij ekran",
"action.call.speaker": "Głośnik",
"action.call.disconnect": "Rozłącz",
"action.call.join": "Dołącz",
"action.call.decline": "Odrzuć",

View file

@ -49,6 +49,7 @@ const ICONS = {
video: (props) => ICONS._feather(`<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>`, props),
file: (props) => ICONS._feather(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>`, props),
mic: (props) => ICONS._feather(`<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>`, props),
speaker: (props) => ICONS._create(`<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"></path>`, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props),
home: (props) => ICONS._create(`<path d="M1 6V15H6V11C6 9.89543 6.89543 9 8 9C9.10457 9 10 9.89543 10 11V15H15V6L8 0L1 6Z" stroke="currentColor" stroke-width="1.28" stroke-linejoin="round" stroke-linecap="round" transform="translate(0 0.5)"/>`, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props),
inbox: (props) => ICONS._material(`<path d="M216-144q-29 0-50.5-21.5T144-216v-528q0-29.7 21.5-50.85Q187-816 216-816h528q29.7 0 50.85 21.15Q816-773.7 816-744v528q0 29-21.15 50.5T744-144H216Zm0-72h528v-144H632q-23 43-63.5 69.5T480-264q-49 0-89.5-26T328-360H216v144Zm332-148q28-28 28-68h168v-312H216v312h168q0 40 28 68t68 28q40 0 68-28ZM216-216h528-528Z"/>`, props),

View file

@ -1729,4 +1729,17 @@ input:checked + .toggle-slider:before {
.voice-call-btn {
padding: 0 0.75rem;
}
#voice-users {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(5.5rem, 100%), 1fr));
grid-auto-rows: minmax(6.25rem, 1fr);
align-content: stretch;
justify-items: stretch;
}
.voice-tile {
width: auto !important;
height: auto !important;
min-width: 0;
}
}

View file

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

View file

@ -14,6 +14,20 @@ let knownVoiceUsers = new Set();
let isMicMuted = false;
let remoteVideos = {}, videoDecryptChains = {};
let camControl = null, screenControl = null;
let screenReceiverNode = null;
let voiceOutputAudio = null;
let voiceLoopSendPc = null, voiceLoopRecvPc = null;
let voicePlayoutDest = null;
let voiceLoopWatchdog = null;
let voiceRemoteStream = null;
let voiceLoopRestarts = 0;
let voiceDeviceChangeHandler = null;
let voiceDeviceChangeTimer = null;
let isVoiceSpeakerMode = false;
let voiceTapNode = null;
let pcmBatch = null;
let pcmBatchLen = 0;
let voicePcmFlushTimer = null;
const SPEAKING_HANGOVER_MS = 400;
@ -62,6 +76,269 @@ function updateVoicePing() {
if (pingEl) pingEl.innerText = voicePingMs !== null ? `${voicePingMs} ms` : "";
}
function isMobileVoiceDevice() {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) && !!window.RTCPeerConnection;
}
function getNativeVoiceRouting() {
try {
return window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.VoiceRouting ? window.Capacitor.Plugins.VoiceRouting : null;
} catch (e) {
return null;
}
}
function teardownVoiceLoopback() {
if (voiceLoopWatchdog) { clearTimeout(voiceLoopWatchdog); voiceLoopWatchdog = null; }
document.removeEventListener("click", voicePlayRetry, true);
window.removeEventListener("voicemodechanged", voiceModeChangedHandler);
if (voiceDeviceChangeHandler && navigator.mediaDevices && navigator.mediaDevices.removeEventListener) {
navigator.mediaDevices.removeEventListener("devicechange", voiceDeviceChangeHandler);
}
voiceDeviceChangeHandler = null;
clearTimeout(voiceDeviceChangeTimer);
if (voiceLoopSendPc) { try { voiceLoopSendPc.close(); } catch (e) {} voiceLoopSendPc = null; }
if (voiceLoopRecvPc) { try { voiceLoopRecvPc.close(); } catch (e) {} voiceLoopRecvPc = null; }
if (voicePlayoutDest) { try { voicePlayoutDest.disconnect(); } catch (e) {} voicePlayoutDest = null; }
if (voiceOutputAudio) {
try { voiceOutputAudio.pause(); } catch (e) {}
voiceOutputAudio.srcObject = null;
voiceOutputAudio.remove();
voiceOutputAudio = null;
}
voiceRemoteStream = null;
}
function voicePlayRetry() {
if (voiceOutputAudio) voiceOutputAudio.play().catch(() => {});
}
function int16ToBase64(view) {
let bin = '';
const u8 = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const STEP = 8192;
for (let i = 0; i < u8.length; i += STEP) {
bin += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + STEP, u8.length)));
}
return btoa(bin);
}
function flushPcmBatch() {
if (!pcmBatchLen || !pcmBatch) return;
const native = getNativeVoiceRouting();
if (native && native.nativeAudioFeed && native.nativeAudioStart) {
const slice = new Int16Array(pcmBatch.subarray(0, pcmBatchLen));
try {
native.nativeAudioFeed({ data: int16ToBase64(slice) }).catch(() => {});
} catch (e) {}
}
pcmBatchLen = 0;
}
function handlePcmChunk(ev) {
try {
if (!getNativeVoiceRouting() || !pcmBatch) return;
const chunk = new Int16Array(ev.data);
for (let i = 0; i < chunk.length; i++) {
if (pcmBatchLen >= pcmBatch.length) flushPcmBatch();
pcmBatch[pcmBatchLen++] = chunk[i];
}
} catch (e) {}
}
function stopNativePlayback() {
if (voicePcmFlushTimer) { clearInterval(voicePcmFlushTimer); voicePcmFlushTimer = null; }
flushPcmBatch();
if (voiceTapNode) {
try { voiceTapNode.disconnect(); } catch (e) {}
try { voiceTapNode.port.onmessage = null; } catch (e) {}
voiceTapNode = null;
}
const native = getNativeVoiceRouting();
if (native && native.nativeAudioStop) native.nativeAudioStop().catch(() => {});
}
// Fired natively when Android reverted MODE_IN_COMMUNICATION and the plugin
// restored it - Chromium's playout track must be re-initialized afterwards.
function voiceModeChangedHandler() {
console.debug("Voice: native mode restore detected, replugging playout");
setTimeout(() => replugVoiceOutputElement(), 150);
setTimeout(() => replugVoiceOutputElement(), 700);
}
function voicePlayoutOk() {
let connected = voiceLoopRecvPc && voiceLoopRecvPc.connectionState === "connected";
let playing = voiceOutputAudio && !voiceOutputAudio.paused;
return !!connected && !!playing;
}
// Re-attaching the stream forces the browser audio stack to re-initialize its
// output track - needed after communication device changes on Android.
async function replugVoiceOutputElement() {
if (!voiceOutputAudio || !voiceRemoteStream) return;
console.debug("Voice: replugging WebRTC playout element");
try { voiceOutputAudio.pause(); } catch (e) {}
voiceOutputAudio.srcObject = null;
voiceOutputAudio.srcObject = voiceRemoteStream;
try { await voiceOutputAudio.play(); } catch (e) {}
}
function fallbackVoiceToMediaOutput() {
if (!receiverNode || !audioContext || !voicePlayoutDest) return;
console.warn("Voice: WebRTC playout unavailable, falling back to media output");
teardownVoiceLoopback();
try { receiverNode.disconnect(); } catch (e) {}
try { receiverNode.connect(audioContext.destination); } catch (e) {}
}
function recoverVoiceLoopback() {
if (!receiverNode || !audioContext || !voicePlayoutDest) return;
const native = getNativeVoiceRouting();
// First aid: re-plug the playout element (fixes stuck routes after device switches)
replugVoiceOutputElement().then(() => {
setTimeout(() => {
if (!voicePlayoutDest || voicePlayoutOk()) return;
if (native && voiceLoopRestarts < 2) {
voiceLoopRestarts++;
console.warn("Voice: restarting WebRTC loopback (attempt " + voiceLoopRestarts + ")");
const wasSpeaker = isVoiceSpeakerMode;
teardownVoiceLoopback();
isVoiceSpeakerMode = wasSpeaker;
setupVoiceOutput();
} else {
fallbackVoiceToMediaOutput();
}
}, 800);
});
}
function setupVoiceOutput() {
if (!audioContext || !receiverNode) return;
try { receiverNode.disconnect(); } catch (e) {}
if (screenReceiverNode) {
try { screenReceiverNode.disconnect(); } catch (e) {}
screenReceiverNode.connect(audioContext.destination);
}
const native = getNativeVoiceRouting();
// App + native plugin: the mixed call audio is played by a native AudioTrack
// (USAGE_VOICE_COMMUNICATION), so it follows the call volume slider and the
// earpiece/speaker switch. Nothing is output through the WebAudio graph.
if (native && isMobileVoiceDevice()) {
stopNativePlayback();
pcmBatch = new Int16Array(1536); // 32 ms @ 48 kHz
pcmBatchLen = 0;
voiceTapNode = new AudioWorkletNode(audioContext, 'audio-tap');
voiceTapNode.port.onmessage = handlePcmChunk;
receiverNode.connect(voiceTapNode);
if (voicePcmFlushTimer) clearInterval(voicePcmFlushTimer);
voicePcmFlushTimer = setInterval(flushPcmBatch, 25);
if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {});
return;
}
if (!isMobileVoiceDevice()) {
receiverNode.connect(audioContext.destination);
return;
}
// Browser-only speaker mode plays through plain media output.
// With the native plugin present, routing stays on the call path
// and the speaker switch is handled natively.
if (isVoiceSpeakerMode) {
receiverNode.connect(audioContext.destination);
return;
}
voiceLoopRestarts = 0;
teardownVoiceLoopback();
voicePlayoutDest = audioContext.createMediaStreamDestination();
receiverNode.connect(voicePlayoutDest);
voiceLoopSendPc = new RTCPeerConnection();
voiceLoopRecvPc = new RTCPeerConnection();
voiceLoopSendPc.onicecandidate = e => { if (e.candidate) try { voiceLoopRecvPc.addIceCandidate(e.candidate); } catch (err) {} };
voiceLoopRecvPc.onicecandidate = e => { if (e.candidate) try { voiceLoopSendPc.addIceCandidate(e.candidate); } catch (err) {} };
voiceLoopRecvPc.onconnectionstatechange = () => {
const st = voiceLoopRecvPc && voiceLoopRecvPc.connectionState;
if (st === "failed") recoverVoiceLoopback();
};
voiceLoopRecvPc.ontrack = e => {
voiceRemoteStream = e.streams[0];
if (!voiceOutputAudio) {
voiceOutputAudio = document.createElement("audio");
voiceOutputAudio.autoplay = true;
voiceOutputAudio.setAttribute("playsinline", "");
document.body.appendChild(voiceOutputAudio);
}
voiceOutputAudio.srcObject = voiceRemoteStream;
voiceOutputAudio.play().catch(() => {});
};
document.addEventListener("click", voicePlayRetry, true);
window.addEventListener("voicemodechanged", voiceModeChangedHandler);
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener) {
voiceDeviceChangeHandler = () => {
clearTimeout(voiceDeviceChangeTimer);
// Audio route changed (BT/headset/speaker) - re-init the playout track
voiceDeviceChangeTimer = setTimeout(() => replugVoiceOutputElement(), 400);
};
navigator.mediaDevices.addEventListener("devicechange", voiceDeviceChangeHandler);
}
voiceLoopSendPc.addTrack(voicePlayoutDest.stream.getAudioTracks()[0], voicePlayoutDest.stream);
voiceLoopSendPc.createOffer()
.then(offer => voiceLoopSendPc.setLocalDescription(offer))
.then(() => voiceLoopRecvPc.setRemoteDescription(voiceLoopSendPc.localDescription))
.then(() => voiceLoopRecvPc.createAnswer())
.then(answer => voiceLoopRecvPc.setLocalDescription(answer))
.then(() => voiceLoopSendPc.setRemoteDescription(voiceLoopRecvPc.localDescription))
.catch(() => {
recoverVoiceLoopback();
});
// Safety net: verify the WebRTC playout actually produces sound and repair it if not
if (voiceLoopWatchdog) clearTimeout(voiceLoopWatchdog);
voiceLoopWatchdog = setTimeout(() => {
voiceLoopWatchdog = null;
if (!voicePlayoutDest) return;
voicePlayRetry();
setTimeout(() => {
if (!voicePlayoutDest || voicePlayoutOk()) return;
recoverVoiceLoopback();
}, 600);
}, 4000);
}
window.toggleVoiceSpeakerMode = async function() {
isVoiceSpeakerMode = !isVoiceSpeakerMode;
let btn = document.getElementById("voice-btn-speaker");
if (btn) btn.classList.toggle("voice-btn-active", isVoiceSpeakerMode);
const native = getNativeVoiceRouting();
if (native && native.setSpeaker) {
// Native routing keeps playback on the call volume stream - only the
// physical output (earpiece <-> loudspeaker) changes, like a phone call.
// The native AudioTrack follows the communication device automatically.
try { await native.setSpeaker({ enabled: isVoiceSpeakerMode }); } catch (e) {}
setTimeout(() => replugVoiceOutputElement(), 250);
setTimeout(() => replugVoiceOutputElement(), 900);
setTimeout(() => { if (voicePlayoutDest && !voicePlayoutOk()) recoverVoiceLoopback(); }, 1700);
return;
}
if (!receiverNode) return;
try { receiverNode.disconnect(); } catch (e) {}
if (!isVoiceSpeakerMode && voicePlayoutDest) {
receiverNode.connect(voicePlayoutDest);
if (voiceOutputAudio) voiceOutputAudio.play().catch(() => {});
} else {
receiverNode.connect(audioContext.destination);
if (voiceOutputAudio) { try { voiceOutputAudio.pause(); } catch (e) {} }
}
};
function setVoiceStatus(state) {
let el = document.getElementById("voice-status");
if (!el) return;
@ -88,8 +365,12 @@ function setVoiceStatus(state) {
}
}
function setTileSpeaking(clientId, speaking) {
function setTileSpeaking(clientId, speaking, streamDecoderId = null) {
let tile = document.getElementById(`voice-tile-${clientId}`);
if (streamDecoderId) {
let streamTile = document.getElementById(`voice-tile-stream-${streamDecoderId}`);
if (streamTile) tile = streamTile;
}
if (!tile) return;
if (tile._speakingTimer) {
@ -197,6 +478,25 @@ class AudioReceiver extends AudioWorkletProcessor {
} return true;
}
}
class AudioTap extends AudioWorkletProcessor {
constructor() { super(); this.acc = new Int16Array(512); this.accLen = 0; }
process(inputs) {
const ch = inputs[0] && inputs[0][0];
if (ch && this.port) {
for (let i = 0; i < ch.length; i++) {
const s = Math.max(-1, Math.min(1, ch[i]));
this.acc[this.accLen++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
if (this.accLen >= this.acc.length) {
this.port.postMessage(this.acc.buffer, [this.acc.buffer]);
this.acc = new Int16Array(512);
this.accLen = 0;
}
}
return true;
}
}
registerProcessor('audio-tap', AudioTap);
registerProcessor('audio-sender', AudioProcessor);
registerProcessor('audio-receiver', AudioReceiver);`;
@ -289,16 +589,22 @@ async function startVoiceSession(serverUrl) {
const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}};
micStream = await navigator.mediaDevices.getUserMedia(micConstraints);
const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.startCall) {
try { await nativeRouting.startCall(); } catch (e) {}
}
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
if (!isAudioWorkletLoaded) {
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
receiverNode.connect(audioContext.destination);
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true;
}
if (audioContext.state === 'suspended') await audioContext.resume();
setupVoiceOutput();
micSourceNode = audioContext.createMediaStreamSource(micStream);
micSourceNode.connect(senderNode);
@ -361,13 +667,14 @@ async function startVoiceSession(serverUrl) {
remoteAudio[audioId].lastUpdate = Date.now();
if (!audioDecryptChains[audioId]) audioDecryptChains[audioId] = new TaskQueue();
const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode;
audioDecryptChains[audioId].enqueue(async () => {
try {
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if(receiverNode) receiverNode.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
} catch (err) {}
});
} else if (type === 1 || type === 2) {
@ -443,7 +750,7 @@ async function startVoiceSession(serverUrl) {
if (remoteAudio[audioId]) {
if(audioDecryptChains[audioId]) audioDecryptChains[audioId].clear();
delete audioDecryptChains[audioId]; delete remoteAudio[audioId];
if(receiverNode) receiverNode.port.postMessage({ action: 'remove', clientId: audioId });
if(screenReceiverNode) screenReceiverNode.port.postMessage({ action: 'remove', clientId: audioId });
}
} else {
const decoderId = `${clientId}-${stoppedTypeId}`;
@ -499,7 +806,8 @@ function handleCallUpdate(data) {
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
delete audioDecoders[u];
delete remoteAudio[u];
if(receiverNode) receiverNode.port.postMessage({ action: 'remove', clientId: u });
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
if(rn) rn.port.postMessage({ action: 'remove', clientId: u });
}
}
@ -727,6 +1035,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>
${(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>` : ""}
<button id="voice-btn-hangup" class="voice-btn voice-btn-danger" onclick="endVoiceSession()" title="${processBlah("action.call.disconnect")}">
${ICONS.call({width: "1.4rem", height: "1.4rem"})}
</button>
@ -770,11 +1081,34 @@ function showVoiceUI() {
header.addEventListener('pointerup', stopDrag);
header.addEventListener('pointercancel', stopDrag);
ensureVoiceFooterFits(voiceUiContainer);
ensureVoiceGridObserver();
setupVoiceWindowResize();
}
// Raises the popup's min-width so the footer buttons always fit with the same
// side padding the footer uses, regardless of how many buttons are rendered.
function ensureVoiceFooterFits(popupEl) {
let footer = popupEl.querySelector("#voice-footer");
if (!footer) return;
const cs = getComputedStyle(footer);
let need = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
const kids = Array.from(footer.children);
const gap = parseFloat(cs.columnGap) || parseFloat(cs.gap) || 0;
kids.forEach((kid, i) => {
if (i > 0) need += gap;
need += kid.offsetWidth;
});
const bcs = getComputedStyle(popupEl);
need += ((parseFloat(bcs.borderLeftWidth) || 0) + (parseFloat(bcs.borderRightWidth) || 0));
if (need > 0) {
popupEl.style.minWidth = Math.ceil(need) + "px";
clampVoiceWindowToViewport(popupEl);
}
}
function setupVoiceWindowResize() {
const popup = voiceUiContainer;
if (!popup || popup.dataset.resizeInit) return;
@ -1046,6 +1380,14 @@ function relayoutVoiceTiles() {
const cs = getComputedStyle(container);
const innerW = container.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) {
container.style.removeProperty("--voice-tile-w");
container.style.removeProperty("--voice-tile-h");
delete container.dataset.layoutSig;
return;
}
const minCol = 6.25 * rootFs;
const minRow = 7 * rootFs;
const gap = 0.75 * rootFs;
@ -1128,8 +1470,9 @@ window.updateVoiceVol = function(clientId, val, type) {
if (el) el.innerText = val + '%';
const audioId = type === 'screen' ? `${clientId}_screen` : clientId;
const volumeMultiplier = parseInt(val) / 100.0;
if (receiverNode) {
receiverNode.port.postMessage({ action: 'setVolume', clientId: audioId, volume: volumeMultiplier });
const rn = type === 'screen' ? screenReceiverNode : receiverNode;
if (rn) {
rn.port.postMessage({ action: 'setVolume', clientId: audioId, volume: volumeMultiplier });
}
};
@ -1149,6 +1492,16 @@ function toggleVoiceMic() {
function endVoiceSession() {
setVoiceStatus("disconnected");
const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.stopCall) {
try { nativeRouting.stopCall(); } catch (e) {}
}
isVoiceSpeakerMode = false;
voiceLoopRestarts = 0;
stopNativePlayback();
pcmBatch = null;
pcmBatchLen = 0;
if (currentVoiceCallId) {
let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });
if (window.fetchEncrypted) window.fetchEncrypted("room/voice/leave", payload).catch(()=>{});
@ -1182,7 +1535,13 @@ function endVoiceSession() {
if (receiverNode) {
receiverNode.port.postMessage({ action: 'clear' });
try { receiverNode.disconnect(); } catch (e) {}
}
if (screenReceiverNode) {
screenReceiverNode.port.postMessage({ action: 'clear' });
try { screenReceiverNode.disconnect(); } catch (e) {}
}
teardownVoiceLoopback();
if (camControl) { camControl.stop(); camControl = null; }
if (screenControl) { screenControl.stop(); screenControl = null; }