'use strict'; const token = location.pathname.split('/').filter(Boolean)[1] || ''; const log = $('#log'); let ws = null, everOpen = false, finished = false; let pc = null, stream = null, actx = null, micAn = null, showAn = null, muted = false; let micSrc = null, selfGain = null; // ---- "What is happening" log: kept in this page only, copied by the guest on request. const diagLines = []; function note(msg){ const line = new Date().toLocaleTimeString([], {hour12:false}) + ' ' + msg; diagLines.push(line); if (diagLines.length > 400) diagLines.shift(); const el = $('#diaglog'); if (!el) return; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30; el.textContent = diagLines.join('\n'); if (atBottom) el.scrollTop = el.scrollHeight; } function diagReport(){ const head = [ 'JohnFeed - what is happening', 'copied ' + new Date().toString(), 'browser: ' + navigator.userAgent, 'name: ' + ($('#name').value.trim() || 'Guest') + ' | headphones ticked: ' + $('#headphones').checked, '', ]; return head.concat(diagLines).join('\n'); } function fatal(msg){ finished = true; $('#fatal').textContent = msg; $('#fatal').classList.remove('hidden'); $('#conn').textContent = 'ended'; $('#conn').className = 'pill bad'; hangup(false); for (const el of document.querySelectorAll('button, textarea, input, select')) el.disabled = true; } function send(o){ if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); } function connect(){ ws = new WebSocket(wsURL('/ws?t=' + encodeURIComponent(token))); ws.onopen = () => { everOpen = true; note('host connection: open'); $('#conn').textContent = 'connected'; $('#conn').className = 'pill ok'; const n = $('#name').value.trim(); if (n) send({t:'name', name:n}); }; ws.onclose = () => { if (finished) return; note('host connection: closed' + (everOpen ? ' - reconnecting in 2 s' : '')); if (!everOpen) { fatal('This invite link is not active. Ask the host for a new one.'); return; } $('#conn').textContent = 'reconnecting…'; $('#conn').className = 'pill bad'; setTimeout(connect, 2000); }; ws.onmessage = async ev => { const m = JSON.parse(ev.data); switch (m.t) { case 'welcome': case 'history': log.textContent = ''; (m.history || []).forEach(x => renderMsg(log, x, 'guest')); if (m.t === 'welcome') setTally(!!m.onAir, false); break; case 'onair': setTally(!!m.on, true); break; case 'chat': renderMsg(log, m.msg, 'guest'); if (document.hidden && m.msg.who === 'host') flashTitle(); break; case 'notice': renderMsg(log, {who:'system', text:m.text, ts:Date.now()}, 'guest'); break; case 'answer': if (pc) await pc.setRemoteDescription({type:'answer', sdp:m.sdp}); break; case 'error': note('host error: ' + m.text); $('#callstate').textContent = m.text; hangup(false); break; case 'callended': note('host ended the audio'); hangup(false); $('#callstate').textContent = 'The host ended the audio. You can join again.'; break; case 'ended': note('session ended: ' + (m.reason || '')); fatal(m.reason || 'The session has ended.'); break; } }; } // ---- tally: a sign set by the host. It does not change any audio. let tallyOn = null; function setTally(on, announce){ if (on === tallyOn) return; tallyOn = on; const el = $('#tally'); el.textContent = on ? '● ON AIR' : 'Off air'; el.className = 'tallybar ' + (on ? 'on' : 'off'); if (announce) note('host set the tally: ' + (on ? 'ON AIR' : 'off air')); } let flashT = null; function flashTitle(){ if (flashT) return; const base = document.title; let on = false; flashT = setInterval(() => { document.title = (on = !on) ? '💬 New message' : base; }, 1000); const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); }; document.addEventListener('visibilitychange', stop); } // ---- devices async function listDevices(){ const devs = await navigator.mediaDevices.enumerateDevices(); const fill = (sel, kind, saved) => { const cur = sel.value || saved || ''; sel.length = 1; devs.filter(d => d.kind === kind && d.deviceId !== 'default' && d.deviceId !== 'communications') .forEach((d, i) => { const o = new Option(d.label || (kind + ' ' + (i+1)), d.deviceId); sel.add(o); }); if ([...sel.options].some(o => o.value === cur)) sel.value = cur; }; fill($('#mic'), 'audioinput', store('gl_mic')); if ('setSinkId' in HTMLMediaElement.prototype) fill($('#out'), 'audiooutput', store('gl_out')); else $('#outwrap').classList.add('hidden'); } navigator.mediaDevices?.addEventListener?.('devicechange', listDevices); $('#mic').onchange = () => store('gl_mic', $('#mic').value); $('#out').onchange = async () => { store('gl_out', $('#out').value); try { await $('#player').setSinkId($('#out').value); } catch(e) {} try { if (actx && actx.setSinkId) await actx.setSinkId($('#out').value); } catch(e) {} note('output changed to "' + ($('#out').selectedOptions[0]?.text || 'default') + '"'); }; $('#mic').addEventListener('change', () => note('microphone choice: "' + ($('#mic').selectedOptions[0]?.text || 'default') + '" (used on next join)')); // ---- call function munge(sdp){ // Ask for stereo, full-band, high-bitrate Opus in both directions. const m = sdp.match(/a=rtpmap:(\d+) opus\/48000/i); if (!m) return sdp; const pt = m[1]; return sdp.replace(new RegExp('a=fmtp:' + pt + ' (.*)'), (line, p) => 'a=fmtp:' + pt + ' ' + p.split(';').filter(x => !/^(stereo|sprop-stereo|maxaveragebitrate|cbr)=/.test(x)).join(';') + ';stereo=1;sprop-stereo=1;maxaveragebitrate=128000'); } async function join(){ if (!window.RTCPeerConnection || !navigator.mediaDevices) { $('#callstate').textContent = 'This browser cannot do live audio. Please use Chrome, Edge, Firefox or Safari.'; return; } $('#join').disabled = true; $('#callstate').textContent = 'Asking for your microphone…'; const hp = $('#headphones').checked; try { stream = await navigator.mediaDevices.getUserMedia({audio: { deviceId: $('#mic').value ? {exact: $('#mic').value} : undefined, echoCancellation: !hp, noiseSuppression: false, autoGainControl: false, channelCount: {ideal: 1}, sampleRate: {ideal: 48000} }}); } catch (e) { note('microphone failed: ' + (e.name || '') + ' ' + (e.message || '')); $('#join').disabled = false; $('#callstate').textContent = 'Microphone not available: ' + (e.message || e.name) + '. Check your browser\'s permission and try again.'; return; } listDevices().catch(()=>{}); { const t = stream.getAudioTracks()[0], st = t.getSettings ? t.getSettings() : {}; note('join: mic "' + (t.label || '?') + '" ' + (st.sampleRate || '?') + ' Hz, ' + (st.channelCount || '?') + ' ch, echo cancellation ' + (st.echoCancellation ? 'ON' : 'off') + ', noise suppression ' + (st.noiseSuppression ? 'ON' : 'off') + ', auto gain ' + (st.autoGainControl ? 'ON' : 'off')); } actx = actx || new AudioContext(); if (actx.state === 'suspended') await actx.resume(); if ($('#out').value && actx.setSinkId) actx.setSinkId($('#out').value).catch(()=>{}); micAn = actx.createAnalyser(); micAn.fftSize = 1024; micSrc = actx.createMediaStreamSource(stream); micSrc.connect(micAn); pc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.l.google.com:19302'}]}); const tr = pc.addTransceiver(stream.getAudioTracks()[0], {direction: 'sendrecv', streams: [stream]}); pc.onicecandidate = e => { if (e.candidate) send({t:'candidate', candidate: e.candidate.toJSON()}); }; pc.ontrack = e => { const s = e.streams[0] || new MediaStream([e.track]); note('receiving the show audio'); const p = $('#player'); p.srcObject = s; if ($('#out').value && p.setSinkId) p.setSinkId($('#out').value).catch(()=>{}); p.play().catch(()=>{}); showAn = actx.createAnalyser(); showAn.fftSize = 1024; actx.createMediaStreamSource(s).connect(showAn); }; pc.oniceconnectionstatechange = () => { if (pc) note('network (ice): ' + pc.iceConnectionState); }; pc.onconnectionstatechange = () => { const s = pc ? pc.connectionState : 'closed'; note('audio connection: ' + s); const label = {connecting:'Connecting audio…', connected:'You are live with the host. 🎙', disconnected:'Audio interrupted - trying to recover…', failed:'Audio connection failed.'}[s]; if (label) $('#callstate').textContent = label; if (s === 'connected') { const p = tr.sender.getParameters(); p.encodings = p.encodings && p.encodings.length ? p.encodings : [{}]; p.encodings[0].maxBitrate = 128000; tr.sender.setParameters(p).catch(()=>{}); } if (s === 'failed') { hangup(false); $('#callstate').textContent = 'Audio connection failed. Press Join to try again - if it keeps failing, tell the host in chat.'; } }; const offer = await pc.createOffer(); offer.sdp = munge(offer.sdp); await pc.setLocalDescription(offer); send({t:'offer', sdp: pc.localDescription.sdp}); $('#callstate').textContent = 'Connecting audio…'; $('#join').classList.add('hidden'); $('#join').disabled = false; $('#mute').classList.remove('hidden'); $('#leave').classList.remove('hidden'); $('#hearself').classList.remove('hidden'); for (const s of ['#mic', '#headphones']) $(s).disabled = true; } function hangup(tell){ if (pc || stream) note(tell ? 'you left the audio' : 'audio stopped'); if (tell) send({t:'hangup'}); hearSelf(false); statsPrev = null; if (pc) { pc.close(); pc = null; } if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; } micAn = showAn = micSrc = null; muted = false; $('#player').srcObject = null; $('#mute').textContent = 'Mute my mic'; $('#mute').classList.remove('danger'); $('#join').classList.remove('hidden'); for (const b of ['#mute', '#leave', '#hearself']) $(b).classList.add('hidden'); for (const s of ['#mic', '#headphones']) $(s).disabled = false; $('#callstate').textContent = 'Not connected to audio.'; } $('#join').onclick = join; $('#leave').onclick = () => hangup(true); $('#mute').onclick = () => { if (!stream) return; muted = !muted; stream.getAudioTracks().forEach(t => t.enabled = !muted); $('#mute').textContent = muted ? 'Unmute my mic' : 'Mute my mic'; $('#mute').classList.toggle('danger', muted); $('#callstate').textContent = muted ? 'Your mic is MUTED - the show cannot hear you.' : 'You are live with the host. 🎙'; note(muted ? 'you muted your mic' : 'you unmuted your mic'); }; // ---- hear yourself: your own mic in your headphones (headphones only - speakers would feed back) function hearSelf(on){ if (selfGain) { selfGain.disconnect(); selfGain = null; } if (on && micSrc && actx) { selfGain = actx.createGain(); selfGain.gain.value = 1; micSrc.connect(selfGain); selfGain.connect(actx.destination); } $('#hearself').textContent = selfGain ? 'Hear yourself: ON' : 'Hear yourself'; $('#hearself').classList.toggle('on', !!selfGain); } $('#hearself').onclick = () => { if (!selfGain && !$('#headphones').checked) { $('#callstate').textContent = 'Hear yourself only works with headphones - through speakers it would howl.'; return; } hearSelf(!selfGain); note(selfGain ? 'hear yourself: on' : 'hear yourself: off'); }; // ---- connection stats into the log every 15 s (both directions, no IP addresses) let statsPrev = null; async function statsTick(){ if (!pc || pc.connectionState !== 'connected') return; let r; try { r = await pc.getStats(); } catch(e) { return; } let inb, out, rin, pair; r.forEach(s => { if (s.kind === 'audio' && s.type === 'inbound-rtp') inb = s; if (s.kind === 'audio' && s.type === 'outbound-rtp') out = s; if (s.kind === 'audio' && s.type === 'remote-inbound-rtp') rin = s; if (s.type === 'transport' && s.selectedCandidatePairId) pair = r.get(s.selectedCandidatePairId); }); if (!pair) r.forEach(s => { if (s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded') pair = s; }); const now = {t: performance.now(), inb, out}; const p = statsPrev; statsPrev = now; if (!p) return; const secs = (now.t - p.t) / 1000, d = (a, b, k) => (a && b && a[k] != null && b[k] != null) ? a[k] - b[k] : 0; const parts = []; if (out) { const kbps = d(out, p.out, 'bytesSent') * 8 / 1000 / secs; let s = 'you->show ' + kbps.toFixed(0) + ' kb/s'; if (rin) s += ', host reports loss ' + ((rin.fractionLost || 0) * 100).toFixed(1) + '%' + (rin.jitter != null ? ', jitter ' + (rin.jitter * 1000).toFixed(0) + ' ms' : ''); parts.push(s); } if (inb) { const got = d(inb, p.inb, 'packetsReceived'), lost = d(inb, p.inb, 'packetsLost'); const conc = d(inb, p.inb, 'concealedSamples'), tot = d(inb, p.inb, 'totalSamplesReceived'); const jbd = d(inb, p.inb, 'jitterBufferDelay'), jbn = d(inb, p.inb, 'jitterBufferEmittedCount'); parts.push('show->you lost ' + lost + '/' + (got + lost) + ' pkts, jitter ' + ((inb.jitter || 0) * 1000).toFixed(0) + ' ms' + (jbn ? ', buffer ' + (jbd / jbn * 1000).toFixed(0) + ' ms' : '') + (tot ? ', concealed ' + (conc / tot * 100).toFixed(1) + '%' : '')); } if (pair) { const lc = r.get(pair.localCandidateId), rc = r.get(pair.remoteCandidateId); parts.push('round trip ' + (pair.currentRoundTripTime != null ? (pair.currentRoundTripTime * 1000).toFixed(0) + ' ms' : '?') + ' (' + (lc ? lc.candidateType + '/' + (lc.protocol || '') : '?') + ' -> ' + (rc ? rc.candidateType : '?') + ')'); } if (parts.length) note('stats: ' + parts.join(' | ')); } setInterval(statsTick, 15000); $('#copylog').onclick = async () => { const text = diagReport(); let ok = false; try { await navigator.clipboard.writeText(text); ok = true; } catch(e) {} if (!ok) { // fallback: select the text so the guest can copy it by hand const r = document.createRange(); r.selectNodeContents($('#diaglog')); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); } $('#copied').textContent = ok ? 'Copied - paste it into the chat or an email.' : 'Selected - press Ctrl+C (Cmd+C on a Mac) to copy.'; setTimeout(() => { $('#copied').textContent = ''; }, 6000); }; // ---- meters const buf = new Float32Array(1024); function peak(an){ if (!an) return 0; an.getFloatTimeDomainData(buf); let p = 0; for (const v of buf) { const a = Math.abs(v); if (a > p) p = a; } return p; } (function loop(){ setMeter($('#mMic'), muted ? 0 : peak(micAn)); setMeter($('#mShow'), peak(showAn)); requestAnimationFrame(loop); })(); // ---- name + chat $('#name').value = store('gl_name') || ''; $('#name').addEventListener('change', () => { const n = $('#name').value.trim(); store('gl_name', n); send({t:'name', name:n}); }); chatForm($('#chatform'), $('#msg'), t => send({t:'chat', text:t})); window.addEventListener('beforeunload', () => { if (pc) send({t:'hangup'}); }); note('page opened'); if (!token) fatal('This link is incomplete. Ask the host for your invite link.'); else { connect(); if (navigator.mediaDevices) listDevices().catch(()=>{}); }