web: auto-reconnect on cross-LAN drops + live mic-input switching
- chat + zebra-audio: treat ICE 'disconnected' as recoverable (grace before acting), auto ICE-restart on 'failed'/timeout driven by the offerer to avoid glare, and auto-rejoin the signaling socket if it drops mid-call. Superseded-pc guard ignores late events from a closed connection. - zebra-audio: input-device dropdown that hot-swaps the mic via replaceTrack (no renegotiation, mute preserved); refreshes on devicechange so a plugged-in interface appears mid-call.
This commit is contained in:
parent
21e3e84b14
commit
162b4b2917
3 changed files with 210 additions and 61 deletions
155
web/chat.html
155
web/chat.html
|
|
@ -1168,6 +1168,7 @@ function ensurePC() {
|
|||
logLine('sys', 'codec preference: G.711/G.722 ahead of Opus (clean amplitude)');
|
||||
}
|
||||
} catch (_) {}
|
||||
const selfPc = pc; /* snapshot so a superseded pc's late events are ignored */
|
||||
pc.ontrack = (ev) => {
|
||||
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
||||
attachInboundTrack(stream, ev.track.id);
|
||||
|
|
@ -1181,20 +1182,34 @@ function ensurePC() {
|
|||
logLine('sys', `ice candidate: ${typ}${mdns}`);
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (selfPc !== pc) return; /* event from a superseded pc — ignore */
|
||||
const s = pc.connectionState;
|
||||
rtcStatus.textContent = 'connection: ' + s;
|
||||
if (s === 'connected') {
|
||||
dotRtc.className = 'dot ok';
|
||||
clearRtcRecover(); rtcReconnectTries = 0;
|
||||
logLine('sys', 'webrtc connected');
|
||||
announceHello().catch(() => {});
|
||||
setTimeout(reportTransport, 1200);
|
||||
} else if (s === 'failed' || s === 'closed' || s === 'disconnected') {
|
||||
} else if (s === 'disconnected') {
|
||||
dotRtc.className = 'dot warn';
|
||||
setPath('path: reconnecting…', null);
|
||||
logLine('sys', 'webrtc disconnected — waiting for ICE to recover');
|
||||
clearRtcRecover();
|
||||
rtcRecoverTimer = setTimeout(attemptRtcRestart, 5000); /* often self-heals */
|
||||
} else if (s === 'failed') {
|
||||
dotRtc.className = 'dot warn';
|
||||
setPath('path: reconnecting…', null);
|
||||
logLine('sys', 'webrtc failed — restarting ICE');
|
||||
attemptRtcRestart();
|
||||
} else if (s === 'closed') {
|
||||
dotRtc.className = 'dot';
|
||||
clearRtcRecover();
|
||||
setPath('path: not connected', null);
|
||||
logLine('err', `webrtc ${s} — click "reset connection" then retry`);
|
||||
}
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (selfPc !== pc) return;
|
||||
rtcStatus.textContent = 'ice: ' + pc.iceConnectionState;
|
||||
};
|
||||
return pc;
|
||||
|
|
@ -1233,6 +1248,7 @@ async function reportTransport() {
|
|||
|
||||
function resetConnection() {
|
||||
if (typeof disconnectRelay === 'function') disconnectRelay();
|
||||
clearRtcRecover(); rtcReconnectTries = 0;
|
||||
if (pc) {
|
||||
try { pc.close(); } catch (_) {}
|
||||
pc = null;
|
||||
|
|
@ -1288,6 +1304,10 @@ const SIGNAL_SALT = new TextEncoder().encode('zebra-signal-v1');
|
|||
let relayWS = null;
|
||||
let relayRole = null;
|
||||
let relayKey = null;
|
||||
let relayWantOpen = false; /* true from connect until the user disconnects */
|
||||
let relayReconnectTimer = null; /* auto-rejoin the signaling socket if it drops */
|
||||
let rtcRecoverTimer = null; /* grace timer before forcing an ICE restart */
|
||||
let rtcReconnectTries = 0;
|
||||
|
||||
async function deriveSignalRoom(code) {
|
||||
const h = await crypto.subtle.digest('SHA-256',
|
||||
|
|
@ -1313,13 +1333,35 @@ async function relaySendSDP(kind, desc) {
|
|||
const ct = await aesEncrypt(relayKey, JSON.stringify(desc));
|
||||
relaySend({ type: 'sdp', kind, data: b64(ct) });
|
||||
}
|
||||
async function relayMakeOffer() {
|
||||
async function relayMakeOffer(iceRestart = false) {
|
||||
ensurePC();
|
||||
const off = await pc.createOffer();
|
||||
const off = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
|
||||
await pc.setLocalDescription(off);
|
||||
await waitForIceGathering(pc);
|
||||
await relaySendSDP('offer', pc.localDescription);
|
||||
relaySet('offer sent — waiting for answer', 'ok');
|
||||
relaySet(iceRestart ? 'reconnecting — new offer sent' : 'offer sent — waiting for answer', 'ok');
|
||||
}
|
||||
/* Relay-path ICE recovery. A transient blip drops us to 'disconnected' (usually
|
||||
* self-heals) or 'failed' (needs an ICE restart). The offerer owns restarts so
|
||||
* the two sides don't both re-offer at once (glare); the answerer waits for the
|
||||
* new offer to arrive over the relay and answers it on its existing pc. */
|
||||
function clearRtcRecover() { if (rtcRecoverTimer) { clearTimeout(rtcRecoverTimer); rtcRecoverTimer = null; } }
|
||||
async function attemptRtcRestart() {
|
||||
clearRtcRecover();
|
||||
if (!pc || pc.connectionState === 'connected') { rtcReconnectTries = 0; return; }
|
||||
if (!relayWS || relayWS.readyState !== WebSocket.OPEN) return; /* WS rejoin re-offers */
|
||||
if (relayRole !== 'offerer') { relaySet('reconnecting — waiting for peer'); return; }
|
||||
if (rtcReconnectTries >= 6) { relaySet('reconnect failed — click "reset" then retry', 'err'); return; }
|
||||
rtcReconnectTries++;
|
||||
try {
|
||||
await refreshTurnCred();
|
||||
if (pc.setConfiguration) { try { pc.setConfiguration(rtcConfig); } catch (_) {} }
|
||||
await relayMakeOffer(true);
|
||||
logLine('sys', `ice restart attempt ${rtcReconnectTries}`);
|
||||
} catch (e) { logLine('err', 'ice restart failed: ' + e.message); }
|
||||
if (!pc || pc.connectionState !== 'connected') {
|
||||
rtcRecoverTimer = setTimeout(attemptRtcRestart, Math.min(2000 * rtcReconnectTries, 10000));
|
||||
}
|
||||
}
|
||||
async function onRelaySDP(kind, descJSON) {
|
||||
const desc = JSON.parse(descJSON);
|
||||
|
|
@ -1354,54 +1396,69 @@ async function connectRelay() {
|
|||
try {
|
||||
relayKey = await deriveSignalKey(code);
|
||||
const room = await deriveSignalRoom(code);
|
||||
relayWantOpen = true;
|
||||
relaySet('connecting to relay…');
|
||||
relayWS = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(room));
|
||||
relayWS.onopen = () => {
|
||||
$('dot-relay').className = 'dot ok';
|
||||
$('btn-relay-connect').disabled = true;
|
||||
$('btn-relay-disconnect').disabled = false;
|
||||
logLine('sys', 'relay connected — waiting for peer');
|
||||
};
|
||||
relayWS.onclose = () => {
|
||||
$('dot-relay').className = 'dot warn';
|
||||
$('btn-relay-connect').disabled = false;
|
||||
$('btn-relay-disconnect').disabled = true;
|
||||
if (relayWS) relaySet('relay disconnected');
|
||||
relayWS = null; relayRole = null;
|
||||
};
|
||||
relayWS.onerror = () => relaySet('relay connection error', 'err');
|
||||
relayWS.onmessage = async (ev) => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
|
||||
if (m.type === 'welcome') {
|
||||
relayRole = m.role;
|
||||
relaySet(`in room as ${m.role} — ${m.peers} peer(s) present`);
|
||||
logLine('sys', `relay role: ${m.role} (${m.peers} present)`);
|
||||
if (relayRole === 'offerer' && m.peers >= 2) await relayMakeOffer();
|
||||
} else if (m.type === 'peer-joined') {
|
||||
logLine('sys', 'peer joined the room');
|
||||
if (relayRole === 'offerer') await relayMakeOffer();
|
||||
} else if (m.type === 'peer-left') {
|
||||
logLine('sys', 'peer left the room');
|
||||
relaySet('peer left — waiting for a peer');
|
||||
} else if (m.type === 'sdp') {
|
||||
try {
|
||||
const pt = await aesDecrypt(relayKey, unb64(m.data));
|
||||
await onRelaySDP(m.kind, pt);
|
||||
} catch (e) {
|
||||
logLine('err', 'relay sdp failed (wrong code?): ' + e.message);
|
||||
relaySet('sdp exchange failed — codes must match', 'err');
|
||||
}
|
||||
} else if (m.type === 'error') {
|
||||
relaySet('relay: ' + m.message, 'err');
|
||||
logLine('err', 'relay: ' + m.message);
|
||||
}
|
||||
};
|
||||
openRelaySocket(room);
|
||||
} catch (e) {
|
||||
relaySet('connect failed: ' + e.message, 'err');
|
||||
if (relayWS) { try { relayWS.close(); } catch (_) {} relayWS = null; }
|
||||
}
|
||||
}
|
||||
function openRelaySocket(room) {
|
||||
relayWS = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(room));
|
||||
relayWS.onopen = () => {
|
||||
$('dot-relay').className = 'dot ok';
|
||||
$('btn-relay-connect').disabled = true;
|
||||
$('btn-relay-disconnect').disabled = false;
|
||||
logLine('sys', 'relay connected — waiting for peer');
|
||||
};
|
||||
relayWS.onclose = () => {
|
||||
$('dot-relay').className = 'dot warn';
|
||||
relayWS = null; relayRole = null;
|
||||
if (relayWantOpen) {
|
||||
$('btn-relay-disconnect').disabled = false;
|
||||
relaySet('relay dropped — reconnecting…');
|
||||
if (relayReconnectTimer) clearTimeout(relayReconnectTimer);
|
||||
relayReconnectTimer = setTimeout(() => { if (relayWantOpen) openRelaySocket(room); }, 1500);
|
||||
} else {
|
||||
$('btn-relay-connect').disabled = false;
|
||||
$('btn-relay-disconnect').disabled = true;
|
||||
relaySet('relay disconnected');
|
||||
}
|
||||
};
|
||||
relayWS.onerror = () => relaySet('relay connection error', 'err');
|
||||
relayWS.onmessage = async (ev) => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
|
||||
if (m.type === 'welcome') {
|
||||
relayRole = m.role;
|
||||
relaySet(`in room as ${m.role} — ${m.peers} peer(s) present`);
|
||||
logLine('sys', `relay role: ${m.role} (${m.peers} present)`);
|
||||
/* pc already exists ⇒ this is a re-join after a drop: ICE-restart offer */
|
||||
if (relayRole === 'offerer' && m.peers >= 2) await relayMakeOffer(!!pc);
|
||||
} else if (m.type === 'peer-joined') {
|
||||
logLine('sys', 'peer joined the room');
|
||||
if (relayRole === 'offerer') await relayMakeOffer(!!pc);
|
||||
} else if (m.type === 'peer-left') {
|
||||
logLine('sys', 'peer left the room');
|
||||
relaySet('peer left — waiting for a peer');
|
||||
} else if (m.type === 'sdp') {
|
||||
try {
|
||||
const pt = await aesDecrypt(relayKey, unb64(m.data));
|
||||
await onRelaySDP(m.kind, pt);
|
||||
} catch (e) {
|
||||
logLine('err', 'relay sdp failed (wrong code?): ' + e.message);
|
||||
relaySet('sdp exchange failed — codes must match', 'err');
|
||||
}
|
||||
} else if (m.type === 'error') {
|
||||
relaySet('relay: ' + m.message, 'err');
|
||||
logLine('err', 'relay: ' + m.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
function disconnectRelay() {
|
||||
relayWantOpen = false;
|
||||
if (relayReconnectTimer) { clearTimeout(relayReconnectTimer); relayReconnectTimer = null; }
|
||||
clearRtcRecover();
|
||||
if (relayWS) { const w = relayWS; relayWS = null; try { w.close(); } catch (_) {} }
|
||||
$('dot-relay').className = 'dot warn';
|
||||
$('btn-relay-connect').disabled = false;
|
||||
|
|
@ -1591,9 +1648,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
|
|||
})();
|
||||
</script>
|
||||
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-28</span><br>
|
||||
md5 <span class="stamp-md5">2eefd0f7f0c5ca9b42078a885e2d822d</span><br>
|
||||
sha256 <span class="stamp-sha">20c008ca4c8341e9017f46488299120714599f4765e745820ab7e08d9b3b3306</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">3ded822c9d19eedb538d02156b0eb00b</span><br>
|
||||
sha256 <span class="stamp-sha">ec5d4dc1662fbcd570d70b9e0d0dd40e13aa804e5c5f989bec57dc9643bffe5f</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -266,9 +266,9 @@
|
|||
</p>
|
||||
|
||||
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-28</span><br>
|
||||
md5 <span class="stamp-md5">e4edc61635d2ae846d7bb7438742e8a7</span><br>
|
||||
sha256 <span class="stamp-sha">16e749e33551edad39a1146e3809efc7486b8461eb51d1b1238b70f13301a5b6</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">a7363c33e5b129b38c5d385690d7622a</span><br>
|
||||
sha256 <span class="stamp-sha">4b984972c9051c9fd1f4c416e957d9926e6070423ecca67c5404231435268c51</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
|
||||
padding: 0.45rem; background: #fff; color: #000; flex: 1; min-width: 0;
|
||||
}
|
||||
select {
|
||||
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
|
||||
padding: 0.45rem; background: #fff; color: #000; flex: 1; min-width: 0; cursor: pointer;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #000; background: #fff; flex-shrink: 0; }
|
||||
.dot.ok { background: #060; border-color: #060; }
|
||||
.dot.warn { background: #888; }
|
||||
|
|
@ -88,6 +92,10 @@
|
|||
<div class="dot" id="dot-path"></div>
|
||||
<span id="path-status" class="status-line">path: —</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="note" for="mic-select" style="flex:0 0 auto;min-width:3rem">input</label>
|
||||
<select id="mic-select" title="audio input device — switchable mid-call"><option value="">default microphone</option></select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="note"><input type="checkbox" id="music-mode"> music mode — raw mic, no echo/noise cancellation (for playing audio through it)</label>
|
||||
</div>
|
||||
|
|
@ -176,13 +184,35 @@ async function refreshTurnCred(){
|
|||
}
|
||||
|
||||
/* ---- audio ---- */
|
||||
let micStream = null, audioCtx = null, musicMode = false;
|
||||
let micStream = null, audioCtx = null, musicMode = false, micDeviceId = '';
|
||||
/* voice mode = echo-cancel + noise-suppress + auto-gain (clean speech).
|
||||
* music mode = all of that OFF so music/audio passes through un-mangled. */
|
||||
function micConstraints(){
|
||||
return musicMode
|
||||
const base = musicMode
|
||||
? { echoCancellation:false, noiseSuppression:false, autoGainControl:false }
|
||||
: { echoCancellation:true, noiseSuppression:true, autoGainControl:true };
|
||||
if (micDeviceId) base.deviceId = { exact: micDeviceId }; /* pick a specific input */
|
||||
return base;
|
||||
}
|
||||
/* list audio inputs into the dropdown. Labels only appear once the user has
|
||||
* granted mic permission, so this is re-run after getMic and on devicechange
|
||||
* (e.g. plugging in an audio interface to play music through). */
|
||||
async function refreshMicList(){
|
||||
try {
|
||||
const devs = await navigator.mediaDevices.enumerateDevices();
|
||||
const mics = devs.filter(d=>d.kind==='audioinput');
|
||||
const sel = $('mic-select'); if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
if (!mics.length){ sel.innerHTML = '<option value="">default microphone</option>'; return; }
|
||||
mics.forEach((m,i)=>{
|
||||
const o = document.createElement('option');
|
||||
o.value = m.deviceId;
|
||||
o.textContent = m.label || ('microphone '+(i+1));
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if (micDeviceId && mics.some(m=>m.deviceId===micDeviceId)) sel.value = micDeviceId;
|
||||
else micDeviceId = sel.value; /* track whatever the browser defaulted to */
|
||||
} catch(e){ logLine('err','could not list inputs: '+e.message); }
|
||||
}
|
||||
function tagTrack(t){ if (t) t.contentHint = musicMode ? 'music' : 'speech'; }
|
||||
async function setSenderBitrate(sender){
|
||||
|
|
@ -232,6 +262,8 @@ function meterFor(stream, fillId){
|
|||
|
||||
/* ---- WebRTC ---- */
|
||||
let pc = null, relayWS = null, relayRole = null, relayKey = null;
|
||||
let wantCall = false, rdvRoom = null, sigReconnectTimer = null;
|
||||
let iAmOfferer = false, rtcTimer = null, rtcTries = 0;
|
||||
|
||||
function waitForIceGathering(p, timeoutMs=6000){
|
||||
return new Promise(res=>{
|
||||
|
|
@ -256,12 +288,21 @@ async function ensurePC(){
|
|||
logLine('', 'remote audio connected');
|
||||
};
|
||||
pc.onicecandidate = (ev) => { if (ev.candidate){ const t=(ev.candidate.candidate.match(/typ\s+(\S+)/)||[])[1]; logLine('','ice: '+(t||'?')); } };
|
||||
const self = pc;
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (self !== pc) return; /* event from a superseded connection */
|
||||
const s = pc.connectionState;
|
||||
setStatus('call: '+s, s==='connected'?'ok':null);
|
||||
$('dot-call').className = 'dot ' + (s==='connected'?'ok':'warn');
|
||||
if (s==='connected'){ $('btn-mute').disabled=false; $('btn-hangup').disabled=false; setTimeout(reportPath,1200); partnerKnown=true; setMicIcons(); sendMicState(); }
|
||||
else if (s==='failed'||s==='disconnected'||s==='closed'){ setPath('path: —', null); }
|
||||
if (s==='connected'){
|
||||
if (rtcTimer){ clearTimeout(rtcTimer); rtcTimer=null; } rtcTries=0;
|
||||
$('btn-mute').disabled=false; $('btn-hangup').disabled=false; setTimeout(reportPath,1200); partnerKnown=true; setMicIcons(); sendMicState();
|
||||
} else if (s==='disconnected'){
|
||||
setPath('path: reconnecting…', null);
|
||||
if (rtcTimer) clearTimeout(rtcTimer); rtcTimer=setTimeout(attemptReconnect, 5000); /* often self-heals */
|
||||
} else if (s==='failed'){
|
||||
setPath('path: reconnecting…', null); attemptReconnect();
|
||||
} else if (s==='closed'){ setPath('path: —', null); }
|
||||
};
|
||||
return pc;
|
||||
}
|
||||
|
|
@ -288,6 +329,7 @@ function relaySend(obj){ if (relayWS && relayWS.readyState===1) relayWS.send(JSO
|
|||
async function relaySendSDP(kind, desc){ relaySend({ type:'sdp', kind, data: b64(await aesEncrypt(relayKey, JSON.stringify(desc))) }); }
|
||||
function freshPC(){ if (pc){ try{pc.close();}catch(_){} pc=null; } }
|
||||
async function makeOffer(){
|
||||
iAmOfferer = true; /* we drive any later ICE recovery */
|
||||
freshPC(); /* always (re)negotiate on a clean connection */
|
||||
await ensurePC();
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
|
|
@ -295,9 +337,27 @@ async function makeOffer(){
|
|||
await relaySendSDP('offer', pc.localDescription);
|
||||
setStatus('offer sent — connecting', null);
|
||||
}
|
||||
/* Relay-path recovery: when the media dies, the offerer re-runs the handshake
|
||||
* (a fresh PC); the answerer just waits for the new offer. 'disconnected'
|
||||
* usually self-heals, so the caller gives it a few seconds before redialing. */
|
||||
async function attemptReconnect(){
|
||||
if (rtcTimer){ clearTimeout(rtcTimer); rtcTimer=null; }
|
||||
if (!relayWS || relayWS.readyState!==1) return; /* WS rejoin will redial */
|
||||
if (pc && pc.connectionState==='connected'){ rtcTries=0; return; }
|
||||
if (!iAmOfferer){ setStatus('reconnecting — waiting for partner', null); return; }
|
||||
if (rtcTries>=6){ setStatus('reconnect failed — hang up & call again','err'); return; }
|
||||
rtcTries++;
|
||||
logLine('','reconnecting (attempt '+rtcTries+')');
|
||||
try { await refreshTurnCred(); await makeOffer(); }
|
||||
catch(e){ logLine('err','reconnect failed: '+e.message); }
|
||||
if (!(pc && pc.connectionState==='connected')){
|
||||
rtcTimer=setTimeout(attemptReconnect, Math.min(2000*rtcTries,10000));
|
||||
}
|
||||
}
|
||||
async function onSDP(kind, json){
|
||||
const desc = JSON.parse(json);
|
||||
if (kind==='offer'){
|
||||
iAmOfferer = false; /* the offerer drives recovery, not us */
|
||||
freshPC(); /* every incoming offer = a fresh connection */
|
||||
await ensurePC();
|
||||
await pc.setRemoteDescription(desc);
|
||||
|
|
@ -314,16 +374,27 @@ async function startCall(){
|
|||
const code = $('rdv-code').value.trim();
|
||||
if (!code){ setStatus('enter a rendezvous code first','err'); return; }
|
||||
if (relayWS){ setStatus('already in a call — hang up first', null); return; }
|
||||
try { await getMic(); meterFor(micStream,'meter-mic'); setMicIcons(); }
|
||||
try { await getMic(); meterFor(micStream,'meter-mic'); setMicIcons(); await refreshMicList(); }
|
||||
catch(e){ setStatus('microphone blocked: '+e.message,'err'); return; }
|
||||
await refreshTurnCred();
|
||||
relayKey = await deriveSignalKey(code);
|
||||
const room = await deriveSignalRoom(code);
|
||||
setStatus('connecting to rendezvous…', null);
|
||||
$('btn-call').disabled = true;
|
||||
wantCall = true; rdvRoom = room;
|
||||
openSignal(room);
|
||||
}
|
||||
function openSignal(room){
|
||||
relayWS = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(room));
|
||||
relayWS.onopen = () => logLine('', 'rendezvous connected — waiting for partner');
|
||||
relayWS.onclose = () => { if (relayWS) setStatus('rendezvous closed', null); relayWS=null; relayRole=null; $('btn-call').disabled=false; };
|
||||
relayWS.onclose = () => {
|
||||
relayWS=null; relayRole=null;
|
||||
if (wantCall){
|
||||
setStatus('rendezvous dropped — reconnecting…', null);
|
||||
if (sigReconnectTimer) clearTimeout(sigReconnectTimer);
|
||||
sigReconnectTimer = setTimeout(()=>{ if (wantCall) openSignal(rdvRoom); }, 1500);
|
||||
} else { setStatus('rendezvous closed', null); $('btn-call').disabled=false; }
|
||||
};
|
||||
relayWS.onerror = () => setStatus('rendezvous error','err');
|
||||
relayWS.onmessage = async (ev) => {
|
||||
let m; try { m=JSON.parse(ev.data); } catch(_){ return; }
|
||||
|
|
@ -350,6 +421,9 @@ async function startCall(){
|
|||
};
|
||||
}
|
||||
function hangup(){
|
||||
wantCall=false;
|
||||
if (sigReconnectTimer){ clearTimeout(sigReconnectTimer); sigReconnectTimer=null; }
|
||||
if (rtcTimer){ clearTimeout(rtcTimer); rtcTimer=null; } rtcTries=0;
|
||||
if (relayWS){ const w=relayWS; relayWS=null; try{w.close();}catch(_){} }
|
||||
if (pc){ try{pc.close();}catch(_){} pc=null; }
|
||||
$('remote-audio').srcObject = null;
|
||||
|
|
@ -392,13 +466,31 @@ $('music-mode').addEventListener('change', async (e)=>{
|
|||
logLine('', 'mic mode: '+(musicMode?'MUSIC — raw, no voice isolation':'VOICE — echo/noise cancel'));
|
||||
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','mic mode switch failed: '+err.message); } }
|
||||
});
|
||||
/* swap the audio input device live: applyMicMode re-acquires with the new
|
||||
* deviceId and hot-swaps the sender track — no renegotiation, mute preserved */
|
||||
$('mic-select').addEventListener('change', async (e)=>{
|
||||
micDeviceId = e.target.value;
|
||||
logLine('', 'input device: '+((e.target.selectedOptions[0]||{}).textContent||'default'));
|
||||
if (micStream){
|
||||
try { await applyMicMode(); }
|
||||
catch(err){
|
||||
logLine('err','input switch failed: '+err.message);
|
||||
await refreshMicList(); /* device may have vanished — resync the list */
|
||||
}
|
||||
}
|
||||
});
|
||||
/* a plugged/unplugged interface changes the available inputs mid-call */
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
|
||||
navigator.mediaDevices.addEventListener('devicechange', refreshMicList);
|
||||
}
|
||||
refreshMicList(); /* populate up front (labels fill in after mic permission) */
|
||||
logLine('', 'ready — type a rendezvous code and call. mic stays muted to the room until connected.');
|
||||
})();
|
||||
</script>
|
||||
<footer style="max-width:560px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-28</span><br>
|
||||
md5 <span class="stamp-md5">e5fddae2c65939dee70fa8cb05f246d0</span><br>
|
||||
sha256 <span class="stamp-sha">ca3d20d2d9d8f33ce23faee41a148e459ef404e6a4be73001cd991781ce146bc</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-05-29</span><br>
|
||||
md5 <span class="stamp-md5">526a8823d1d01a64db80c1ae00c5cbad</span><br>
|
||||
sha256 <span class="stamp-sha">edaddafa103af1832ea0e8f578af34e3891df77a86ed806674dc8fc285b632dd</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span>
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue