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"
|
applicationId "olcxja.miarven"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 2
|
versionCode 3
|
||||||
versionName "0.1.1"
|
versionName "0.1.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// 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.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,7 @@
|
||||||
"action.call.camera": "camera",
|
"action.call.camera": "camera",
|
||||||
"action.call.screen": "share screen",
|
"action.call.screen": "share screen",
|
||||||
"action.call.disconnect": "disconnect :3",
|
"action.call.disconnect": "disconnect :3",
|
||||||
|
"action.call.speaker": "speaker :3",
|
||||||
"action.call.join": "join :3",
|
"action.call.join": "join :3",
|
||||||
"action.call.decline": "decline :c",
|
"action.call.decline": "decline :c",
|
||||||
"title.incoming.call": "incoming voice chat :3",
|
"title.incoming.call": "incoming voice chat :3",
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,7 @@
|
||||||
"action.call.mute": "Mute Microphone",
|
"action.call.mute": "Mute Microphone",
|
||||||
"action.call.camera": "Camera",
|
"action.call.camera": "Camera",
|
||||||
"action.call.screen": "Share Screen",
|
"action.call.screen": "Share Screen",
|
||||||
|
"action.call.speaker": "Speaker",
|
||||||
"action.call.disconnect": "Disconnect",
|
"action.call.disconnect": "Disconnect",
|
||||||
"action.call.join": "Join",
|
"action.call.join": "Join",
|
||||||
"action.call.decline": "Decline",
|
"action.call.decline": "Decline",
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,7 @@
|
||||||
"action.call.mute": "Wycisz mikrofon",
|
"action.call.mute": "Wycisz mikrofon",
|
||||||
"action.call.camera": "Kamera",
|
"action.call.camera": "Kamera",
|
||||||
"action.call.screen": "Udostępnij ekran",
|
"action.call.screen": "Udostępnij ekran",
|
||||||
|
"action.call.speaker": "Głośnik",
|
||||||
"action.call.disconnect": "Rozłącz",
|
"action.call.disconnect": "Rozłącz",
|
||||||
"action.call.join": "Dołącz",
|
"action.call.join": "Dołącz",
|
||||||
"action.call.decline": "Odrzuć",
|
"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),
|
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),
|
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),
|
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),
|
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),
|
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 {
|
.voice-call-btn {
|
||||||
padding: 0 0.75rem;
|
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 isMicMuted = false;
|
||||||
let remoteVideos = {}, videoDecryptChains = {};
|
let remoteVideos = {}, videoDecryptChains = {};
|
||||||
let camControl = null, screenControl = null;
|
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;
|
const SPEAKING_HANGOVER_MS = 400;
|
||||||
|
|
||||||
|
|
@ -62,6 +76,269 @@ function updateVoicePing() {
|
||||||
if (pingEl) pingEl.innerText = voicePingMs !== null ? `${voicePingMs} ms` : "";
|
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) {
|
function setVoiceStatus(state) {
|
||||||
let el = document.getElementById("voice-status");
|
let el = document.getElementById("voice-status");
|
||||||
if (!el) return;
|
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}`);
|
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) return;
|
||||||
|
|
||||||
if (tile._speakingTimer) {
|
if (tile._speakingTimer) {
|
||||||
|
|
@ -197,6 +478,25 @@ class AudioReceiver extends AudioWorkletProcessor {
|
||||||
} return true;
|
} 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-sender', AudioProcessor);
|
||||||
registerProcessor('audio-receiver', AudioReceiver);`;
|
registerProcessor('audio-receiver', AudioReceiver);`;
|
||||||
|
|
||||||
|
|
@ -290,15 +590,21 @@ async function startVoiceSession(serverUrl) {
|
||||||
const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}};
|
const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}};
|
||||||
micStream = await navigator.mediaDevices.getUserMedia(micConstraints);
|
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 (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
|
||||||
if (!isAudioWorkletLoaded) {
|
if (!isAudioWorkletLoaded) {
|
||||||
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
|
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
|
||||||
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
|
||||||
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||||
receiverNode.connect(audioContext.destination);
|
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||||
isAudioWorkletLoaded = true;
|
isAudioWorkletLoaded = true;
|
||||||
}
|
}
|
||||||
if (audioContext.state === 'suspended') await audioContext.resume();
|
if (audioContext.state === 'suspended') await audioContext.resume();
|
||||||
|
setupVoiceOutput();
|
||||||
|
|
||||||
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
||||||
micSourceNode.connect(senderNode);
|
micSourceNode.connect(senderNode);
|
||||||
|
|
@ -362,12 +668,13 @@ async function startVoiceSession(serverUrl) {
|
||||||
|
|
||||||
if (!audioDecryptChains[audioId]) audioDecryptChains[audioId] = new TaskQueue();
|
if (!audioDecryptChains[audioId]) audioDecryptChains[audioId] = new TaskQueue();
|
||||||
|
|
||||||
|
const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode;
|
||||||
audioDecryptChains[audioId].enqueue(async () => {
|
audioDecryptChains[audioId].enqueue(async () => {
|
||||||
try {
|
try {
|
||||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
|
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
|
||||||
if(receiverNode) receiverNode.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
||||||
|
|
||||||
setTileSpeaking(clientId, true);
|
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
});
|
});
|
||||||
} else if (type === 1 || type === 2) {
|
} else if (type === 1 || type === 2) {
|
||||||
|
|
@ -443,7 +750,7 @@ async function startVoiceSession(serverUrl) {
|
||||||
if (remoteAudio[audioId]) {
|
if (remoteAudio[audioId]) {
|
||||||
if(audioDecryptChains[audioId]) audioDecryptChains[audioId].clear();
|
if(audioDecryptChains[audioId]) audioDecryptChains[audioId].clear();
|
||||||
delete audioDecryptChains[audioId]; delete remoteAudio[audioId];
|
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 {
|
} else {
|
||||||
const decoderId = `${clientId}-${stoppedTypeId}`;
|
const decoderId = `${clientId}-${stoppedTypeId}`;
|
||||||
|
|
@ -499,7 +806,8 @@ function handleCallUpdate(data) {
|
||||||
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
|
if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
|
||||||
delete audioDecoders[u];
|
delete audioDecoders[u];
|
||||||
delete remoteAudio[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")}">
|
<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>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||||
</button>
|
</button>
|
||||||
|
${(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")}">
|
<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"})}
|
${ICONS.call({width: "1.4rem", height: "1.4rem"})}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -770,11 +1081,34 @@ function showVoiceUI() {
|
||||||
header.addEventListener('pointerup', stopDrag);
|
header.addEventListener('pointerup', stopDrag);
|
||||||
header.addEventListener('pointercancel', stopDrag);
|
header.addEventListener('pointercancel', stopDrag);
|
||||||
|
|
||||||
|
ensureVoiceFooterFits(voiceUiContainer);
|
||||||
|
|
||||||
ensureVoiceGridObserver();
|
ensureVoiceGridObserver();
|
||||||
|
|
||||||
setupVoiceWindowResize();
|
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() {
|
function setupVoiceWindowResize() {
|
||||||
const popup = voiceUiContainer;
|
const popup = voiceUiContainer;
|
||||||
if (!popup || popup.dataset.resizeInit) return;
|
if (!popup || popup.dataset.resizeInit) return;
|
||||||
|
|
@ -1046,6 +1380,14 @@ function relayoutVoiceTiles() {
|
||||||
const cs = getComputedStyle(container);
|
const cs = getComputedStyle(container);
|
||||||
const innerW = container.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
const innerW = container.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||||
const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
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 minCol = 6.25 * rootFs;
|
||||||
const minRow = 7 * rootFs;
|
const minRow = 7 * rootFs;
|
||||||
const gap = 0.75 * rootFs;
|
const gap = 0.75 * rootFs;
|
||||||
|
|
@ -1128,8 +1470,9 @@ window.updateVoiceVol = function(clientId, val, type) {
|
||||||
if (el) el.innerText = val + '%';
|
if (el) el.innerText = val + '%';
|
||||||
const audioId = type === 'screen' ? `${clientId}_screen` : clientId;
|
const audioId = type === 'screen' ? `${clientId}_screen` : clientId;
|
||||||
const volumeMultiplier = parseInt(val) / 100.0;
|
const volumeMultiplier = parseInt(val) / 100.0;
|
||||||
if (receiverNode) {
|
const rn = type === 'screen' ? screenReceiverNode : receiverNode;
|
||||||
receiverNode.port.postMessage({ action: 'setVolume', clientId: audioId, volume: volumeMultiplier });
|
if (rn) {
|
||||||
|
rn.port.postMessage({ action: 'setVolume', clientId: audioId, volume: volumeMultiplier });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1149,6 +1492,16 @@ function toggleVoiceMic() {
|
||||||
function endVoiceSession() {
|
function endVoiceSession() {
|
||||||
setVoiceStatus("disconnected");
|
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) {
|
if (currentVoiceCallId) {
|
||||||
let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });
|
let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });
|
||||||
if (window.fetchEncrypted) window.fetchEncrypted("room/voice/leave", payload).catch(()=>{});
|
if (window.fetchEncrypted) window.fetchEncrypted("room/voice/leave", payload).catch(()=>{});
|
||||||
|
|
@ -1182,7 +1535,13 @@ function endVoiceSession() {
|
||||||
|
|
||||||
if (receiverNode) {
|
if (receiverNode) {
|
||||||
receiverNode.port.postMessage({ action: 'clear' });
|
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 (camControl) { camControl.stop(); camControl = null; }
|
||||||
if (screenControl) { screenControl.stop(); screenControl = null; }
|
if (screenControl) { screenControl.stop(); screenControl = null; }
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ public class MainActivity extends BridgeActivity {
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "Miarven",
|
"name": "Miarven",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"description": "An Amazing Capacitor App",
|
"description": "An Amazing Capacitor App",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "",
|
"name": "",
|
||||||
|
|
|
||||||
1
node_modules/@capacitor/android/capacitor/build/.transforms/6ca7c74ad1793698ecc8b9bc2ba4daef/results.bin
generated
vendored
Normal file
1
node_modules/@capacitor/android/capacitor/build/.transforms/6ca7c74ad1793698ecc8b9bc2ba4daef/results.bin
generated
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
o/classes
|
||||||
BIN
node_modules/@capacitor/android/capacitor/build/.transforms/6ca7c74ad1793698ecc8b9bc2ba4daef/transformed/classes/classes_dex/classes.dex
generated
vendored
Normal file
BIN
node_modules/@capacitor/android/capacitor/build/.transforms/6ca7c74ad1793698ecc8b9bc2ba4daef/transformed/classes/classes_dex/classes.dex
generated
vendored
Normal file
Binary file not shown.
1
node_modules/@capacitor/android/capacitor/build/.transforms/d21ea84fc3e73a043c621d9e8954475a/results.bin
generated
vendored
Normal file
1
node_modules/@capacitor/android/capacitor/build/.transforms/d21ea84fc3e73a043c621d9e8954475a/results.bin
generated
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
o/bundleLibRuntimeToDirDebug
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue