guestlink v1: self-hosted guest line (Cleanfeed replacement)
Go + Pion + libopus. One guest, live audio both ways, anonymous chat. README for this version was not preserved; see the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b0e3b805b1
13 files changed
+1839
No files matched your search
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
const $ = s => document.querySelector(s);
|
||||
function wsURL(path){ return (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + path; }
|
||||
function fmtTime(ts){ const d = new Date(ts); return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
|
||||
// Render a chat message. `mine` = which side this page is ('host' | 'guest'). Text only - never innerHTML.
|
||||
function renderMsg(log, m, mine){
|
||||
const el = document.createElement('div');
|
||||
el.className = 'msg ' + (m.who === 'system' ? 'system' : (m.who === mine ? 'me' : 'them'));
|
||||
if (m.who !== 'system') {
|
||||
const w = document.createElement('span'); w.className = 'who';
|
||||
w.textContent = (m.who === mine ? 'You' : m.from) + ' · ' + fmtTime(m.ts);
|
||||
el.appendChild(w);
|
||||
}
|
||||
el.appendChild(document.createTextNode(m.text));
|
||||
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40;
|
||||
log.appendChild(el);
|
||||
if (atBottom || m.who === mine) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
function setMeter(el, peak){ // peak 0..1 linear -> bar on a -60..0 dBFS scale
|
||||
const db = peak > 0 ? 20*Math.log10(peak) : -100;
|
||||
const pct = Math.max(0, Math.min(100, (db + 60) / 60 * 100));
|
||||
el.style.width = pct + '%';
|
||||
el.className = db > -1 ? 'clip' : (db > -9 ? 'hot' : '');
|
||||
return db;
|
||||
}
|
||||
function chatForm(form, ta, send){
|
||||
form.addEventListener('submit', e => { e.preventDefault(); const t = ta.value.trim(); if (t) { send(t); ta.value=''; } ta.focus(); });
|
||||
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); } });
|
||||
}
|
||||
function store(k, v){ try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch(e) { return null; } }
|
||||
@@ -0,0 +1,168 @@
|
||||
'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;
|
||||
|
||||
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;
|
||||
$('#conn').textContent = 'connected'; $('#conn').className = 'pill ok';
|
||||
const n = $('#name').value.trim(); if (n) send({t:'name', name:n});
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (finished) return;
|
||||
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')); 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': $('#callstate').textContent = m.text; hangup(false); break;
|
||||
case 'callended': hangup(false); $('#callstate').textContent = 'The host ended the audio. You can join again.'; break;
|
||||
case 'ended': fatal(m.reason || 'The session has ended.'); break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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) {}
|
||||
};
|
||||
|
||||
// ---- 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) {
|
||||
$('#join').disabled = false;
|
||||
$('#callstate').textContent = 'Microphone not available: ' + (e.message || e.name) + '. Check your browser\'s permission and try again.';
|
||||
return;
|
||||
}
|
||||
listDevices().catch(()=>{});
|
||||
actx = actx || new AudioContext();
|
||||
if (actx.state === 'suspended') await actx.resume();
|
||||
micAn = actx.createAnalyser(); micAn.fftSize = 1024;
|
||||
actx.createMediaStreamSource(stream).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]);
|
||||
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.onconnectionstatechange = () => {
|
||||
const s = pc ? pc.connectionState : 'closed';
|
||||
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');
|
||||
for (const s of ['#mic', '#headphones']) $(s).disabled = true;
|
||||
}
|
||||
|
||||
function hangup(tell){
|
||||
if (tell) send({t:'hangup'});
|
||||
if (pc) { pc.close(); pc = null; }
|
||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||
micAn = showAn = null; muted = false;
|
||||
$('#player').srcObject = null;
|
||||
$('#mute').textContent = 'Mute mic';
|
||||
$('#join').classList.remove('hidden'); $('#mute').classList.add('hidden'); $('#leave').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 mic' : 'Mute mic';
|
||||
$('#mute').classList.toggle('danger', muted);
|
||||
};
|
||||
|
||||
// ---- 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'}); });
|
||||
|
||||
if (!token) fatal('This link is incomplete. Ask the host for your invite link.');
|
||||
else { connect(); if (navigator.mediaDevices) listDevices().catch(()=>{}); }
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
const log = $('#log');
|
||||
let ws = null, lastGuestOnline = null;
|
||||
function send(o){ if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); }
|
||||
|
||||
function connect(){
|
||||
ws = new WebSocket(wsURL('/ws'));
|
||||
ws.onopen = () => { $('#conn').textContent = 'server connected'; $('#conn').className = 'pill ok'; };
|
||||
ws.onclose = () => { $('#conn').textContent = 'server offline - retrying'; $('#conn').className = 'pill bad'; setTimeout(connect, 2000); };
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.t === 'history') { log.textContent = ''; m.history.forEach(x => renderMsg(log, x, 'host')); }
|
||||
else if (m.t === 'chat') { renderMsg(log, m.msg, 'host'); if (m.msg.who === 'guest' && document.hidden) flashTitle(); }
|
||||
else if (m.t === 'state') render(m);
|
||||
};
|
||||
}
|
||||
|
||||
let flashT = null;
|
||||
function flashTitle(){ // visual only - the host page must never make sound on the rig
|
||||
if (flashT) return; const base = document.title; let on = false;
|
||||
flashT = setInterval(() => { document.title = (on = !on) ? '💬 Guest message' : base; }, 1000);
|
||||
const stop = () => { clearInterval(flashT); flashT = null; document.title = base; document.removeEventListener('visibilitychange', stop); };
|
||||
document.addEventListener('visibilitychange', stop);
|
||||
}
|
||||
|
||||
function fmtDur(s){ const m = Math.floor(s/60); return m + ':' + String(s%60).padStart(2,'0'); }
|
||||
|
||||
function render(s){
|
||||
$('#link').value = s.link || '';
|
||||
$('#copy').disabled = $('#revoke').disabled = !s.link;
|
||||
const name = s.guestName || 'Guest (anonymous)';
|
||||
const c = s.call;
|
||||
let line = s.guestOnline ? name + ' - page open' : 'No guest connected';
|
||||
if (c && c.pc === 'connected') line = name + ' - LIVE ' + fmtDur(c.secs);
|
||||
else if (c) line = name + ' - audio ' + c.pc;
|
||||
$('#guest').textContent = line;
|
||||
$('#callinfo').textContent = c ? ('route: ' + (c.route || '…') + (c.sending ? '' : ' · send stopped') + (c.playing ? '' : ' · playout stopped')) : '';
|
||||
if (c) {
|
||||
$('#dIn').textContent = Math.max(-60, setMeter($('#mIn'), c.inPeak)).toFixed(0) + ' dB';
|
||||
$('#dOut').textContent = Math.max(-60, setMeter($('#mOut'), c.outPeak)).toFixed(0) + ' dB';
|
||||
const j = c.jb;
|
||||
$('#jb').textContent = j ? `buffer ${j.depthMs}/${j.targetMs} ms · lost ${j.lost} (fec ${j.fec}) · concealed ${j.plc} · drift-drops ${j.dropped} · underruns ${j.underruns}` : '';
|
||||
} else {
|
||||
setMeter($('#mIn'), 0); setMeter($('#mOut'), 0); $('#dIn').textContent = $('#dOut').textContent = ''; $('#jb').textContent = '';
|
||||
}
|
||||
$('#endcall').disabled = !c; $('#kick').disabled = !s.guestOnline && !c;
|
||||
}
|
||||
|
||||
// Two-click confirm for destructive buttons (no browser dialogs).
|
||||
function confirmBtn(btn, action){
|
||||
const label = btn.textContent; let armed = null;
|
||||
btn.onclick = () => {
|
||||
if (armed) { clearTimeout(armed); armed = null; btn.textContent = label; action(); return; }
|
||||
btn.textContent = 'Click again to confirm'; armed = setTimeout(() => { armed = null; btn.textContent = label; }, 3000);
|
||||
};
|
||||
}
|
||||
confirmBtn($('#newlink'), () => send({t:'newlink'}));
|
||||
confirmBtn($('#revoke'), () => send({t:'revoke'}));
|
||||
confirmBtn($('#kick'), () => send({t:'kick'}));
|
||||
confirmBtn($('#endcall'), () => send({t:'endcall'}));
|
||||
confirmBtn($('#clear'), () => send({t:'clearchat'}));
|
||||
$('#copy').onclick = async () => {
|
||||
const el = $('#link'); el.select();
|
||||
try { await navigator.clipboard.writeText(el.value); } catch(e) { document.execCommand('copy'); }
|
||||
$('#copy').textContent = 'Copied'; setTimeout(() => $('#copy').textContent = 'Copy', 1500);
|
||||
};
|
||||
chatForm($('#chatform'), $('#msg'), t => send({t:'chat', text:t}));
|
||||
connect();
|
||||
@@ -0,0 +1,37 @@
|
||||
:root{--bg:#f4f5f7;--card:#fff;--fg:#1b1d22;--muted:#6a7180;--line:#d9dce3;--accent:#2563eb;--accent-fg:#fff;--danger:#c0392b;--ok:#15803d;--warn:#b45309;--me:#e7efff;--them:#f0f1f4;--sys:#fff7e0}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#121418;--card:#1c1f26;--fg:#e8eaee;--muted:#9aa1ad;--line:#2e333d;--accent:#5b8cff;--accent-fg:#0b0d12;--danger:#ff6b5b;--ok:#4ade80;--warn:#fbbf24;--me:#1f2d4d;--them:#262a33;--sys:#3a3220}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
|
||||
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line)}
|
||||
h1{font-size:18px;margin:0}h2{font-size:15px;margin:0 0 10px}
|
||||
main.grid{display:grid;grid-template-columns:minmax(280px,420px) 1fr;gap:16px;padding:16px;max-width:1200px;margin:0 auto}
|
||||
main.narrow{max-width:520px;margin:60px auto;padding:0 16px}
|
||||
@media (max-width:760px){main.grid{grid-template-columns:1fr}}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;min-width:0}
|
||||
.chat{display:flex;flex-direction:column;min-height:420px}
|
||||
.log{flex:1;overflow-y:auto;min-height:240px;max-height:65vh;display:flex;flex-direction:column;gap:6px;padding:4px 0 10px}
|
||||
.msg{max-width:85%;padding:7px 10px;border-radius:10px;background:var(--them);white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.msg.me{align-self:flex-end;background:var(--me)}
|
||||
.msg.system{align-self:center;background:var(--sys);font-size:13px;color:var(--muted)}
|
||||
.msg .who{display:block;font-size:12px;color:var(--muted);margin-bottom:2px}
|
||||
.row{display:flex;gap:8px;align-items:center;margin:8px 0;flex-wrap:wrap}.row.between{justify-content:space-between}
|
||||
.row>input,.row>textarea{flex:1;min-width:0}
|
||||
input,select,textarea{font:inherit;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:7px;padding:7px 9px;width:100%}
|
||||
textarea{resize:vertical}
|
||||
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--bg);color:var(--fg);border-radius:7px;padding:7px 14px}
|
||||
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);font-weight:600}
|
||||
button.danger{color:var(--danger)}
|
||||
button:disabled{opacity:.5;cursor:default}
|
||||
button.small{padding:3px 10px;font-size:13px}
|
||||
.field{display:block;margin:0 0 10px;font-size:14px}.field select,.field input{margin-top:4px}
|
||||
.check{display:flex;gap:8px;align-items:flex-start;font-size:14px;margin:6px 0 10px}.check input{width:auto;margin-top:3px}
|
||||
.muted{color:var(--muted)}.small{font-size:13px}.mono{font-family:ui-monospace,monospace}.big{font-size:17px;font-weight:600;margin-bottom:4px}
|
||||
.hidden{display:none!important}
|
||||
.pill{font-size:13px;padding:3px 10px;border-radius:99px;border:1px solid var(--line);color:var(--muted)}
|
||||
.pill.ok{color:var(--ok);border-color:var(--ok)}.pill.bad{color:var(--danger);border-color:var(--danger)}
|
||||
.banner{margin:16px;padding:12px 16px;border-radius:8px;background:var(--sys);border:1px solid var(--warn)}
|
||||
.meter{display:grid;grid-template-columns:130px 1fr 52px;align-items:center;gap:8px;margin:8px 0;font-size:13px}
|
||||
.bar{height:10px;background:var(--bg);border:1px solid var(--line);border-radius:5px;overflow:hidden}
|
||||
.bar i{display:block;height:100%;width:0;background:var(--ok);transition:width .08s linear}
|
||||
.bar i.hot{background:var(--warn)}.bar i.clip{background:var(--danger)}
|
||||
.meter b{font-weight:400;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
|
||||
Reference in new issue
Block a user