cloudlog/assets/js/remote-operation.js
Peter Goodhall 4f462683a7 Add microphone mute toggle to remote operation
Add a microphone mute control to the remote operation UI and wire it into the audio pipeline and settings. A new checkbox (remoteOperationMicMuteToggle) is added to the view. JavaScript gains a micMuted setting (default true), persistence to localStorage, UI syncing, and an applyMicMuteState() helper which enables/disables audio tracks on the media stream. The setting is applied when a stream is attached and updated immediately when the user toggles the checkbox.
2026-05-16 14:19:50 +01:00

1647 lines
58 KiB
JavaScript

(function() {
'use strict';
const STORAGE = {
wsUrl: 'cloudlog.remoteOperation.wsUrl',
linkPassword: 'cloudlog.remoteOperation.linkPassword',
linkName: 'cloudlog.remoteOperation.linkName',
radioName: 'cloudlog.remoteOperation.radioName',
audioIn: 'cloudlog.remoteOperation.audioIn',
audioOut: 'cloudlog.remoteOperation.audioOut',
playbackVolume: 'cloudlog.remoteOperation.playbackVolume',
playbackMuted: 'cloudlog.remoteOperation.playbackMuted',
micMuted: 'cloudlog.remoteOperation.micMuted',
stun: 'cloudlog.remoteOperation.stun',
turnUrl: 'cloudlog.remoteOperation.turnUrl',
turnUser: 'cloudlog.remoteOperation.turnUser',
turnPwd: 'cloudlog.remoteOperation.turnPwd',
minimalMic: 'cloudlog.remoteOperation.minimalMicProcessing',
preferOpus: 'cloudlog.remoteOperation.preferOpus',
playoutHint: 'cloudlog.remoteOperation.playoutDelayHint',
statsPoll: 'cloudlog.remoteOperation.statsPoll'
};
const DEFAULTS = {
wsUrl: 'wss://relay.cloudlog.org/ws-webrtc',
linkPassword: '',
linkName: '',
radioName: '',
audioIn: '',
audioOut: '',
playbackVolume: 50,
playbackMuted: false,
micMuted: true,
stun: 'stun:stun.l.google.com:19302',
turnUrl: '',
turnUser: '',
turnPwd: '',
minimalMic: false,
preferOpus: false,
playoutHint: true,
statsPoll: false
};
let ws = null;
let pc = null;
let mediaStream = null;
let pendingRemoteIce = [];
let wsInboundChain = Promise.resolve();
let statsTimer = undefined;
let rxLevelTimer = undefined;
let audioWatchdogTimer = undefined;
let reconnectTimer = undefined;
let reconnectAttempt = 0;
let reconnectCount = 0;
let shouldAutoReconnect = false;
let manualDisconnectRequested = false;
let lastInboundPacketCount = 0;
let lastInboundPacketTs = 0;
let currentStatusState = 'disconnected';
const reconnectBackoffMs = [1000, 2000, 5000, 10000];
let currentSettings = Object.assign({}, DEFAULTS);
let levelAudioContext = null;
let levelMeterCleanup = {
mic: null,
rx: null
};
let rxLevelSample = {
energy: null,
duration: null
};
let quickDiag = {
speaker: 'default',
inboundPackets: 0,
jitterMs: 'n/a',
reconnects: 0,
state: 'Disconnected'
};
function $(id) {
return document.getElementById(id);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function log(line, cls) {
const logEl = $('remoteOperationLog');
if (!logEl) {
return;
}
const t = new Date().toISOString().slice(11, 19);
const span = cls ? '<span class="' + cls + '">' : '<span>';
logEl.innerHTML += span + '[' + t + '] ' + escapeHtml(line) + '</span>\n';
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, className) {
['remote_operation_status', 'remoteOperationModalStatus'].forEach(function(id) {
const el = $(id);
if (!el) {
return;
}
el.className = className;
el.innerHTML = text;
});
}
function setDeviceWarning(message) {
['remoteOperationDeviceWarning', 'remoteOperationModalDeviceWarning'].forEach(function(id) {
const box = $(id);
if (!box) {
return;
}
if (message) {
box.textContent = message;
box.classList.remove('d-none');
} else {
box.textContent = '';
box.classList.add('d-none');
}
});
}
function renderQuickDiagnostics() {
const el = $('remoteOperationQuickDiagnostics');
if (!el) {
return;
}
el.textContent = 'State: ' + quickDiag.state + ' | speaker: ' + quickDiag.speaker + ' | inbound packets: ' + quickDiag.inboundPackets + ' | jitter: ' + quickDiag.jitterMs + ' | reconnects: ' + quickDiag.reconnects;
}
function setAudioUnlockHint(message) {
const hint = $('remoteOperationAudioUnlockHint');
const button = $('remoteOperationEnableAudioButton');
if (hint) {
if (message) {
hint.textContent = message;
hint.classList.remove('d-none');
} else {
hint.textContent = '';
hint.classList.add('d-none');
}
}
if (button) {
if (message) {
button.classList.remove('d-none');
} else {
button.classList.add('d-none');
}
}
}
function setOperationalState(state, detail) {
currentStatusState = state;
let label = 'Disconnected';
let className = 'badge bg-secondary';
if (state === 'connecting') {
label = 'Connecting';
className = 'badge bg-warning text-dark';
} else if (state === 'paired') {
label = 'Connected';
className = 'badge bg-success';
} else if (state === 'media') {
label = 'Media flowing';
className = 'badge bg-success';
} else if (state === 'noaudio') {
label = 'Connected, no audio';
className = 'badge bg-warning text-dark';
} else if (state === 'reconnecting') {
label = detail || 'Reconnecting';
className = 'badge bg-warning text-dark';
} else if (state === 'failed') {
label = detail || 'Connection failed';
className = 'badge bg-danger';
}
setStatus('Status: ' + label, className);
quickDiag.state = label;
renderQuickDiagnostics();
}
function clearReconnectTimer() {
if (reconnectTimer !== undefined) {
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
}
function scheduleReconnect(reason) {
if (!shouldAutoReconnect) {
return;
}
clearReconnectTimer();
const delay = reconnectBackoffMs[Math.min(reconnectAttempt, reconnectBackoffMs.length - 1)];
reconnectAttempt += 1;
reconnectCount += 1;
quickDiag.reconnects = reconnectCount;
renderQuickDiagnostics();
setOperationalState('reconnecting', 'Reconnecting in ' + Math.round(delay / 1000) + 's');
log('Connection dropped (' + reason + '). Reconnect attempt ' + reconnectAttempt + ' in ' + delay + ' ms.', 'err');
reconnectTimer = window.setTimeout(function() {
connectRemoteOperation(true);
}, delay);
}
function applyPlaybackControlsToAudioElement() {
const rx = $('remoteOperationRx');
if (!rx) {
return;
}
const volume = Math.max(0, Math.min(100, Number(currentSettings.playbackVolume || 0)));
rx.volume = volume / 100;
rx.muted = !!currentSettings.playbackMuted;
}
function applyMicMuteState() {
if (!mediaStream) {
return;
}
mediaStream.getAudioTracks().forEach(function(track) {
track.enabled = !currentSettings.micMuted;
});
}
function syncPlaybackControlsToUi() {
const volumeInput = $('remoteOperationPlaybackVolume');
const muteToggle = $('remoteOperationMuteToggle');
const micMuteToggle = $('remoteOperationMicMuteToggle');
if (volumeInput) {
volumeInput.value = String(Math.max(0, Math.min(100, Number(currentSettings.playbackVolume || 0))));
}
if (muteToggle) {
muteToggle.checked = !!currentSettings.playbackMuted;
}
if (micMuteToggle) {
micMuteToggle.checked = !!currentSettings.micMuted;
}
}
async function applySelectedSpeakerOutput() {
const rx = $('remoteOperationRx');
if (!rx) {
return;
}
const select = $('remoteOperationAudioOut');
const selectedId = select ? select.value : currentSettings.audioOut;
const selectedLabel = select && select.selectedOptions && select.selectedOptions.length > 0
? select.selectedOptions[0].textContent
: (selectedId ? 'custom' : 'default');
quickDiag.speaker = selectedLabel || 'default';
renderQuickDiagnostics();
if (!selectedId) {
currentSettings.audioOut = '';
writeSettingsToStorage(currentSettings);
return;
}
if (!rx.setSinkId) {
setDeviceWarning('This browser does not support selecting a specific speaker output.');
return;
}
try {
await rx.setSinkId(selectedId);
currentSettings.audioOut = selectedId;
writeSettingsToStorage(currentSettings);
setDeviceWarning('');
} catch (error) {
setDeviceWarning('Could not switch to the selected speaker output.');
log('setSinkId failed: ' + String(error && error.message ? error.message : error), 'err');
}
}
async function tryEnableAudioPlayback(triggeredByUser) {
const rx = $('remoteOperationRx');
if (!rx) {
return;
}
const ctx = getLevelAudioContext();
if (ctx && ctx.state === 'suspended') {
try {
await ctx.resume();
} catch (_) {}
}
applyPlaybackControlsToAudioElement();
try {
await rx.play();
setAudioUnlockHint('');
} catch (_) {
if (!triggeredByUser) {
setAudioUnlockHint('Browser blocked autoplay. Click Enable Audio.');
}
}
}
function setButtonState(state) {
const isConnected = state === 'connected';
const isConnecting = state === 'connecting';
['remoteOperationConnectButton', 'remoteOperationModalConnectButton'].forEach(function(id) {
const connectButton = $(id);
if (connectButton) {
connectButton.disabled = isConnecting;
connectButton.textContent = isConnected ? 'Disconnect' : (isConnecting ? 'Connecting...' : 'Connect');
connectButton.className = isConnected ? 'btn btn-sm btn-danger' : 'btn btn-sm btn-primary';
}
});
}
function isAudioStat(stat) {
const media = String(stat && (stat.kind || stat.mediaType) ? (stat.kind || stat.mediaType) : '').toLowerCase();
return media === 'audio';
}
function setLevelBar(id, value) {
const bar = $(id);
if (!bar) {
return;
}
const safeValue = Math.max(0, Math.min(100, Math.round(value || 0)));
bar.style.width = safeValue + '%';
bar.textContent = safeValue + '%';
bar.setAttribute('aria-valuenow', String(safeValue));
}
function resetLevelBars() {
setLevelBar('remoteOperationMicLevel', 0);
setLevelBar('remoteOperationRxLevel', 0);
}
function getLevelAudioContext() {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) {
return null;
}
if (!levelAudioContext) {
levelAudioContext = new Ctor();
}
if (levelAudioContext.state === 'suspended') {
levelAudioContext.resume().catch(function() {});
}
return levelAudioContext;
}
function stopLevelMeter(kind) {
if (levelMeterCleanup[kind]) {
levelMeterCleanup[kind]();
levelMeterCleanup[kind] = null;
}
if (kind === 'mic') {
setLevelBar('remoteOperationMicLevel', 0);
} else if (kind === 'rx') {
setLevelBar('remoteOperationRxLevel', 0);
}
}
function attachLevelMeter(kind, stream, barId) {
stopLevelMeter(kind);
if (!stream) {
return;
}
const audioContext = getLevelAudioContext();
if (!audioContext) {
return;
}
const analyser = audioContext.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.7;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const samples = new Uint8Array(analyser.fftSize);
let frameId = null;
function tick() {
analyser.getByteTimeDomainData(samples);
let sum = 0;
for (let index = 0; index < samples.length; index++) {
const normalized = (samples[index] - 128) / 128;
sum += normalized * normalized;
}
const rms = Math.sqrt(sum / samples.length);
const level = Math.min(100, rms * 220);
setLevelBar(barId, level);
frameId = window.requestAnimationFrame(tick);
}
tick();
levelMeterCleanup[kind] = function() {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
frameId = null;
}
try {
source.disconnect();
} catch (_) {}
try {
analyser.disconnect();
} catch (_) {}
};
}
function readSettingsFromStorage() {
const settings = Object.assign({}, DEFAULTS);
settings.wsUrl = localStorage.getItem(STORAGE.wsUrl) || DEFAULTS.wsUrl;
settings.linkPassword = '';
settings.linkName = localStorage.getItem(STORAGE.linkName) || '';
settings.radioName = localStorage.getItem(STORAGE.radioName) || '';
settings.audioIn = localStorage.getItem(STORAGE.audioIn) || '';
settings.audioOut = localStorage.getItem(STORAGE.audioOut) || '';
settings.playbackVolume = Number(localStorage.getItem(STORAGE.playbackVolume));
if (!Number.isFinite(settings.playbackVolume)) {
settings.playbackVolume = DEFAULTS.playbackVolume;
}
settings.playbackMuted = localStorage.getItem(STORAGE.playbackMuted) === '1';
settings.micMuted = localStorage.getItem(STORAGE.micMuted) === '1';
settings.stun = localStorage.getItem(STORAGE.stun) || DEFAULTS.stun;
settings.turnUrl = localStorage.getItem(STORAGE.turnUrl) || '';
settings.turnUser = localStorage.getItem(STORAGE.turnUser) || '';
settings.turnPwd = localStorage.getItem(STORAGE.turnPwd) || '';
settings.minimalMic = localStorage.getItem(STORAGE.minimalMic) === '1';
settings.preferOpus = localStorage.getItem(STORAGE.preferOpus) === '1';
settings.playoutHint = localStorage.getItem(STORAGE.playoutHint) !== '0';
settings.statsPoll = localStorage.getItem(STORAGE.statsPoll) === '1';
currentSettings = settings;
return settings;
}
function writeSettingsToStorage(settings) {
localStorage.setItem(STORAGE.wsUrl, settings.wsUrl || DEFAULTS.wsUrl);
localStorage.setItem(STORAGE.linkName, settings.linkName || '');
localStorage.setItem(STORAGE.radioName, settings.radioName || '');
localStorage.setItem(STORAGE.audioIn, settings.audioIn || '');
localStorage.setItem(STORAGE.audioOut, settings.audioOut || '');
localStorage.setItem(STORAGE.playbackVolume, String(Math.max(0, Math.min(100, Number(settings.playbackVolume || 0)))));
localStorage.setItem(STORAGE.playbackMuted, settings.playbackMuted ? '1' : '0');
localStorage.setItem(STORAGE.micMuted, settings.micMuted ? '1' : '0');
localStorage.setItem(STORAGE.stun, settings.stun || DEFAULTS.stun);
localStorage.setItem(STORAGE.turnUrl, settings.turnUrl || '');
localStorage.setItem(STORAGE.turnUser, settings.turnUser || '');
localStorage.setItem(STORAGE.turnPwd, settings.turnPwd || '');
localStorage.setItem(STORAGE.minimalMic, settings.minimalMic ? '1' : '0');
localStorage.setItem(STORAGE.preferOpus, settings.preferOpus ? '1' : '0');
localStorage.setItem(STORAGE.playoutHint, settings.playoutHint ? '1' : '0');
localStorage.setItem(STORAGE.statsPoll, settings.statsPoll ? '1' : '0');
currentSettings = settings;
}
async function loadRemoteOperationSecretFromAccount() {
const response = await fetch(base_url + 'index.php/qso/remoteoperationsecret_json', {
method: 'GET',
credentials: 'same-origin'
});
const data = await response.json();
if (!data || data.status !== 'ok') {
throw new Error(data && data.message ? data.message : 'Failed to load remote operation secret');
}
currentSettings.linkPassword = typeof data.link_password === 'string' ? data.link_password : '';
const input = $('remoteOperationLinkPassword');
if (input) {
input.value = currentSettings.linkPassword;
}
}
async function saveRemoteOperationSecretToAccount(linkPassword) {
const body = new URLSearchParams();
body.set('link_password', linkPassword || '');
const response = await fetch(base_url + 'index.php/qso/remoteoperationsecret_save', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: body.toString()
});
const data = await response.json();
if (!data || data.status !== 'ok') {
throw new Error(data && data.message ? data.message : 'Failed to save remote operation secret');
}
currentSettings.linkPassword = linkPassword || '';
}
function dismissRemoteOperationMarkup() {
const modalEl = getModal();
const backdropEl = $('modal-backdrop');
if (modalEl) {
modalEl.classList.remove('show');
modalEl.style.display = 'none';
modalEl.remove();
}
if (backdropEl) {
backdropEl.classList.remove('show');
backdropEl.style.display = 'none';
backdropEl.remove();
}
const mountPoint = $('remote-modals-here');
if (mountPoint) {
mountPoint.innerHTML = '';
}
}
function parseIceServers(settings) {
const servers = (settings.stun || DEFAULTS.stun)
.split(',')
.map(function(server) { return server.trim(); })
.filter(Boolean)
.map(function(url) { return { urls: url }; });
if (servers.length === 0) {
servers.push({ urls: DEFAULTS.stun });
}
if (settings.turnUrl) {
servers.push({
urls: settings.turnUrl,
username: settings.turnUser || '',
credential: settings.turnPwd || ''
});
}
return servers;
}
function buildMicConstraints(audioConstraint, settings) {
if (!settings.minimalMic || audioConstraint === true) {
return { audio: audioConstraint, video: false };
}
const proc = {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
};
const mic = typeof audioConstraint === 'object' && audioConstraint && 'deviceId' in audioConstraint
? Object.assign({}, proc, audioConstraint)
: proc;
return { audio: mic, video: false };
}
function latencyMinimalMicSel() {
return $('remoteOperationMinimalMicProcessing');
}
function applyPlayoutDelayHints(rtpPc, settings) {
if (!settings.playoutHint) {
return;
}
rtpPc.getTransceivers().forEach(function(transceiver) {
if (!transceiver.receiver || transceiver.receiver.track?.kind !== 'audio') {
return;
}
try {
transceiver.receiver.playoutDelayHint = 0;
} catch (_) {
// Unsupported in this browser build.
}
});
}
function preferOpusInOffer(rtpPc) {
const caps = window.RTCRtpSender && RTCRtpSender.getCapabilities ? RTCRtpSender.getCapabilities('audio') : null;
const list = caps && caps.codecs;
if (!list || list.length === 0) {
return;
}
const codecs = list.slice();
const idx = codecs.findIndex(function(codec) {
return (codec.mimeType || '').toLowerCase().includes('opus');
});
if (idx <= 0) {
return;
}
const opus = codecs.splice(idx, 1)[0];
codecs.unshift(opus);
rtpPc.getTransceivers().forEach(function(transceiver) {
if (!transceiver.sender || !transceiver.sender.track || transceiver.sender.track.kind !== 'audio') {
return;
}
try {
transceiver.setCodecPreferences(codecs);
} catch (_) {
// Keep the default codec order if the browser rejects the list.
}
});
}
async function populateDevices() {
const settings = readSettingsFromStorage();
setDeviceWarning('');
try {
await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
} catch (_) {
// Enumeration may still work.
}
const audioIn = $('remoteOperationAudioIn');
const audioOut = $('remoteOperationAudioOut');
if (!audioIn || !audioOut) {
return;
}
audioIn.innerHTML = '';
audioOut.innerHTML = '';
const devices = await navigator.mediaDevices.enumerateDevices();
const inputs = devices.filter(function(device) { return device.kind === 'audioinput'; });
const outputs = devices.filter(function(device) { return device.kind === 'audiooutput'; });
inputs.forEach(function(device) {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || ('mic ' + device.deviceId.slice(0, 6));
audioIn.appendChild(option);
});
outputs.forEach(function(device) {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || ('out ' + device.deviceId.slice(0, 6));
audioOut.appendChild(option);
});
if (settings.audioIn) {
audioIn.value = settings.audioIn;
}
if (settings.audioOut) {
audioOut.value = settings.audioOut;
}
const missing = [];
if (settings.audioIn && audioIn.value !== settings.audioIn) {
missing.push('mic input');
if (audioIn.options.length > 0) {
audioIn.selectedIndex = 0;
}
currentSettings.audioIn = audioIn.value || '';
}
if (settings.audioOut && audioOut.value !== settings.audioOut) {
missing.push('speaker output');
if (audioOut.options.length > 0) {
audioOut.selectedIndex = 0;
}
currentSettings.audioOut = audioOut.value || '';
}
if (missing.length > 0) {
setDeviceWarning('Saved ' + missing.join(' and ') + ' not found. Falling back to current defaults.');
writeSettingsToStorage(currentSettings);
}
await applySelectedSpeakerOutput();
log('Device list refreshed.', 'ok');
}
function syncCardButtons() {
const connected = !!(pc && pc.connectionState && pc.connectionState !== 'closed');
setButtonState(connected ? 'connected' : 'disconnected');
}
function stopRxLevelPolling() {
if (rxLevelTimer !== undefined) {
clearInterval(rxLevelTimer);
rxLevelTimer = undefined;
}
rxLevelSample.energy = null;
rxLevelSample.duration = null;
}
function stopAudioWatchdog() {
if (audioWatchdogTimer !== undefined) {
clearInterval(audioWatchdogTimer);
audioWatchdogTimer = undefined;
}
lastInboundPacketTs = 0;
lastInboundPacketCount = 0;
}
function startAudioWatchdog() {
stopAudioWatchdog();
lastInboundPacketTs = Date.now();
audioWatchdogTimer = setInterval(function() {
if (!pc || pc.connectionState !== 'connected') {
return;
}
if (Date.now() - lastInboundPacketTs > 8000) {
setOperationalState('noaudio');
}
}, 3000);
}
async function sampleRxLevel() {
if (!pc) {
setLevelBar('remoteOperationRxLevel', 0);
return;
}
const stats = await pc.getStats();
let audioLevel = null;
let totalAudioEnergy = null;
let totalSamplesDuration = null;
let packetsReceived = null;
let jitterMs = null;
stats.forEach(function(r) {
const isInboundAudio = (r.type === 'inbound-rtp' || r.type === 'track') && isAudioStat(r);
if (!isInboundAudio) {
return;
}
if (audioLevel == null && Number.isFinite(Number(r.audioLevel))) {
audioLevel = Number(r.audioLevel);
}
if (totalAudioEnergy == null && Number.isFinite(Number(r.totalAudioEnergy))) {
totalAudioEnergy = Number(r.totalAudioEnergy);
}
if (totalSamplesDuration == null && Number.isFinite(Number(r.totalSamplesDuration))) {
totalSamplesDuration = Number(r.totalSamplesDuration);
}
if (packetsReceived == null && Number.isFinite(Number(r.packetsReceived))) {
packetsReceived = Number(r.packetsReceived);
}
if (jitterMs == null && Number.isFinite(Number(r.jitter))) {
jitterMs = Number(r.jitter) * 1000;
}
if (jitterMs == null && r.jitterBufferDelay != null && r.jitterBufferEmittedCount) {
const emitted = Number(r.jitterBufferEmittedCount);
if (emitted > 0) {
jitterMs = (Number(r.jitterBufferDelay) / emitted) * 1000;
}
}
});
if (Number.isFinite(packetsReceived)) {
if (packetsReceived > lastInboundPacketCount) {
lastInboundPacketCount = packetsReceived;
lastInboundPacketTs = Date.now();
if (currentStatusState === 'noaudio') {
setOperationalState('media');
}
}
quickDiag.inboundPackets = Math.round(packetsReceived);
quickDiag.jitterMs = Number.isFinite(jitterMs) ? jitterMs.toFixed(2) + ' ms' : 'n/a';
renderQuickDiagnostics();
}
if (audioLevel != null) {
setLevelBar('remoteOperationRxLevel', Math.min(100, audioLevel * 140));
return;
}
if (totalAudioEnergy == null || totalSamplesDuration == null) {
setLevelBar('remoteOperationRxLevel', 0);
return;
}
if (rxLevelSample.energy == null || rxLevelSample.duration == null) {
rxLevelSample.energy = totalAudioEnergy;
rxLevelSample.duration = totalSamplesDuration;
setLevelBar('remoteOperationRxLevel', 0);
return;
}
const deltaEnergy = totalAudioEnergy - rxLevelSample.energy;
const deltaDuration = totalSamplesDuration - rxLevelSample.duration;
rxLevelSample.energy = totalAudioEnergy;
rxLevelSample.duration = totalSamplesDuration;
if (!(deltaEnergy > 0) || !(deltaDuration > 0)) {
setLevelBar('remoteOperationRxLevel', 0);
return;
}
const rms = Math.sqrt(deltaEnergy / deltaDuration);
setLevelBar('remoteOperationRxLevel', Math.min(100, rms * 160));
}
function startRxLevelPolling() {
stopRxLevelPolling();
rxLevelTimer = setInterval(function() {
sampleRxLevel().catch(function() {});
}, 250);
sampleRxLevel().catch(function() {});
}
function stopStatsPolling() {
if (statsTimer !== undefined) {
clearInterval(statsTimer);
statsTimer = undefined;
}
}
function startStatsPolling() {
stopStatsPolling();
statsTimer = setInterval(function() {
collectAndRenderStats(false).catch(function() {});
}, 2000);
}
async function collectAndRenderStats(logAnomalies) {
const box = $('remoteOperationStats');
if (!pc || !box) {
return;
}
const stats = await pc.getStats();
let connState = pc.connectionState;
let iceState = pc.iceConnectionState;
let iceRttMs = null;
let mediaRttMs = null;
const byId = new Map();
stats.forEach(function(r) {
byId.set(r.id, r);
});
stats.forEach(function(r) {
if (r.type === 'candidate-pair') {
const ok = r.state === 'succeeded' || r.state === 'in-progress';
if (ok && (r.currentRoundTripTime != null || r.totalRoundTripTime != null)) {
const seconds = r.currentRoundTripTime != null ? Number(r.currentRoundTripTime) : Number(r.totalRoundTripTime);
if (Number.isFinite(seconds) && seconds > 0) {
const ms = seconds * 1000;
if (r.nominated || iceRttMs == null || ms < iceRttMs) {
iceRttMs = ms;
}
}
}
}
if (r.type === 'remote-inbound-rtp' && isAudioStat(r) && r.roundTripTime != null) {
const ms = Number(r.roundTripTime) * 1000;
if (Number.isFinite(ms) && ms > 0) {
mediaRttMs = ms;
}
}
});
const lines = [];
lines.push('connectionState=' + connState + ' iceConnectionState=' + iceState);
lines.push('ICE RTT(best pair)≈ ' + (iceRttMs != null ? iceRttMs.toFixed(0) + ' ms' : 'n/a') + ' SRTP-remote-inbound roundTrip≈ ' + (mediaRttMs != null ? mediaRttMs.toFixed(0) + ' ms' : 'n/a'));
stats.forEach(function(r) {
if (r.type === 'outbound-rtp' && isAudioStat(r)) {
const codecStat = r.codecId ? byId.get(r.codecId) : null;
const codecHint = codecStat && codecStat.mimeType ? codecStat.mimeType : '(see codec stat id)';
lines.push('→ Shack outbound-audio packetsSent=' + (r.packetsSent != null ? r.packetsSent : '—') + ' codec=' + codecHint);
}
});
stats.forEach(function(r) {
if (r.type === 'inbound-rtp' && isAudioStat(r)) {
const codecStat = r.codecId ? byId.get(r.codecId) : null;
const codecHint = codecStat && codecStat.mimeType ? codecStat.mimeType : '—';
const received = r.packetsReceived != null ? Number(r.packetsReceived) : 0;
const lost = r.packetsLost != null ? Number(r.packetsLost) : 0;
const jitterS = r.jitter != null ? Number(r.jitter) : null;
let jbMs = null;
if (r.jitterBufferDelay != null && r.jitterBufferEmittedCount) {
const emitted = Number(r.jitterBufferEmittedCount);
if (emitted > 0) {
jbMs = (Number(r.jitterBufferDelay) / emitted) * 1000;
}
}
lines.push('← Shack inbound-audio packetsReceived≈ ' + Math.round(received) + ' packetsLost(total)=' + lost);
lines.push(' jitter ' + (jitterS != null ? (jitterS * 1000).toFixed(2) + ' ms' : 'n/a') + '; jitter-buffer smoothing≈ ' + (jbMs != null ? jbMs.toFixed(1) + ' ms/item' : 'n/a'));
lines.push(' decoder codec ' + codecHint);
quickDiag.inboundPackets = Math.round(received);
quickDiag.jitterMs = jitterS != null ? (jitterS * 1000).toFixed(2) + ' ms' : 'n/a';
renderQuickDiagnostics();
const lossFrac = received + lost > 0 ? lost / (received + lost) : 0;
if (logAnomalies && lossFrac > 0.03 && lost > 5) {
log('Stats: inbound packet loss spike totalLost=' + lost, 'err');
}
}
});
box.textContent = lines.join('\n');
const refreshButton = $('remoteOperationStatsRefresh');
if (refreshButton) {
refreshButton.disabled = false;
}
}
async function flushPendingRemoteIceCandidates() {
if (!pc || pendingRemoteIce.length === 0) {
return;
}
const batch = pendingRemoteIce;
pendingRemoteIce = [];
log('Applying ' + batch.length + ' queued remote ICE candidate(s).', 'ok');
for (const init of batch) {
try {
await pc.addIceCandidate(init);
} catch (e) {
log('addIceCandidate (queued): ' + e.message, 'err');
}
}
}
async function startCall() {
if (pc) {
return;
}
pendingRemoteIce = [];
mediaStream = null;
pc = new RTCPeerConnection({ iceServers: parseIceServers(currentSettings) });
const micId = $('remoteOperationAudioIn') ? $('remoteOperationAudioIn').value : currentSettings.audioIn;
const micDev = micId ? { deviceId: { exact: micId } } : true;
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia(buildMicConstraints(micDev, currentSettings));
} catch (error) {
if (micId) {
setDeviceWarning('Saved mic input is unavailable. Falling back to default microphone.');
log('Mic device selection failed, retrying with browser default input.', 'err');
currentSettings.audioIn = '';
writeSettingsToStorage(currentSettings);
stream = await navigator.mediaDevices.getUserMedia(buildMicConstraints(true, currentSettings));
} else {
throw error;
}
}
mediaStream = stream;
attachLevelMeter('mic', stream, 'remoteOperationMicLevel');
applyMicMuteState();
stream.getTracks().forEach(function(track) {
pc.addTrack(track, stream);
});
if (currentSettings.preferOpus) {
preferOpusInOffer(pc);
}
pc.onconnectionstatechange = function() {
syncCardButtons();
if (pc.connectionState === 'connected') {
setOperationalState('paired');
} else if (pc.connectionState === 'failed') {
setOperationalState('failed');
setButtonState('disconnected');
}
};
pc.ontrack = function(ev) {
const rx = $('remoteOperationRx');
if (rx) {
rx.srcObject = ev.streams[0];
}
applySelectedSpeakerOutput().catch(function() {});
startRxLevelPolling();
startAudioWatchdog();
applyPlaybackControlsToAudioElement();
tryEnableAudioPlayback(false).catch(function() {});
applyPlayoutDelayHints(pc, currentSettings);
setOperationalState('media');
setButtonState('connected');
if (currentSettings.statsPoll || ($('remoteOperationStatsPoll') && $('remoteOperationStatsPoll').checked)) {
startStatsPolling();
}
collectAndRenderStats(false).catch(function() {});
};
pc.onicecandidate = function(ev) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}
if (ev.candidate) {
send(ws, {
type: 'candidate',
candidate: ev.candidate.candidate,
sdpMid: ev.candidate.sdpMid,
sdpMLineIndex: ev.candidate.sdpMLineIndex
});
} else {
send(ws, { type: 'candidate-end' });
}
};
const offer = await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: false });
await pc.setLocalDescription(offer);
const radioName = $('remoteOperationRadioName') ? $('remoteOperationRadioName').value.trim() : '';
const payload = { type: 'offer', sdp: offer.sdp };
if (radioName) {
payload.radioName = radioName;
}
send(ws, payload);
log((radioName ? ('Sent offer for radio "' + radioName + '".') : 'Sent offer (first remote-ready radio).'), 'ok');
}
function send(wsObj, obj) {
if (wsObj && wsObj.readyState === WebSocket.OPEN) {
wsObj.send(JSON.stringify(obj));
}
}
async function onSignalPayload(obj) {
if (obj.type === 'error') {
const message = typeof obj.message === 'string' ? obj.message : '';
const hint = message === 'audio_device_mapping_failed'
? ' On Aurora: open Radio Settings, refresh devices, re-pick Radio Output / Radio Input, and save.'
: (message === 'no_radio_configured' ? ' On Aurora: enable internet remote audio on a radio and set both Radio Output & Radio Input.' : '');
const nameHint = (message === 'unknown_radio_name' && obj.radioName !== undefined && obj.radioName !== null)
? ' No row named "' + String(obj.radioName) + '" with internet remote audio + both devices. Match the radio name exactly.'
: '';
log('Shack signaled error: ' + (message || JSON.stringify(obj)) + hint + nameHint, 'err');
return;
}
if (!pc) {
return;
}
if (obj.type === 'answer' && typeof obj.sdp === 'string') {
await pc.setRemoteDescription({ type: 'answer', sdp: obj.sdp });
applyPlayoutDelayHints(pc, currentSettings);
log('Set remote answer.', 'ok');
await flushPendingRemoteIceCandidates();
return;
}
if (obj.type === 'candidate' && typeof obj.candidate === 'string' && obj.candidate.length) {
const init = {
candidate: obj.candidate,
sdpMid: obj.sdpMid === undefined || obj.sdpMid === '' ? null : obj.sdpMid,
sdpMLineIndex: obj.sdpMLineIndex ?? 0
};
if (!pc.remoteDescription) {
pendingRemoteIce.push(init);
return;
}
try {
await pc.addIceCandidate(init);
} catch (e) {
log('addIceCandidate: ' + e.message, 'err');
}
return;
}
if (obj.type === 'candidate-end') {
log('Peer candidate gathering done.', 'ok');
return;
}
log('Unknown signal ' + JSON.stringify(obj), 'err');
}
async function dispatchSignalingMessage(ev) {
let obj;
try {
obj = JSON.parse(ev.data);
} catch (_) {
log('Bad JSON from relay: ' + ev.data, 'err');
return;
}
if (obj.op === 'webrtc-signaling-joined') {
log('Joined signalling as ' + obj.role + ' link name=' + obj.sessionId, 'ok');
return;
}
if (obj.op === 'webrtc-signaling-paired') {
log('Paired with peer role=' + obj.with, 'ok');
setOperationalState('paired');
await startCall();
return;
}
if (obj.op === 'webrtc-signaling-peer-left') {
log('Peer left.', 'err');
teardown(obj.reason || 'peer_left');
scheduleReconnect(obj.reason || 'peer_left');
return;
}
await onSignalPayload(obj);
}
function disconnectWebSocket() {
if (ws !== null) {
ws.close();
}
}
function teardown(reason) {
pendingRemoteIce = [];
stopStatsPolling();
stopRxLevelPolling();
stopAudioWatchdog();
clearReconnectTimer();
stopLevelMeter('mic');
stopLevelMeter('rx');
if ($('remoteOperationStatsRefresh')) {
$('remoteOperationStatsRefresh').disabled = true;
}
if ($('remoteOperationStats')) {
$('remoteOperationStats').textContent = '(disconnected)';
}
if (pc) {
if (pc.getSenders) {
pc.getSenders().forEach(function(sender) {
try {
if (sender.track) {
sender.track.stop();
}
} catch (_) {}
});
}
pc.close();
pc = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach(function(track) {
try {
track.stop();
} catch (_) {}
});
mediaStream = null;
}
if (ws && ws.readyState === WebSocket.OPEN) {
if (manualDisconnectRequested) {
ws.onclose = null;
}
ws.close();
}
ws = null;
log('Teardown ' + reason, 'err');
setOperationalState('disconnected');
setButtonState('disconnected');
if ($('remoteOperationRx')) {
$('remoteOperationRx').srcObject = null;
}
setAudioUnlockHint('');
resetLevelBars();
syncCardButtons();
}
async function connectRemoteOperation(isReconnectAttempt) {
clearReconnectTimer();
if (!isReconnectAttempt) {
reconnectAttempt = 0;
shouldAutoReconnect = true;
}
if (ws !== null) {
ws.close();
}
currentSettings = readSettingsFromStorage();
try {
await loadRemoteOperationSecretFromAccount();
} catch (error) {
shouldAutoReconnect = false;
alert('Could not load link password from your account.');
log('Remote Operation link password load failed: ' + String(error && error.message ? error.message : error), 'err');
return;
}
if (!/^wss?:\/\//i.test(currentSettings.wsUrl)) {
shouldAutoReconnect = false;
alert('WebSocket URL must start with ws:// or wss://');
return;
}
if (!currentSettings.linkPassword || currentSettings.linkPassword.length < 16) {
shouldAutoReconnect = false;
alert('Link password must be at least 16 characters.');
openRemoteOperationSettings();
return;
}
if (!currentSettings.linkName) {
shouldAutoReconnect = false;
alert('Enter a link name.');
openRemoteOperationSettings();
return;
}
setOperationalState('connecting');
setButtonState('connecting');
log('Connecting to ' + currentSettings.wsUrl);
wsInboundChain = Promise.resolve();
ws = new WebSocket(currentSettings.wsUrl);
ws.onopen = function() {
manualDisconnectRequested = false;
reconnectAttempt = 0;
send(ws, {
op: 'webrtc-signaling-join',
mediaToken: currentSettings.linkPassword,
sessionId: currentSettings.linkName,
role: 'remote'
});
log('WS open, sent join.', 'ok');
};
ws.onmessage = function(ev) {
wsInboundChain = wsInboundChain
.then(function() {
return dispatchSignalingMessage(ev);
})
.catch(function(e) {
log(String(e && e.message ? e.message : e), 'err');
});
};
ws.onerror = function() {
log('WS error', 'err');
};
ws.onclose = function() {
log('WS closed', 'err');
const wasManual = manualDisconnectRequested;
manualDisconnectRequested = false;
ws = null;
teardown('ws_close');
if (!wasManual) {
scheduleReconnect('ws_close');
}
};
}
function getModal() {
return $('remoteOperationModal') || $('modal');
}
function toggleRemoteOperationConnection() {
const activeConnection = (pc && pc.connectionState && pc.connectionState !== 'closed') || (ws && ws.readyState !== WebSocket.CLOSED);
if (activeConnection) {
shouldAutoReconnect = false;
manualDisconnectRequested = true;
teardown('user');
return;
}
connectRemoteOperation(false);
}
function populateModalFromSettings() {
const settings = readSettingsFromStorage();
const mapping = {
remoteOperationWsUrl: settings.wsUrl,
remoteOperationLinkName: settings.linkName,
remoteOperationRadioName: settings.radioName,
remoteOperationStun: settings.stun,
remoteOperationTurnUrl: settings.turnUrl,
remoteOperationTurnUser: settings.turnUser,
remoteOperationTurnPwd: settings.turnPwd
};
Object.keys(mapping).forEach(function(id) {
const el = $(id);
if (el) {
el.value = mapping[id];
}
});
const minimalMic = $('remoteOperationMinimalMicProcessing');
const preferOpus = $('remoteOperationPreferOpus');
const playoutHint = $('remoteOperationPlayoutDelayHint');
const statsPoll = $('remoteOperationStatsPoll');
if (minimalMic) {
minimalMic.checked = settings.minimalMic;
}
if (preferOpus) {
preferOpus.checked = settings.preferOpus;
}
if (playoutHint) {
playoutHint.checked = settings.playoutHint;
}
if (statsPoll) {
statsPoll.checked = settings.statsPoll;
}
syncPlaybackControlsToUi();
applyPlaybackControlsToAudioElement();
const modalStatus = $('remoteOperationModalStatus');
if (modalStatus) {
modalStatus.textContent = 'Saved settings';
modalStatus.className = 'badge bg-secondary';
}
loadRemoteOperationSecretFromAccount().catch(function(error) {
log('Could not load account link password: ' + String(error && error.message ? error.message : error), 'err');
});
}
function resetRemoteOperationSettings() {
Object.keys(STORAGE).forEach(function(key) {
localStorage.removeItem(STORAGE[key]);
});
currentSettings = Object.assign({}, DEFAULTS);
writeSettingsToStorage(currentSettings);
syncPlaybackControlsToUi();
applyPlaybackControlsToAudioElement();
populateModalFromSettings();
populateDevices().catch(function(error) {
log(String(error && error.message ? error.message : error), 'err');
});
saveRemoteOperationSecretToAccount('').catch(function(error) {
log('Could not clear account link password: ' + String(error && error.message ? error.message : error), 'err');
});
setDeviceWarning('Remote settings reset to defaults for this browser.');
log('Remote Operation settings reset.', 'ok');
}
async function saveRemoteOperationSettings() {
const saveButton = $('remoteOperationSaveButton');
const modalStatus = $('remoteOperationModalStatus');
const config = {
wsUrl: $('remoteOperationWsUrl') ? $('remoteOperationWsUrl').value.trim() : DEFAULTS.wsUrl,
linkPassword: $('remoteOperationLinkPassword') ? $('remoteOperationLinkPassword').value.trim() : '',
linkName: $('remoteOperationLinkName') ? $('remoteOperationLinkName').value.trim() : '',
radioName: $('remoteOperationRadioName') ? $('remoteOperationRadioName').value.trim() : '',
audioIn: $('remoteOperationAudioIn') ? $('remoteOperationAudioIn').value : '',
audioOut: $('remoteOperationAudioOut') ? $('remoteOperationAudioOut').value : '',
playbackVolume: $('remoteOperationPlaybackVolume') ? Number($('remoteOperationPlaybackVolume').value) : DEFAULTS.playbackVolume,
playbackMuted: !!($('remoteOperationMuteToggle') && $('remoteOperationMuteToggle').checked),
stun: $('remoteOperationStun') ? $('remoteOperationStun').value.trim() : DEFAULTS.stun,
turnUrl: $('remoteOperationTurnUrl') ? $('remoteOperationTurnUrl').value.trim() : '',
turnUser: $('remoteOperationTurnUser') ? $('remoteOperationTurnUser').value.trim() : '',
turnPwd: $('remoteOperationTurnPwd') ? $('remoteOperationTurnPwd').value : '',
minimalMic: !!($('remoteOperationMinimalMicProcessing') && $('remoteOperationMinimalMicProcessing').checked),
preferOpus: !!($('remoteOperationPreferOpus') && $('remoteOperationPreferOpus').checked),
playoutHint: !!($('remoteOperationPlayoutDelayHint') && $('remoteOperationPlayoutDelayHint').checked),
statsPoll: !!($('remoteOperationStatsPoll') && $('remoteOperationStatsPoll').checked)
};
if (!/^wss?:\/\//i.test(config.wsUrl)) {
alert('WebSocket URL must start with ws:// or wss://');
return;
}
if (config.linkPassword && config.linkPassword.length < 16) {
alert('Link password must be at least 16 characters.');
return;
}
if (!config.linkName) {
alert('A link name is required.');
return;
}
if (!config.stun) {
config.stun = DEFAULTS.stun;
}
if (saveButton) {
saveButton.disabled = true;
saveButton.textContent = 'Saving...';
}
try {
await saveRemoteOperationSecretToAccount(config.linkPassword);
} catch (error) {
if (saveButton) {
saveButton.disabled = false;
saveButton.textContent = 'Save settings';
}
if (modalStatus) {
modalStatus.textContent = 'Save failed';
modalStatus.className = 'badge bg-danger';
}
alert('Could not save link password to your account.');
log('Remote Operation link password save failed: ' + String(error && error.message ? error.message : error), 'err');
return;
}
writeSettingsToStorage(config);
applyPlaybackControlsToAudioElement();
applySelectedSpeakerOutput().catch(function() {});
if (modalStatus) {
modalStatus.textContent = 'Saved';
modalStatus.className = 'badge bg-success';
}
if (saveButton) {
saveButton.textContent = 'Saved';
}
log('Remote Operation settings saved.', 'ok');
window.setTimeout(function() {
if (saveButton) {
saveButton.disabled = false;
saveButton.textContent = 'Save settings';
}
dismissRemoteOperationMarkup();
}, 600);
}
function closeRemoteOperationModal() {
dismissRemoteOperationMarkup();
}
function openRemoteOperationSettings() {
const modalEl = getModal();
if (!modalEl) {
const settingsButton = $('remoteOperationSettingsButton');
if (settingsButton) {
settingsButton.click();
}
return;
}
populateModalFromSettings();
if (modalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) {
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
}
function refreshRemoteOperationStats() {
const btn = $('remoteOperationStatsRefresh');
if (btn) {
btn.disabled = true;
}
collectAndRenderStats(true).catch(function() {
if (btn) {
btn.disabled = false;
}
});
}
function bindModalEvents() {
const modalEl = getModal();
if (!modalEl || modalEl.getAttribute('data-remote-operation-bound') === '1') {
return;
}
const saveButton = $('remoteOperationSaveButton');
const resetButton = $('remoteOperationResetSettingsButton');
const statsRefresh = $('remoteOperationStatsRefresh');
const closeButton = $('remoteOperationCloseButton');
const enumerateButton = $('remoteOperationEnumerateDevices');
const audioOut = $('remoteOperationAudioOut');
const audioIn = $('remoteOperationAudioIn');
if (enumerateButton) {
enumerateButton.addEventListener('click', function() {
populateDevices().catch(function(error) {
log(String(error && error.message ? error.message : error), 'err');
});
});
}
if (saveButton) {
saveButton.addEventListener('click', saveRemoteOperationSettings);
}
if (resetButton) {
resetButton.addEventListener('click', resetRemoteOperationSettings);
}
if (statsRefresh) {
statsRefresh.addEventListener('click', refreshRemoteOperationStats);
}
if (closeButton) {
closeButton.addEventListener('click', closeRemoteOperationModal);
}
if (audioOut) {
audioOut.addEventListener('change', function() {
currentSettings.audioOut = audioOut.value;
writeSettingsToStorage(currentSettings);
applySelectedSpeakerOutput().catch(function() {});
});
}
if (audioIn) {
audioIn.addEventListener('change', function() {
currentSettings.audioIn = audioIn.value;
writeSettingsToStorage(currentSettings);
});
}
modalEl.setAttribute('data-remote-operation-bound', '1');
}
function bindEvents() {
const modalEl = getModal();
const volumeInput = $('remoteOperationPlaybackVolume');
const muteToggle = $('remoteOperationMuteToggle');
const micMuteToggle = $('remoteOperationMicMuteToggle');
const unlockButton = $('remoteOperationEnableAudioButton');
['remoteOperationConnectButton', 'remoteOperationModalConnectButton'].forEach(function(id) {
const connectButton = $(id);
if (connectButton) {
connectButton.addEventListener('click', toggleRemoteOperationConnection);
}
});
if (volumeInput) {
volumeInput.addEventListener('input', function() {
currentSettings.playbackVolume = Number(volumeInput.value);
writeSettingsToStorage(currentSettings);
applyPlaybackControlsToAudioElement();
});
}
if (muteToggle) {
muteToggle.addEventListener('change', function() {
currentSettings.playbackMuted = !!muteToggle.checked;
writeSettingsToStorage(currentSettings);
applyPlaybackControlsToAudioElement();
});
}
if (micMuteToggle) {
micMuteToggle.addEventListener('change', function() {
currentSettings.micMuted = !!micMuteToggle.checked;
writeSettingsToStorage(currentSettings);
applyMicMuteState();
});
}
if (unlockButton) {
unlockButton.addEventListener('click', function() {
tryEnableAudioPlayback(true).catch(function() {});
});
}
if (modalEl) {
modalEl.addEventListener('shown.bs.modal', function() {
populateModalFromSettings();
bindModalEvents();
populateDevices().catch(function(error) {
log(String(error && error.message ? error.message : error), 'err');
});
});
}
if (document.body) {
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
const detail = evt && evt.detail ? evt.detail : null;
const target = detail && detail.target ? detail.target : null;
if (target && target.id === 'remote-modals-here') {
populateModalFromSettings();
populateDevices().catch(function(error) {
log(String(error && error.message ? error.message : error), 'err');
});
bindModalEvents();
}
});
}
}
function init() {
readSettingsFromStorage();
bindEvents();
setOperationalState('disconnected');
setButtonState('disconnected');
syncPlaybackControlsToUi();
applyPlaybackControlsToAudioElement();
quickDiag.reconnects = reconnectCount;
renderQuickDiagnostics();
resetLevelBars();
const statsPoll = $('remoteOperationStatsPoll');
if (statsPoll) {
statsPoll.checked = currentSettings.statsPoll;
}
if ($('remoteOperationStats')) {
$('remoteOperationStats').textContent = '(run a call - stats appear after media flows)';
}
loadRemoteOperationSecretFromAccount().catch(function(error) {
log('Could not load account link password: ' + String(error && error.message ? error.message : error), 'err');
});
}
window.openRemoteOperationSettings = openRemoteOperationSettings;
window.closeRemoteOperationModal = closeRemoteOperationModal;
window.saveRemoteOperationSettings = saveRemoteOperationSettings;
window.connectRemoteOperation = connectRemoteOperation;
window.refreshRemoteOperationStats = refreshRemoteOperationStats;
window.populateRemoteOperationDevices = function() {
return populateDevices();
};
document.addEventListener('DOMContentLoaded', init);
})();