better android app voice chat integration
This commit is contained in:
parent
88dde78581
commit
4fcc268e80
182 changed files with 1403 additions and 62 deletions
|
|
@ -7,8 +7,8 @@ android {
|
|||
applicationId "olcxja.miarven"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2
|
||||
versionName "0.1.1"
|
||||
versionCode 3
|
||||
versionName "0.1.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ public class MainActivity extends BridgeActivity {
|
|||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void load() {
|
||||
// Must be added to the builder BEFORE super.load() creates the bridge,
|
||||
// otherwise the plugin is silently dropped (bridge already built).
|
||||
registerPlugin(VoiceRoutingPlugin.class);
|
||||
super.load();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
471
android/app/src/main/java/olcxja/miarven/VoiceRoutingPlugin.java
Normal file
471
android/app/src/main/java/olcxja/miarven/VoiceRoutingPlugin.java
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
package olcxja.miarven;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.media.AudioAttributes;
|
||||
import android.media.AudioDeviceInfo;
|
||||
import android.media.AudioFocusRequest;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioTrack;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
import com.getcapacitor.annotation.PermissionCallback;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@CapacitorPlugin(name = "VoiceRouting", permissions = {
|
||||
@Permission(alias = "bluetooth", strings = { Manifest.permission.BLUETOOTH_CONNECT })
|
||||
})
|
||||
public class VoiceRoutingPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "VoiceRouting";
|
||||
private static final long MODE_GUARD_INTERVAL_MS = 2500;
|
||||
|
||||
private AudioManager audioManager;
|
||||
private boolean inCall = false;
|
||||
private boolean speakerOn = false;
|
||||
private AudioFocusRequest focusRequest26 = null;
|
||||
private PluginCall pendingStartCall = null;
|
||||
private Handler mainHandler;
|
||||
private boolean modeListenerAdded = false;
|
||||
|
||||
private AudioTrack nativeTrack = null;
|
||||
private Thread nativeFeedThread = null;
|
||||
private final LinkedBlockingQueue<byte[]> pcmQueue = new LinkedBlockingQueue<>();
|
||||
private volatile boolean nativePlaying = false;
|
||||
|
||||
private final AudioManager.OnAudioFocusChangeListener focusChangeListener = fi -> {};
|
||||
|
||||
private void log(String msg) {
|
||||
// android.util.Log is always visible in logcat, even for release builds
|
||||
Log.i(TAG, msg);
|
||||
}
|
||||
|
||||
private AudioManager am() {
|
||||
if (audioManager == null) {
|
||||
audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
|
||||
}
|
||||
return audioManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holding call audio focus is what makes Android treat the app as being in
|
||||
* a real call: without it the system reverts MODE_IN_COMMUNICATION back to
|
||||
* MODE_NORMAL every few seconds (AudioManager.MSG_CHECK_MODE_FOR_UID).
|
||||
*/
|
||||
private void requestCallFocus() {
|
||||
AudioManager am = am();
|
||||
if (am == null) return;
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
focusRequest26 = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(new AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build())
|
||||
.setOnAudioFocusChangeListener(focusChangeListener)
|
||||
.build();
|
||||
int res = am.requestAudioFocus(focusRequest26);
|
||||
log("audio focus request (26+): " + res);
|
||||
} else {
|
||||
@SuppressWarnings("deprecation")
|
||||
int res = am.requestAudioFocus(focusChangeListener, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN);
|
||||
log("audio focus request (legacy): " + res);
|
||||
}
|
||||
}
|
||||
|
||||
private void abandonCallFocus() {
|
||||
AudioManager am = am();
|
||||
if (am == null) return;
|
||||
if (Build.VERSION.SDK_INT >= 26 && focusRequest26 != null) {
|
||||
am.abandonAudioFocusRequest(focusRequest26);
|
||||
focusRequest26 = null;
|
||||
} else {
|
||||
@SuppressWarnings("deprecation")
|
||||
int res = am.abandonAudioFocus(focusChangeListener);
|
||||
log("audio focus abandoned: " + res);
|
||||
}
|
||||
}
|
||||
|
||||
private Handler handler() {
|
||||
if (mainHandler == null) {
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
}
|
||||
return mainHandler;
|
||||
}
|
||||
|
||||
private boolean needsBluetoothPermission() {
|
||||
return Build.VERSION.SDK_INT >= 31
|
||||
&& ContextCompat.checkSelfPermission(getContext(), Manifest.permission.BLUETOOTH_CONNECT)
|
||||
!= PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 12+ silently reverts MODE_IN_COMMUNICATION back to MODE_NORMAL.
|
||||
* When that happens, Chromium rebuilds its playout track with media usage
|
||||
* and the call stops following the call volume stream. This guard restores
|
||||
* the mode (and the route with it).
|
||||
*/
|
||||
private void assertModeAndRoute() {
|
||||
AudioManager am = am();
|
||||
if (am == null || !inCall) return;
|
||||
if (am.getMode() != AudioManager.MODE_IN_COMMUNICATION) {
|
||||
log("MODE_IN_COMMUNICATION was reverted, restoring");
|
||||
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
|
||||
applyRoute();
|
||||
// Tell the web layer so it re-initializes its WebRTC playout track,
|
||||
// which Chromium otherwise leaves tagged as media after the revert.
|
||||
try {
|
||||
getBridge().triggerWindowJSEvent("voicemodechanged");
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "failed to notify web layer", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startModeGuard() {
|
||||
AudioManager am = am();
|
||||
// Instant restore on API 30+ (fires the moment the system touches the mode)
|
||||
if (Build.VERSION.SDK_INT >= 30 && am != null && !modeListenerAdded) {
|
||||
try {
|
||||
am.addOnModeChangedListener(getContext().getMainExecutor(), modeChangedListener);
|
||||
modeListenerAdded = true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "addOnModeChangedListener failed", e);
|
||||
}
|
||||
}
|
||||
handler().postDelayed(guardRunnable, MODE_GUARD_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private void stopModeGuard() {
|
||||
AudioManager am = am();
|
||||
if (modeListenerAdded && am != null) {
|
||||
try {
|
||||
am.removeOnModeChangedListener(modeChangedListener);
|
||||
} catch (Exception e) {}
|
||||
modeListenerAdded = false;
|
||||
}
|
||||
handler().removeCallbacks(guardRunnable);
|
||||
}
|
||||
|
||||
private final Runnable guardRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!inCall) return;
|
||||
assertModeAndRoute();
|
||||
handler().postDelayed(this, MODE_GUARD_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
private final AudioManager.OnModeChangedListener modeChangedListener = new AudioManager.OnModeChangedListener() {
|
||||
@Override
|
||||
public void onModeChanged(int mode) {
|
||||
if (!inCall) return;
|
||||
log("onModeChanged -> " + mode);
|
||||
assertModeAndRoute();
|
||||
}
|
||||
};
|
||||
|
||||
@PluginMethod
|
||||
public void startCall(PluginCall call) {
|
||||
if (needsBluetoothPermission()) {
|
||||
pendingStartCall = call;
|
||||
requestAllPermissions(call, "onBtPermissionResult");
|
||||
return;
|
||||
}
|
||||
beginCall(call);
|
||||
}
|
||||
|
||||
@PermissionCallback
|
||||
private void onBtPermissionResult(PluginCall call) {
|
||||
// Bluetooth permission is optional - continue regardless of the outcome.
|
||||
if (pendingStartCall == call) {
|
||||
pendingStartCall = null;
|
||||
beginCall(call);
|
||||
}
|
||||
}
|
||||
|
||||
private void beginCall(PluginCall call) {
|
||||
AudioManager am = am();
|
||||
if (am == null) {
|
||||
call.reject("AudioManager unavailable");
|
||||
return;
|
||||
}
|
||||
inCall = true;
|
||||
speakerOn = false;
|
||||
requestCallFocus();
|
||||
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
|
||||
applyRoute();
|
||||
startModeGuard();
|
||||
setVolumeControlStreamCompat(AudioManager.STREAM_VOICE_CALL);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
private void setVolumeControlStreamCompat(int stream) {
|
||||
try {
|
||||
Context ctx = getContext();
|
||||
if (ctx instanceof Activity) {
|
||||
((Activity) ctx).setVolumeControlStream(stream);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "setVolumeControlStream failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void nativeAudioStart(PluginCall call) {
|
||||
Integer sr = call.getInt("sampleRate");
|
||||
int sampleRate = (sr == null || sr < 8000) ? 48000 : sr;
|
||||
|
||||
stopNativeAudioInternal();
|
||||
|
||||
int minBuf = AudioTrack.getMinBufferSize(sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT);
|
||||
if (minBuf <= 0) {
|
||||
call.reject("getMinBufferSize failed: " + minBuf);
|
||||
return;
|
||||
}
|
||||
|
||||
AudioAttributes attrs = new AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build();
|
||||
AudioFormat format = new AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build();
|
||||
|
||||
// Keep the buffer tight - its capacity directly adds to playback latency,
|
||||
// because blocking writes keep it filled close to 100% in steady state.
|
||||
int bufBytes = Math.max(minBuf, 1920); // >= 20 ms @ 48 kHz mono 16-bit
|
||||
|
||||
AudioTrack.Builder builder = new AudioTrack.Builder()
|
||||
.setAudioAttributes(attrs)
|
||||
.setAudioFormat(format)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(bufBytes);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
builder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY);
|
||||
}
|
||||
|
||||
nativeTrack = builder.build();
|
||||
pcmQueue.clear();
|
||||
nativePlaying = true;
|
||||
try {
|
||||
nativeTrack.play();
|
||||
} catch (IllegalStateException e) {
|
||||
stopNativeAudioInternal();
|
||||
call.reject("AudioTrack.play failed", e);
|
||||
return;
|
||||
}
|
||||
|
||||
nativeFeedThread = new Thread(() -> {
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO);
|
||||
while (nativePlaying) {
|
||||
byte[] chunk;
|
||||
try {
|
||||
chunk = pcmQueue.poll(100, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException ie) {
|
||||
break;
|
||||
}
|
||||
if (chunk == null) continue;
|
||||
AudioTrack track = nativeTrack;
|
||||
if (track == null || !nativePlaying) break;
|
||||
int written = track.write(chunk, 0, chunk.length, AudioTrack.WRITE_BLOCKING);
|
||||
if (written < 0) {
|
||||
Log.w(TAG, "AudioTrack.write error: " + written);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, "VoiceRoutingFeed");
|
||||
nativeFeedThread.start();
|
||||
|
||||
log("native audio started, rate=" + sampleRate
|
||||
+ " minBuf=" + minBuf
|
||||
+ " bufBytes=" + bufBytes
|
||||
+ " (~" + (bufBytes * 500 / sampleRate) + " ms)");
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void nativeAudioFeed(PluginCall call) {
|
||||
String b64 = call.getString("data");
|
||||
if (!nativePlaying || b64 == null || nativeTrack == null) {
|
||||
call.resolve();
|
||||
return;
|
||||
}
|
||||
byte[] pcm;
|
||||
try {
|
||||
pcm = android.util.Base64.decode(b64, android.util.Base64.NO_WRAP);
|
||||
} catch (IllegalArgumentException e) {
|
||||
call.resolve();
|
||||
return;
|
||||
}
|
||||
while (pcmQueue.size() > 10) {
|
||||
pcmQueue.poll(); // drop oldest to avoid unbounded growth
|
||||
}
|
||||
pcmQueue.offer(pcm);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void nativeAudioStop(PluginCall call) {
|
||||
stopNativeAudioInternal();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
private void stopNativeAudioInternal() {
|
||||
nativePlaying = false;
|
||||
if (nativeFeedThread != null) {
|
||||
nativeFeedThread.interrupt();
|
||||
nativeFeedThread = null;
|
||||
}
|
||||
if (nativeTrack != null) {
|
||||
try {
|
||||
nativeTrack.pause();
|
||||
nativeTrack.flush();
|
||||
nativeTrack.release();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "AudioTrack release failed", e);
|
||||
}
|
||||
nativeTrack = null;
|
||||
}
|
||||
pcmQueue.clear();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void stopCall(PluginCall call) {
|
||||
inCall = false;
|
||||
stopModeGuard();
|
||||
abandonCallFocus();
|
||||
stopNativeAudioInternal();
|
||||
setVolumeControlStreamCompat(AudioManager.STREAM_MUSIC);
|
||||
|
||||
AudioManager am = am();
|
||||
if (am != null) {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
am.clearCommunicationDevice();
|
||||
}
|
||||
am.setSpeakerphoneOn(false);
|
||||
am.setMode(AudioManager.MODE_NORMAL);
|
||||
}
|
||||
speakerOn = false;
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void setSpeaker(PluginCall call) {
|
||||
speakerOn = Boolean.TRUE.equals(call.getBoolean("enabled", false));
|
||||
applyRoute();
|
||||
JSObject ret = new JSObject();
|
||||
ret.put("enabled", speakerOn);
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void debugInfo(PluginCall call) {
|
||||
AudioManager am = am();
|
||||
JSObject ret = new JSObject();
|
||||
if (am == null) {
|
||||
ret.put("error", "no audio manager");
|
||||
call.resolve(ret);
|
||||
return;
|
||||
}
|
||||
ret.put("sdkInt", Build.VERSION.SDK_INT);
|
||||
ret.put("mode", am.getMode());
|
||||
ret.put("inCall", inCall);
|
||||
ret.put("speakerOn", speakerOn);
|
||||
|
||||
java.util.List<String> comm = new java.util.ArrayList<>();
|
||||
for (AudioDeviceInfo d : am.getAvailableCommunicationDevices()) {
|
||||
comm.add(String.valueOf(d.getType()));
|
||||
}
|
||||
ret.put("commDevices", String.join(",", comm));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
AudioDeviceInfo cur = am.getCommunicationDevice();
|
||||
ret.put("commDeviceType", cur == null ? -1 : cur.getType());
|
||||
}
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
private boolean applyRoute() {
|
||||
AudioManager am = am();
|
||||
if (am == null) return false;
|
||||
|
||||
// Communication mode must be active for the whole duration of the call,
|
||||
// otherwise Chromium tags its playout track as media.
|
||||
if (am.getMode() != AudioManager.MODE_IN_COMMUNICATION) {
|
||||
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
|
||||
}
|
||||
|
||||
boolean routed = false;
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
AudioDeviceInfo target;
|
||||
if (speakerOn) {
|
||||
target = findDevice(am, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
|
||||
} else {
|
||||
target = findPreferredNonSpeaker(am);
|
||||
}
|
||||
if (target != null) {
|
||||
routed = am.setCommunicationDevice(target);
|
||||
}
|
||||
log("route -> speaker=" + speakerOn
|
||||
+ " device=" + (target == null ? "default" : String.valueOf(target.getType()))
|
||||
+ " applied=" + routed
|
||||
+ " mode=" + am.getMode());
|
||||
if (target == null && !speakerOn) {
|
||||
// No earpiece/headset available (e.g. tablet): keep default comm routing
|
||||
routed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!routed) {
|
||||
// Legacy path (Android < 12, or communication device rejected)
|
||||
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
|
||||
am.setSpeakerphoneOn(speakerOn);
|
||||
routed = true;
|
||||
}
|
||||
return routed;
|
||||
}
|
||||
|
||||
private static AudioDeviceInfo findPreferredNonSpeaker(AudioManager am) {
|
||||
int[] preference = {
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADSET,
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
|
||||
AudioDeviceInfo.TYPE_USB_HEADSET,
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
|
||||
AudioDeviceInfo.TYPE_BLE_HEADSET,
|
||||
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
|
||||
};
|
||||
for (int type : preference) {
|
||||
AudioDeviceInfo d = findDevice(am, type);
|
||||
if (d != null) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AudioDeviceInfo findDevice(AudioManager am, int type) {
|
||||
List<AudioDeviceInfo> devices = am.getAvailableCommunicationDevices();
|
||||
for (AudioDeviceInfo d : devices) {
|
||||
if (d.getType() == type) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue