better android app voice chat integration
This commit is contained in:
parent
88dde78581
commit
4fcc268e80
182 changed files with 1403 additions and 62 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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ć",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
const CLIENT_VERSION = "0.1.1";
|
||||
const CLIENT_VERSION = "0.1.2";
|
||||
|
|
|
|||
381
webroot/voice.js
381
webroot/voice.js
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue