www.unturf.com/zebra-report/zebra-spaces.html

1297 lines
59 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>zebra spaces</title>
<style>
@font-face {
font-family: 'chunkfiveregular';
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal; font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace; background: #fff; color: #000;
padding: 2rem; max-width: 1440px; margin: 0 auto;
}
/* two-column page on desktop: left = timeline (1fr, takes remaining
* space — will host the running event feed once v0.3 lands), right =
* controls (capped at 520px so they never get awkwardly wide on a
* full-screen monitor). Both columns get min-width:0 so they shrink
* without forcing a horizontal scrollbar. */
.page {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 520px);
gap: 2rem;
align-items: start;
}
.timeline {
border-right: 1px solid #ddd; padding-right: 2rem; min-height: 60vh;
min-width: 0;
}
.controls { min-width: 0; }
.timeline-placeholder {
font-size: 0.8rem; color: #999; padding: 1rem 0; line-height: 1.6;
}
@media (max-width: 800px) {
body { padding: 1.25rem; }
/* stack on mobile, controls first, timeline below */
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
.controls { order: 1; }
.timeline { order: 2;
border-right: none; border-top: 1px solid #ddd;
padding-right: 0; padding-top: 1rem; min-height: 0; }
}
/* never let dynamically-appended <audio> sinks (one per remote
* speaker) render their default ~300px control strip — they're just
* sinks for the WebRTC track, no UI required */
audio { display: none; }
h1 {
font-family: 'chunkfiveregular', serif;
font-size: 3rem; font-weight: normal; letter-spacing: 0.02em;
line-height: 1; margin-bottom: 0.2rem;
}
.sub {
font-size: 0.75rem; color: #555; margin-bottom: 2rem;
letter-spacing: 0.05em; text-transform: uppercase;
}
.sub a { color: #555; }
h2 {
font-family: 'chunkfiveregular', serif; font-size: 1.1rem; font-weight: normal;
border-bottom: 1px solid #000; padding-bottom: 0.25rem; margin-bottom: 0.8rem;
}
section { margin-bottom: 1.6rem; }
button {
background: #fff; color: #000; border: 1px solid #000;
padding: 0.4rem 0.9rem; font-family: monospace; font-size: 0.85rem; cursor: pointer;
}
button:hover:not(:disabled) { background: #f0f0f0; }
button:disabled { opacity: 0.3; cursor: default; }
button.invert { background: #000; color: #fff; }
button.invert:hover:not(:disabled) { background: #333; }
button.small { padding: 0.2rem 0.55rem; font-size: 0.75rem; }
.row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; flex-wrap: wrap; }
input[type=text], input[type=password] {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
padding: 0.4rem; background: #fff; color: #000; flex: 1; min-width: 0;
}
textarea {
font-family: monospace; font-size: 0.75rem; border: 1px solid #000;
padding: 0.4rem; background: #fff; color: #000; width: 100%; min-height: 4rem;
word-break: break-all;
}
select {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
padding: 0.4rem; 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; }
.status-line { font-size: 0.8rem; color: #555; }
.status-line.ok { color: #060; }
.status-line.err { color: #b00; }
.note { font-size: 0.75rem; color: #555; line-height: 1.5; }
.meter { height: 8px; border: 1px solid #000; background: #fff; position: relative; overflow: hidden; }
.meter-fill { height: 100%; background: #000; width: 0%; transition: width 0.06s linear; }
/* member rows: badge | handle | pubkey short | mic | meter — mod actions
* wrap to a 2nd row underneath, indented under the handle */
.member {
display: grid;
grid-template-columns: 5.5rem 1fr 4rem 1.4rem 1fr;
align-items: center; gap: 0.55rem; padding: 0.35rem 0;
border-bottom: 1px dotted #ccc; font-size: 0.85rem;
}
.member:last-child { border-bottom: none; }
.badge {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border: 1px solid #000; text-align: center;
}
.badge.host { background: #000; color: #fff; }
.badge.cohost { background: #444; color: #fff; }
.badge.speaker { background: #fff; color: #000; }
.badge.listener{ background: #fff; color: #888; border-color: #888; }
.handle { font-weight: bold; word-break: break-all; }
.handle .me { color: #060; font-weight: normal; font-size: 0.7rem; margin-left: 0.3rem; }
.pub-short { font-size: 0.7rem; color: #888; }
.raised { color: #b00; font-weight: bold; }
.mic { width: 18px; height: 18px; }
.mic svg { width: 18px; height: 18px; display: block; }
.mod-actions {
display: flex; gap: 0.3rem; flex-wrap: wrap;
grid-column: 1 / -1; /* second row, full width */
justify-content: flex-start;
padding-left: 6.05rem; /* line up under the handle (badge col + gap) */
margin-top: 0.2rem;
}
.mod-actions:empty { display: none; } /* no row gap when nothing to do */
.log { border: 1px solid #000; height: 150px; overflow-y: auto; padding: 0.5rem; font-size: 0.75rem; line-height: 1.5; background: #fafafa; }
.log-line { margin-bottom: 0.2rem; word-wrap: break-word; }
.log-line .ts { color: #888; }
.log-line.err { color: #b00; }
.invite-banner {
border: 2px solid #000; padding: 0.8rem; margin-bottom: 1rem; background: #ffd;
display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem;
}
.invite-banner .invite-actions { display: flex; gap: 0.5rem; }
.notice-banner {
padding: 0.7rem 0.8rem; margin-bottom: 1rem;
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
font-size: 0.85rem;
}
.notice-banner.warn { border: 2px solid #b00; background: #fee; color: #b00; }
.notice-banner.info { border: 2px solid #060; background: #efe; color: #060; }
.notice-banner button { margin-left: auto; }
.vault-panel { border: 1px dashed #000; padding: 0.7rem; margin-top: 0.5rem; }
.hidden { display: none !important; }
@media (max-width: 500px) {
.member { grid-template-columns: 5rem 1fr 1.4rem; }
.member .pub-short, .member .meter { display: none; }
.mod-actions { padding-left: 5.55rem; }
}
</style>
</head>
<body>
<div class="page">
<main class="timeline">
<p class="timeline-placeholder">timeline — coming soon</p>
</main>
<aside class="controls">
<h1>zebra spaces</h1>
<p class="sub">encrypted voice rooms &nbsp;·&nbsp; webrtc &nbsp;·&nbsp; rendezvous &nbsp;·&nbsp;
<a href="zebra-audio.html">1:1 call</a> &nbsp;·&nbsp;
<a href="host-your-own.html">host your own</a> &nbsp;·&nbsp;
<a href="/">unturf</a></p>
<section id="sec-identity">
<h2>identity</h2>
<div class="row">
<label class="note" for="handle" style="flex:0 0 4rem">handle</label>
<input type="text" id="handle" placeholder="what others see — under 32 chars" maxlength="32" autocomplete="off">
<span id="pub-short" class="pub-short" title="your persistent ed25519 pubkey"></span>
</div>
<div class="row">
<button id="btn-vault" class="small">backup / restore key</button>
<span class="note">your key lives only in this browser; back it up if you want it on another device or after clearing data.</span>
</div>
<div id="vault-panel" class="vault-panel hidden">
<p class="note" style="margin-bottom:0.5rem"><strong>backup</strong> — pick a password; you'll get a text blob to save.</p>
<div class="row">
<input type="password" id="vault-pass" placeholder="password" autocomplete="new-password">
<button id="btn-vault-backup" class="small">export</button>
</div>
<p class="note" style="margin:0.6rem 0 0.4rem"><strong>restore</strong> — paste a backup blob + its password. <em>overwrites your current identity.</em></p>
<textarea id="vault-blob" placeholder="zspc-id-v1|…"></textarea>
<div class="row" style="margin-top:0.4rem">
<input type="password" id="vault-pass-restore" placeholder="password" autocomplete="current-password">
<button id="btn-vault-restore" class="small">import</button>
</div>
<div class="row" style="margin-top:0.4rem">
<span class="dot" id="vault-dot"></span>
<span id="vault-status" class="status-line">idle</span>
</div>
</div>
</section>
<section id="sec-call">
<h2>space</h2>
<div class="row">
<input type="text" id="rdv-code" placeholder="rendezvous code — same string for everyone in the space">
<button id="btn-enter" class="invert">enter</button>
</div>
<div class="row">
<span class="dot warn" id="dot-call"></span>
<span id="call-status" class="status-line">idle</span>
<button id="btn-mute" disabled>mute</button>
<button id="btn-leave" disabled>leave</button>
</div>
<div class="row" id="row-mic-controls">
<label class="note" for="mic-select" style="flex:0 0 3rem">input</label>
<select id="mic-select" title="audio input device — applies once you have the mic"><option value="">default microphone</option></select>
</div>
<div class="row" id="row-music-mode">
<label class="note"><input type="checkbox" id="music-mode"> music mode — raw mic, no echo/noise cancellation (for playing audio through it)</label>
</div>
<p class="note">
everyone joins as a listener. host (first to enter) can invite listeners to the
mic. co-hosts share mod powers but cannot remove the host. audio is end-to-end
encrypted (DTLS-SRTP); SDP is encrypted with the rendezvous code before reaching
the relay.
</p>
</section>
<section id="sec-invite" class="hidden">
<div class="invite-banner">
<div>
<strong>you're invited to the mic.</strong>
<span id="invite-from" class="note"></span>
</div>
<div class="invite-actions">
<button id="btn-accept-mic" class="invert small">accept</button>
<button id="btn-decline-mic" class="small">decline</button>
</div>
</div>
</section>
<section id="sec-notice" class="hidden">
<div id="notice-banner" class="notice-banner warn">
<span id="notice-text"></span>
<button id="btn-notice-close" class="small">dismiss</button>
</div>
</section>
<section id="sec-room" class="hidden">
<h2>room</h2>
<div id="members"></div>
</section>
<section id="sec-listener-actions" class="hidden">
<h2>your hand</h2>
<div class="row">
<button id="btn-raise" class="invert">raise hand</button>
<button id="btn-lower" class="hidden">lower hand</button>
<span id="hand-status" class="note">tap "raise hand" to ask a mod for the mic.</span>
</div>
</section>
<section>
<h2>log</h2>
<div class="log" id="log"></div>
</section>
</aside>
</div>
<script>
(async () => {
const $ = (id) => document.getElementById(id);
function b64(buf){ const u8=buf instanceof Uint8Array?buf:new Uint8Array(buf); let s=''; for(const b of u8) s+=String.fromCharCode(b); return btoa(s); }
function unb64(s){ return Uint8Array.from(atob(s), c=>c.charCodeAt(0)); }
function hex(buf){ const u8=buf instanceof Uint8Array?buf:new Uint8Array(buf); return Array.from(u8).map(b=>b.toString(16).padStart(2,'0')).join(''); }
function shortHex(s){ return s.slice(0,4)+'…'+s.slice(-4); }
const logEl = $('log');
function logLine(kind, msg){
const d=document.createElement('div'); d.className='log-line '+(kind||'');
d.innerHTML='<span class="ts">'+new Date().toLocaleTimeString()+'</span> '+msg.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight;
}
/* ==================================================================
* identity — ed25519 keypair, persisted in localStorage as JWK.
*
* Why JWK and not raw bytes: WebCrypto's Ed25519 importKey accepts
* 'jwk' and 'raw'/'pkcs8' but JWK round-trips losslessly with both
* private and pubkey halves in one object. The pubkey we wire to the
* server is the raw 32-byte form, base64-encoded, matching what the
* Go side does with ed25519.PublicKey.
* ================================================================== */
const ID_KEY = 'zebra-spaces-id-v1';
const HANDLE_KEY = 'zebra-spaces-handle-v1';
const VAULT_PREFIX = 'zspc-id-v1|';
const PBKDF2_ITER = 600000;
let myKeys = null; /* { privateKey, publicKey, pubB64, pubHex } */
let myHandle = '';
async function generateIdentity(){
const kp = await crypto.subtle.generateKey({ name:'Ed25519' }, true, ['sign','verify']);
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
localStorage.setItem(ID_KEY, JSON.stringify(jwk));
return packKeys(kp.privateKey, kp.publicKey, raw);
}
function packKeys(priv, pub, rawPub){
const pubB64 = b64(rawPub), pubHex = hex(rawPub);
return { privateKey: priv, publicKey: pub, pubB64, pubHex };
}
async function loadOrCreateIdentity(){
const stored = localStorage.getItem(ID_KEY);
if (!stored){ myKeys = await generateIdentity(); logLine('','new identity created'); return; }
try {
const jwk = JSON.parse(stored);
const priv = await crypto.subtle.importKey('jwk', jwk, { name:'Ed25519' }, true, ['sign']);
/* derive pubkey JWK from the priv JWK so we can importKey for raw export */
const pubJwk = { kty:jwk.kty, crv:jwk.crv, x:jwk.x };
const pub = await crypto.subtle.importKey('jwk', pubJwk, { name:'Ed25519' }, true, ['verify']);
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pub));
myKeys = packKeys(priv, pub, raw);
} catch(e){
logLine('err','stored identity unreadable, generating new one: '+e.message);
myKeys = await generateIdentity();
}
}
async function signBytes(bytes){
const sig = await crypto.subtle.sign('Ed25519', myKeys.privateKey, bytes);
return b64(sig);
}
/* canonical sig inputs — must match the Go side byte-for-byte */
function sigJoin(roomID, nonce, pubB64, handle){
return new TextEncoder().encode('zebra-spaces|v1|join|'+roomID+'|'+nonce+'|'+pubB64+'|'+handle);
}
function sigAction(roomID, epoch, action, ...args){
return new TextEncoder().encode(['zebra-spaces','v1',roomID,String(epoch),action,...args].join('|'));
}
/* ==================================================================
* vault — password backup/restore of the identity JWK.
*
* Format: 'zspc-id-v1|' + base64(salt[16] | iv[12] | aes-gcm-ct).
* key = PBKDF2-SHA256(password, salt, 600000) -> AES-GCM-256
* ct = AES-GCM(iv, key, utf8(JSON(jwk)))
* Self-contained: anyone with the blob + password can restore.
* ================================================================== */
async function deriveVaultKey(password, salt, usage){
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password),
'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey({ name:'PBKDF2', salt, iterations:PBKDF2_ITER, hash:'SHA-256' },
base, { name:'AES-GCM', length:256 }, false, usage);
}
async function vaultExport(password){
const jwk = JSON.parse(localStorage.getItem(ID_KEY));
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveVaultKey(password, salt, ['encrypt']);
const ct = new Uint8Array(await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key,
new TextEncoder().encode(JSON.stringify(jwk))));
const blob = new Uint8Array(16+12+ct.length); blob.set(salt); blob.set(iv,16); blob.set(ct,28);
return VAULT_PREFIX + b64(blob);
}
async function vaultImport(blobStr, password){
if (!blobStr.startsWith(VAULT_PREFIX)) throw new Error('not a zebra-spaces vault blob');
const bytes = unb64(blobStr.slice(VAULT_PREFIX.length).trim());
if (bytes.length < 16+12+1) throw new Error('vault blob too short');
const salt = bytes.slice(0,16), iv = bytes.slice(16,28), ct = bytes.slice(28);
const key = await deriveVaultKey(password, salt, ['decrypt']);
const pt = new Uint8Array(await crypto.subtle.decrypt({ name:'AES-GCM', iv }, key, ct));
const jwk = JSON.parse(new TextDecoder().decode(pt));
/* round-trip through WebCrypto to validate it's a real Ed25519 key */
const priv = await crypto.subtle.importKey('jwk', jwk, { name:'Ed25519' }, true, ['sign']);
const pubJwk = { kty:jwk.kty, crv:jwk.crv, x:jwk.x };
const pub = await crypto.subtle.importKey('jwk', pubJwk, { name:'Ed25519' }, true, ['verify']);
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pub));
localStorage.setItem(ID_KEY, JSON.stringify(jwk));
myKeys = packKeys(priv, pub, raw);
renderIdentity();
}
function setVaultStatus(msg, cls){
const e=$('vault-status'); e.textContent=msg; e.className='status-line'+(cls?' '+cls:'');
$('vault-dot').className='dot'+(cls==='ok'?' ok':cls==='err'?' warn':'');
}
function renderIdentity(){ $('pub-short').textContent = myKeys ? shortHex(myKeys.pubHex) : ''; }
$('btn-vault').addEventListener('click', () => $('vault-panel').classList.toggle('hidden'));
$('btn-vault-backup').addEventListener('click', async () => {
const pw = $('vault-pass').value;
if (!pw){ setVaultStatus('enter a password first','err'); return; }
if (pw.length < 8){ setVaultStatus('password too short (min 8 chars)','err'); return; }
try {
setVaultStatus('encrypting…');
const blob = await vaultExport(pw);
/* offer download — local file, no network */
const fname = 'zebra-id-'+shortHex(myKeys.pubHex).replace('…','-')+'.txt';
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([blob], {type:'text/plain'}));
a.download = fname; a.click();
setVaultStatus('exported as '+fname,'ok');
$('vault-pass').value = '';
} catch(e){ setVaultStatus('export failed: '+e.message,'err'); }
});
$('btn-vault-restore').addEventListener('click', async () => {
const blob = $('vault-blob').value.trim();
const pw = $('vault-pass-restore').value;
if (!blob || !pw){ setVaultStatus('paste blob + password','err'); return; }
try {
setVaultStatus('decrypting…');
await vaultImport(blob, pw);
setVaultStatus('identity restored — pubkey '+shortHex(myKeys.pubHex),'ok');
$('vault-blob').value=''; $('vault-pass-restore').value='';
} catch(e){ setVaultStatus('restore failed: '+e.message,'err'); }
});
/* handle is per-browser, also in localStorage so it survives reload */
$('handle').addEventListener('input', (e) => {
myHandle = e.target.value.trim().slice(0, 32);
localStorage.setItem(HANDLE_KEY, myHandle);
});
await loadOrCreateIdentity();
myHandle = localStorage.getItem(HANDLE_KEY) || '';
$('handle').value = myHandle;
renderIdentity();
logLine('', 'pubkey '+myKeys.pubHex);
/* ==================================================================
* rendezvous signaling
* ================================================================== */
const SIGNAL_URL = new URLSearchParams(location.search).get('signal')
|| 'wss://cors-proxy.uncloseai.com/zebra-spaces-signal';
const SIGNAL_SALT = new TextEncoder().encode('zebra-spaces-v1');
async function deriveSignalRoom(code){
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('zebra-spaces-room|'+code));
return hex(h);
}
async function deriveSignalKey(code){
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(code), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey({ name:'PBKDF2', salt:SIGNAL_SALT, iterations:PBKDF2_ITER, hash:'SHA-256' },
base, { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']);
}
async function aesEncrypt(key, str){
const iv=crypto.getRandomValues(new Uint8Array(12));
const ct=new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv}, key, new TextEncoder().encode(str)));
const out=new Uint8Array(12+ct.length); out.set(iv); out.set(ct,12); return out;
}
async function aesDecrypt(key, bytes){
const iv=bytes.slice(0,12), ct=bytes.slice(12);
return new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv}, key, ct));
}
/* ==================================================================
* SFU bridge — listeners subscribe to receive every speaker's audio;
* speakers publish their mic. Mesh handles speaker↔speaker low-latency;
* SFU handles broadcast fan-out to listeners. Speakers never subscribe
* (they'd hear their mesh peers a second time, delayed).
* ================================================================== */
const SFU_BASE = (new URLSearchParams(location.search).get('sfu')
|| 'https://cors-proxy.uncloseai.com/zebra-spaces-sfu').replace(/\/$/, '');
let sfuPubPC = null, sfuPubPeerID = null;
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
/* SFU MediaStream cache keyed by PUBLISHER pubkey hex (= streamID set by the
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
* we tear their audio element, but Pion often REUSES the transceiver on
* their rejoin so ontrack doesn't fire a second time — the same MediaStream
* object keeps receiving new RTP under the hood. By caching by pubkey we
* can re-attach that same stream to a fresh audio element on peer-joined
* even when no fresh ontrack event arrives. */
const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream
function attachSfuTrack(uuid, stream){
let a = remoteAudio.get(uuid);
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
a.srcObject = stream;
/* programmatic play in case autoplay policy needs the nudge after a track
* swap — caller already had a user gesture (entered the space) */
try { const p = a.play(); if (p && p.catch) p.catch(()=>{}); } catch(_){}
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || shortHex(uuid)));
}
/* If we already have a cached SFU stream for this member's pubkey (from a
* prior subscribe-side ontrack), attach it. Used on peer-joined / host
* promotions / room state updates so a rejoined speaker's audio reattaches
* without needing the SFU to emit a fresh ontrack. */
function attachCachedSfuStreamFor(uuid){
const mm = members.get(uuid);
if (!mm || !mm.pubkey) return;
let pubHex;
try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ return; }
const stream = sfuStreamsByPubHex.get(pubHex);
if (stream) attachSfuTrack(uuid, stream);
}
function flushSfuStreams(){
for (const [pubHex, stream] of sfuStreamsByPubHex){
for (const [uuid, mm] of members){
try {
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
if (!remoteAudio.has(uuid)) attachSfuTrack(uuid, stream);
break;
}
} catch(_){}
}
}
}
async function sfuPublish(){
if (sfuPubPC){ logLine('','sfu publish: already publishing'); return; }
if (!micStream || !myKeys || !roomID){
logLine('err','sfu publish skipped: mic='+(!!micStream)+' keys='+(!!myKeys)+' room='+(!!roomID));
return;
}
logLine('', 'sfu publish: starting (base='+SFU_BASE+')');
const pc = new RTCPeerConnection(rtcConfig);
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
await pc.setLocalDescription(await pc.createOffer());
await waitForIceGathering(pc);
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sdp: pc.localDescription.sdp })
});
if (!res.ok){ pc.close(); throw new Error('sfu publish http '+res.status); }
const ans = await res.json();
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
sfuPubPC = pc; sfuPubPeerID = ans.peer_id;
logLine('', 'sfu: publishing as '+shortHex(sfuPubPeerID));
}
async function sfuUnpublish(){
if (!sfuPubPC) return;
const pid = sfuPubPeerID;
try { sfuPubPC.close(); } catch(_){}
sfuPubPC = null; sfuPubPeerID = null;
if (pid && roomID){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
}
}
async function sfuSubscribe(){
if (sfuSubPC || !roomID) return;
const pc = new RTCPeerConnection(rtcConfig);
pc.ontrack = (ev) => {
const pubHex = ev.streams[0] ? ev.streams[0].id : '';
if (!pubHex) return;
/* cache stream by publisher pubkey so it survives the member's session
* uuid changing across leave/rejoin — see flushSfuStreams */
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
for (const [uuid, mm] of members){
try {
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
attachSfuTrack(uuid, ev.streams[0]);
return;
}
} catch(_){}
}
/* no matching member yet — flushSfuStreams will attach on peer-joined */
};
/* server-initiated offer: POST /subscribe (empty body) — SFU answers with
* an SDP offer containing one m-line per current publisher. We answer it
* and POST the answer back, which completes the initial handshake. */
const offerRes = await fetch(SFU_BASE + '/subscribe?room=' + encodeURIComponent(roomID), {
method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'
});
if (!offerRes.ok){ pc.close(); throw new Error('sfu subscribe http '+offerRes.status); }
const offer = await offerRes.json();
await pc.setRemoteDescription({ type:'offer', sdp: offer.sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGathering(pc);
const ackRes = await fetch(SFU_BASE + '/subscribe-answer?room=' + encodeURIComponent(roomID) + '&peer=' + offer.peer_id, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sdp: pc.localDescription.sdp })
});
if (!ackRes.ok){ pc.close(); throw new Error('sfu subscribe-answer http '+ackRes.status); }
sfuSubPC = pc; sfuSubPeerID = offer.peer_id;
/* SSE: server pushes renegotiation offers when publisher set changes.
* We answer each via POST /answer. ping events are keepalive only. */
sfuSubEvents = new EventSource(SFU_BASE + '/events?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID);
sfuSubEvents.onmessage = async (ev) => {
let m; try { m = JSON.parse(ev.data); } catch(_){ return; }
if (m.type !== 'offer' || !sfuSubPC) return;
try {
await sfuSubPC.setRemoteDescription({ type:'offer', sdp: m.sdp });
const ans = await sfuSubPC.createAnswer();
await sfuSubPC.setLocalDescription(ans);
await waitForIceGathering(sfuSubPC);
await fetch(SFU_BASE + '/answer?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sdp: sfuSubPC.localDescription.sdp })
});
} catch(e){ logLine('err','sfu renegotiate: '+e.message); }
};
sfuSubEvents.onerror = () => { /* EventSource auto-reconnects */ };
logLine('', 'sfu: subscribed as '+shortHex(sfuSubPeerID));
}
async function sfuUnsubscribe(){
if (sfuSubEvents){ try { sfuSubEvents.close(); } catch(_){} sfuSubEvents = null; }
if (sfuSubPC){ try { sfuSubPC.close(); } catch(_){} sfuSubPC = null; sfuSubPeerID = null; }
sfuStreamsByPubHex.clear();
}
/* ==================================================================
* ephemeral TURN credentials (reused from zebra-audio model)
* ================================================================== */
const TURN_CRED_URL = new URLSearchParams(location.search).get('turncred')
|| 'https://cors-proxy.uncloseai.com/turn-cred';
let rtcConfig = { iceServers: [{ urls: ['stun:proxy.uncloseai.com:3478','stun:stun.l.google.com:19302'] }] };
async function refreshTurnCred(){
try {
const c = await (await fetch(TURN_CRED_URL, {cache:'no-store'})).json();
if (c && c.credential && c.uris) {
rtcConfig = { iceServers: [
{ urls: c.stun || ['stun:proxy.uncloseai.com:3478'] },
{ urls: c.uris, username: c.username, credential: c.credential }
] };
logLine('', 'TURN credentials fetched');
}
} catch (e) { logLine('', 'no TURN creds — direct/STUN only ('+e.message+')'); }
}
/* ==================================================================
* audio — mic acquisition + per-PC remote audio elements
*
* One mic stream local; one <audio> per remote speaker (so all speakers
* are heard concurrently). Music-mode + mid-call device switching from
* zebra-audio carries over directly: re-acquire + replaceTrack on every
* live sender (now multiple senders, one per peer).
* ================================================================== */
let micStream = null, audioCtx = null, musicMode = false, micDeviceId = '';
function micConstraints(){
const base = musicMode
? { echoCancellation:false, noiseSuppression:false, autoGainControl:false }
: { echoCancellation:true, noiseSuppression:true, autoGainControl:true };
if (micDeviceId) base.deviceId = { exact: micDeviceId };
return base;
}
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;
} catch(e){ logLine('err','could not list inputs: '+e.message); }
}
function tagTrack(t){ if (t) t.contentHint = musicMode ? 'music' : 'speech'; }
async function getMic(){
if (micStream) return micStream;
micStream = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
tagTrack(micStream.getAudioTracks()[0]);
return micStream;
}
async function setSenderBitrate(sender){
if (!sender) return;
try {
const p = sender.getParameters();
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
p.encodings[0].maxBitrate = musicMode ? 160000 : 40000;
await sender.setParameters(p);
} catch(_){}
}
async function applyMicMode(){
/* re-acquire mic with new constraints, hot-swap onto every live sender
* (mesh peers + the SFU publish PC) */
const ns = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
const nt = ns.getAudioTracks()[0];
tagTrack(nt); nt.enabled = !muted;
async function swap(pc){
const sender = pc.getSenders().find(s=>s.track && s.track.kind==='audio') || pc.getSenders()[0];
if (sender){ try { await sender.replaceTrack(nt); } catch(_){} setSenderBitrate(sender); }
}
for (const [_, pc] of peers) await swap(pc);
if (sfuPubPC) await swap(sfuPubPC);
if (micStream) micStream.getTracks().forEach(t=>t.stop());
micStream = ns;
/* old analyser is now dead — rewire local meter against the fresh stream */
if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); }
}
/* per-peer meter: one analyser node + one rAF loop, keyed by uuid. The tick
* reads members.get(uuid)._meterEl fresh each frame so renderRoom can replace
* the DOM element without killing the meter. Cleanup happens when the uuid
* leaves the room (members loses the key) or stopMeter is called. */
const meterCtl = new Map(); /* uuid -> {an, buf} */
function startMeter(uuid, stream){
if (!stream || meterCtl.has(uuid)) return;
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
let src;
try { src = audioCtx.createMediaStreamSource(stream); }
catch(e){ logLine('err','meter for '+shortHex(uuid)+': '+e.message); return; }
const an = audioCtx.createAnalyser(); an.fftSize = 512;
src.connect(an);
const buf = new Uint8Array(an.fftSize);
meterCtl.set(uuid, { an, buf });
(function tick(){
const c = meterCtl.get(uuid);
if (!c) return;
if (!members.has(uuid)){ meterCtl.delete(uuid); return; }
c.an.getByteTimeDomainData(c.buf);
let peak=0;
for (let i=0;i<c.buf.length;i++){ const v=Math.abs(c.buf[i]-128)/128; if(v>peak)peak=v; }
const m = members.get(uuid);
if (m && m._meterEl) m._meterEl.style.width = Math.min(100, Math.round(peak*180))+'%';
requestAnimationFrame(tick);
})();
}
function stopMeter(uuid){ meterCtl.delete(uuid); }
/* ==================================================================
* room state mirror (server is source of truth, we mirror locally for
* rendering). updated by welcome / peer-joined / peer-left / state /
* role-change / peer-booted / host-promoted.
* ================================================================== */
let myUUID = '';
let myRole = ''; /* host | cohost | speaker | listener */
let roomEpoch = 0;
let roomID = '';
let members = new Map(); /* uuid -> {uuid,pubkey,handle,role,joined_at} */
let hostUUID = '';
let handraise = new Set(); /* uuids of listeners with hand raised */
let outstandingInvite = null; /* {from, epoch} when we're invited */
const peers = new Map(); /* uuid -> RTCPeerConnection (mesh among speakers) */
const remoteAudio = new Map(); /* uuid -> <audio> element */
function isMod(role){ return role==='host' || role==='cohost'; }
function canSpeak(role){ return role==='host' || role==='cohost' || role==='speaker'; }
/* ==================================================================
* signaling websocket — JSON text frames, see protocol header in
* cmd/zebra-spaces-signal/main.go
* ================================================================== */
let ws = null, wantConnected = false, sigKey = null, sigReconnect = null;
function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); }
async function sendEncSDP(toUUID, kind, desc){
send({ type:'sdp-to', to:toUUID, kind, data: b64(await aesEncrypt(sigKey, JSON.stringify(desc))) });
}
async function sendMicState(){
if (!sigKey) return;
try { send({ type:'mic-state', data: b64(await aesEncrypt(sigKey, JSON.stringify({muted}))) }); } catch(_){}
}
function setStatus(msg, cls){ const e=$('call-status'); e.textContent=msg; e.className='status-line'+(cls?' '+cls:''); }
async function joinSpace(){
const code = $('rdv-code').value.trim();
if (!code){ setStatus('enter a rendezvous code','err'); return; }
if (!myHandle){ setStatus('pick a handle first (under "identity")','err'); $('handle').focus(); return; }
if (ws){ setStatus('already in a space — leave first',null); return; }
await refreshTurnCred();
/* mic isn't acquired here — listeners don't broadcast. We grab it
* lazily when our role becomes speaker (or we entered as host). */
roomID = await deriveSignalRoom(code);
sigKey = await deriveSignalKey(code);
wantConnected = true;
$('btn-enter').disabled = true;
setStatus('connecting…');
openSignal();
}
function openSignal(){
ws = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(roomID));
ws.onopen = async () => {
/* first message: signed join. Server assigns role: host if room is
* empty/we're the recently-departed host coming back, else listener. */
const nonce = hex(crypto.getRandomValues(new Uint8Array(16)));
const sig = await signBytes(sigJoin(roomID, nonce, myKeys.pubB64, myHandle));
send({ type:'join', pubkey: myKeys.pubB64, handle: myHandle, nonce, sig });
};
ws.onclose = () => {
ws = null;
if (wantConnected){
setStatus('rendezvous dropped — reconnecting…');
if (sigReconnect) clearTimeout(sigReconnect);
sigReconnect = setTimeout(()=>{ if (wantConnected) openSignal(); }, 1500);
} else {
setStatus('left',null);
$('btn-enter').disabled = false;
}
};
ws.onerror = () => setStatus('signal error','err');
ws.onmessage = (ev) => { handleSignal(ev.data).catch(e=>logLine('err','signal: '+e.message)); };
}
async function handleSignal(raw){
let m; try { m = JSON.parse(raw); } catch(_){ return; }
switch (m.type){
case 'welcome':
myUUID = m.your_uuid; myRole = m.role; roomEpoch = m.epoch;
applyState(m.state);
logLine('', 'joined as '+myRole+' — uuid '+shortHex(myUUID));
setStatus('connected as '+myRole, 'ok');
$('dot-call').className='dot ok';
$('btn-leave').disabled = false;
$('sec-room').classList.remove('hidden');
onRoleEntered();
renderRoom();
break;
case 'state':
roomEpoch = m.epoch; applyState(m.state); flushSfuStreams(); renderRoom(); break;
case 'peer-joined':
members.set(m.uuid, { uuid:m.uuid, pubkey:m.pubkey, handle:m.handle, role:m.role, joined_at: Date.now()/1000 });
logLine('', m.handle+' joined as '+m.role);
/* if the original host rejoined during their grace window, the room
* is rescued — drop the "space closing" status from our top bar */
if (m.role === 'host'){
hostUUID = m.uuid;
setStatus('connected as '+myRole, 'ok');
}
/* a new member may resolve a queued SFU track (e.g. host's rejoin
* race where ontrack fired before peer-joined) */
flushSfuStreams();
/* establish mesh PC if both us and them are speakers (or mods).
* present-member-offers: existing speaker offers when a new speaker
* arrives. deterministic by uuid string compare. */
if (canSpeak(myRole) && canSpeak(m.role)) connectToPeer(m.uuid, /*weOffer*/ myUUID < m.uuid);
renderRoom();
break;
case 'peer-left':
{
const left = members.get(m.uuid);
if (left) logLine('', left.handle+' left');
members.delete(m.uuid);
handraise.delete(m.uuid);
tearPeer(m.uuid);
if (hostUUID === m.uuid) hostUUID = '';
renderRoom();
}
break;
case 'sdp-from':
try { await onSDP(m.from, m.kind, await aesDecrypt(sigKey, unb64(m.data))); }
catch(e){ logLine('err','sdp from '+shortHex(m.from)+' failed: '+e.message); }
break;
case 'mic-state':
try { const s = JSON.parse(await aesDecrypt(sigKey, unb64(m.data)));
const mm = members.get(m.uuid); if (mm){ mm.muted = !!s.muted; renderRoom(); } } catch(_){}
break;
case 'hand-raised':
handraise.add(m.uuid); renderRoom();
{ const mm = members.get(m.uuid); if (mm) logLine('', mm.handle+' raised hand'); }
break;
case 'hand-lowered':
handraise.delete(m.uuid); renderRoom(); break;
case 'mic-invite':
if (m.to === myUUID){
outstandingInvite = { from:m.from, epoch:m.epoch };
const from = members.get(m.from);
$('invite-from').textContent = 'from '+(from?from.handle:shortHex(m.from));
$('sec-invite').classList.remove('hidden');
logLine('', 'you were invited to the mic by '+(from?from.handle:shortHex(m.from)));
} else {
const from = members.get(m.from), to = members.get(m.to);
if (from && to) logLine('', from.handle+' invited '+to.handle+' to the mic');
}
break;
case 'mic-invite-declined':
if (m.from === myUUID){
const to = members.get(m.to);
logLine('', (to?to.handle:shortHex(m.to))+' declined the mic');
}
break;
case 'role-change':
{
const mm = members.get(m.uuid);
if (mm){
const prev = mm.role;
mm.role = m.role;
roomEpoch = m.epoch;
if (m.role === 'host') hostUUID = m.uuid;
if (m.uuid === myUUID){
myRole = m.role;
logLine('', 'you are now '+m.role);
setStatus('connected as '+myRole, 'ok');
const byMod = members.get(m.by);
const byTxt = byMod ? ' by '+byMod.handle : '';
/* visible self-notification per role transition */
if (m.role === 'listener' && prev !== 'listener') showNotice('You were moved to listener'+byTxt+'. Your mic is off.', 'warn');
else if (m.role === 'speaker' && prev === 'listener') showNotice('You are now a speaker.', 'info');
else if (m.role === 'speaker' && prev === 'cohost') showNotice('You were stepped down to speaker'+byTxt+'.', 'warn');
else if (m.role === 'cohost') showNotice('You were promoted to co-host'+byTxt+'.', 'info');
else if (m.role === 'host') showNotice('You are now the host.', 'info');
onRoleChanged(prev, m.role);
} else {
logLine('', mm.handle+' is now '+m.role);
/* mesh adjustments */
if (canSpeak(myRole)){
if (canSpeak(m.role) && !peers.has(m.uuid)) connectToPeer(m.uuid, myUUID < m.uuid);
if (!canSpeak(m.role) && peers.has(m.uuid)) tearPeer(m.uuid);
} else if (peers.has(m.uuid)){
tearPeer(m.uuid);
}
}
renderRoom();
}
}
break;
case 'peer-booted':
{
const mm = members.get(m.uuid);
const by = members.get(m.by);
if (mm) logLine('', mm.handle+' was removed by '+(by?by.handle:'a mod'));
if (m.uuid === myUUID){
/* server will close our socket; surface a clear notice and prevent
* the WS reconnect loop from auto-rejoining into a boot loop */
wantConnected = false;
showNotice('You were removed from this space'+(by?' by '+by.handle:'')+'.', 'warn');
logLine('err','you were removed from this space');
}
members.delete(m.uuid); tearPeer(m.uuid); handraise.delete(m.uuid);
renderRoom();
}
break;
case 'host-left':
logLine('', 'host left the space');
hostUUID = '';
break;
case 'host-promoted':
roomEpoch = m.epoch;
hostUUID = m.new_host_uuid;
{ const mm = members.get(m.new_host_uuid);
if (mm){ mm.role = 'host';
if (m.new_host_uuid === myUUID){ myRole = 'host'; setStatus('connected as host','ok'); onRoleChanged('cohost','host'); }
else { setStatus('connected as '+myRole, 'ok'); } /* clears the space-closing warning */
logLine('', (mm.handle||shortHex(m.new_host_uuid))+' is now host'); }
flushSfuStreams();
renderRoom();
}
break;
case 'space-closing':
logLine('err','space closing in '+m.grace_seconds+'s — '+m.reason);
setStatus('host left — space closing in '+m.grace_seconds+'s', 'err');
break;
case 'error':
logLine('err','signal: '+m.message);
setStatus('signal: '+m.message,'err');
break;
}
}
function applyState(state){
members = new Map();
hostUUID = state.host_uuid || '';
for (const m of (state.members||[])) members.set(m.uuid, Object.assign({}, m));
handraise = new Set(state.handraise_queue||[]);
}
/* called once on welcome (whatever role we entered as) and on every
* role change. host/cohost/speaker need a mic; listener drops it.
*
* SFU bridge: speakers publish to the SFU (so listeners hear them);
* listeners subscribe to the SFU (so they hear the speakers). Speakers
* never subscribe — mesh gives them lower-latency audio already. */
async function onRoleEntered(){
if (canSpeak(myRole)) await ensureMicAndUI();
else updateRoleUI();
if (canSpeak(myRole)){
for (const [uuid, mm] of members){
if (uuid === myUUID) continue;
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
}
sfuPublish().catch(e => logLine('err','sfu publish: '+e.message));
} else {
sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message));
}
}
async function onRoleChanged(prev, next){
if (!canSpeak(prev) && canSpeak(next)){
await ensureMicAndUI();
for (const [uuid, mm] of members){
if (uuid === myUUID) continue;
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
}
await sfuUnsubscribe();
sfuPublish().catch(e => logLine('err','sfu publish: '+e.message));
} else if (canSpeak(prev) && !canSpeak(next)){
for (const u of [...peers.keys()]) tearPeer(u);
dropMic(); muted = false;
await sfuUnpublish();
sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message));
}
updateRoleUI();
}
async function ensureMicAndUI(){
/* mic input + music-mode rows are always visible; here we just grant the
* mic and unblock mute. Toggling music-mode before getting a mic is fine —
* micConstraints() reads the live `musicMode` flag whenever we re-acquire. */
try {
await getMic(); await refreshMicList();
$('btn-mute').disabled = false;
if (myUUID) startMeter(myUUID, micStream);
} catch(e){ logLine('err','mic blocked: '+e.message); }
updateRoleUI();
}
function dropMic(){
if (myUUID) stopMeter(myUUID);
if (micStream){ micStream.getTracks().forEach(t=>t.stop()); micStream = null; }
$('btn-mute').disabled = true;
}
function updateRoleUI(){
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
}
/* ==================================================================
* mesh — one RTCPeerConnection per other speaker
*
* Deterministic offerer: lex-smaller uuid offers. Avoids both-offer
* collisions when two speakers arrive nearly simultaneously. Reconnect
* by tear + reconnect with the same rule, so the same side always
* drives recovery.
* ================================================================== */
async function connectToPeer(uuid, weOffer){
if (peers.has(uuid)) return;
if (!micStream){ try { await getMic(); } catch(e){ logLine('err','mic for '+shortHex(uuid)+': '+e.message); return; } }
const pc = new RTCPeerConnection(rtcConfig);
peers.set(uuid, pc);
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
pc.ontrack = (ev) => {
let a = remoteAudio.get(uuid);
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
stopMeter(uuid); startMeter(uuid, a.srcObject);
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed' && peers.get(uuid) === pc){
logLine('', 'peer '+shortHex(uuid)+' failed — reconnecting');
tearPeer(uuid);
/* let the offerer drive recovery */
setTimeout(()=>{ if (members.has(uuid) && canSpeak(members.get(uuid).role) && canSpeak(myRole))
connectToPeer(uuid, myUUID < uuid); }, 1500);
}
};
if (weOffer){
await pc.setLocalDescription(await pc.createOffer());
await waitForIceGathering(pc);
await sendEncSDP(uuid, 'offer', pc.localDescription);
}
}
function tearPeer(uuid){
stopMeter(uuid);
const pc = peers.get(uuid);
if (pc){ try { pc.close(); } catch(_){} peers.delete(uuid); }
const a = remoteAudio.get(uuid);
if (a){ try { a.srcObject = null; a.remove(); } catch(_){} remoteAudio.delete(uuid); }
}
function waitForIceGathering(p, timeoutMs=6000){
return new Promise(res=>{
if (p.iceGatheringState==='complete') return res();
let done=false; const fin=()=>{ if(done)return; done=true; p.removeEventListener('icegatheringstatechange',h); clearTimeout(t); res(); };
const h=()=>{ if(p.iceGatheringState==='complete') fin(); };
p.addEventListener('icegatheringstatechange',h); const t=setTimeout(fin,timeoutMs);
});
}
async function onSDP(fromUUID, kind, json){
const desc = JSON.parse(json);
let pc = peers.get(fromUUID);
if (kind === 'offer'){
/* if we had an old PC, tear it (renegotiation = fresh PC) */
if (pc){ try { pc.close(); } catch(_){} peers.delete(fromUUID); }
await connectToPeer(fromUUID, /*weOffer*/ false);
pc = peers.get(fromUUID); if (!pc) return;
await pc.setRemoteDescription(desc);
await pc.setLocalDescription(await pc.createAnswer());
await waitForIceGathering(pc);
await sendEncSDP(fromUUID, 'answer', pc.localDescription);
} else if (kind === 'answer' && pc){
await pc.setRemoteDescription(desc);
}
}
/* ==================================================================
* mod actions — signed messages sent to the server
* ================================================================== */
async function modInvite(uuid){
const sig = await signBytes(sigAction(roomID, roomEpoch, 'mic-invite', uuid));
send({ type:'mic-invite', to: uuid, epoch: roomEpoch, sig });
}
/* grant-mic: hand-raised listener doesn't need to accept — server promotes
* them directly to speaker. Use modInvite for cold (unsolicited) invites. */
async function modGrant(uuid){
const sig = await signBytes(sigAction(roomID, roomEpoch, 'grant-mic', uuid));
send({ type:'grant-mic', to: uuid, epoch: roomEpoch, sig });
}
async function modPromote(uuid){
const sig = await signBytes(sigAction(roomID, roomEpoch, 'promote', uuid, 'cohost'));
send({ type:'promote', target: uuid, to: 'cohost', epoch: roomEpoch, sig });
}
async function modDemote(uuid, to){
const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to));
send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig });
}
async function modBoot(uuid){
if (!confirm('boot this person from the space?')) return;
const sig = await signBytes(sigAction(roomID, roomEpoch, 'boot', uuid));
send({ type:'boot', target: uuid, epoch: roomEpoch, sig });
}
/* ==================================================================
* room rendering — one <div class="member"> per uuid
*
* Re-render on every state change. Members keep a stable _meterEl ref
* so the per-speaker meter survives identity (re-render replaces row
* nodes; meter() captures the element by closure and stops itself when
* its element leaves the DOM).
* ================================================================== */
const MIC_ON = '<svg viewBox="0 0 24 24" fill="none" stroke="#060" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
const MIC_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="#b00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
function rankOf(role){ return {host:4, cohost:3, speaker:2, listener:1}[role] || 0; }
function renderRoom(){
const wrap = $('members'); wrap.innerHTML = '';
/* sort host → cohosts → speakers → listeners, then by joined_at */
const arr = [...members.values()].sort((a,b)=>{
const r = rankOf(b.role) - rankOf(a.role);
return r !== 0 ? r : (a.joined_at - b.joined_at);
});
for (const m of arr){
const row = document.createElement('div'); row.className = 'member';
const badge = document.createElement('span'); badge.className = 'badge '+m.role; badge.textContent = m.role;
const handle = document.createElement('span'); handle.className = 'handle';
handle.textContent = m.handle;
if (m.uuid === myUUID){ const me=document.createElement('span'); me.className='me'; me.textContent='(you)'; handle.appendChild(me); }
const pub = document.createElement('span'); pub.className = 'pub-short'; pub.title = m.pubkey; pub.textContent = shortHex(hex(unb64(m.pubkey)));
const micEl = document.createElement('span'); micEl.className = 'mic';
if (canSpeak(m.role)){
micEl.innerHTML = m.muted ? MIC_OFF : MIC_ON;
} else if (handraise.has(m.uuid)){
micEl.innerHTML = '<span class="raised" title="hand raised">✋</span>';
}
const meter = document.createElement('div'); meter.className = 'meter';
const fill = document.createElement('div'); fill.className = 'meter-fill';
meter.appendChild(fill);
m._meterEl = fill;
/* mod controls — only render when we can act on this row */
const acts = document.createElement('span'); acts.className = 'mod-actions';
if (isMod(myRole) && m.uuid !== myUUID){
if (m.role === 'listener'){
const b = document.createElement('button'); b.className='small invert';
const raised = handraise.has(m.uuid);
b.textContent = raised ? 'give the mic' : 'invite mic';
b.onclick = raised
? () => modGrant(m.uuid).catch(e=>logLine('err','grant: '+e.message))
: () => modInvite(m.uuid).catch(e=>logLine('err','invite: '+e.message));
acts.appendChild(b);
}
if (m.role === 'speaker' && myRole === 'host'){
const b = document.createElement('button'); b.className='small';
b.textContent = '→ cohost'; b.onclick = () => modPromote(m.uuid).catch(e=>logLine('err','promote: '+e.message));
acts.appendChild(b);
}
if (m.role === 'speaker'){
const b = document.createElement('button'); b.className='small';
b.textContent = '→ listener'; b.onclick = () => modDemote(m.uuid, 'listener').catch(e=>logLine('err','demote: '+e.message));
acts.appendChild(b);
}
if (m.role === 'cohost' && myRole === 'host'){
const b = document.createElement('button'); b.className='small';
b.textContent = '→ speaker'; b.onclick = () => modDemote(m.uuid, 'speaker').catch(e=>logLine('err','demote: '+e.message));
acts.appendChild(b);
}
/* boot allowed against anyone except host; cohosts also can't boot cohosts (host only) */
if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){
const b = document.createElement('button'); b.className='small';
b.textContent = 'boot'; b.onclick = () => modBoot(m.uuid);
acts.appendChild(b);
}
}
row.appendChild(badge); row.appendChild(handle); row.appendChild(pub);
row.appendChild(micEl); row.appendChild(meter); row.appendChild(acts);
wrap.appendChild(row);
}
/* keep listener UI in sync */
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
$('btn-raise').classList.toggle('hidden', handraise.has(myUUID));
$('btn-lower').classList.toggle('hidden', !handraise.has(myUUID));
}
/* ==================================================================
* listener actions: raise/lower hand, accept/decline mic invite
* ================================================================== */
$('btn-raise').addEventListener('click', () => { send({ type:'raise-hand' }); });
$('btn-lower').addEventListener('click', () => { send({ type:'lower-hand' }); });
/* notice banner — visible callouts for events that affect you directly
* (boot, role change). Auto-clears after 8s for info; stays for warn. */
let noticeTimer = null;
function showNotice(text, kind){
if (noticeTimer){ clearTimeout(noticeTimer); noticeTimer = null; }
$('notice-text').textContent = text;
$('notice-banner').className = 'notice-banner ' + (kind || 'warn');
$('sec-notice').classList.remove('hidden');
if (kind === 'info'){
noticeTimer = setTimeout(()=>{ $('sec-notice').classList.add('hidden'); noticeTimer = null; }, 8000);
}
}
function hideNotice(){
if (noticeTimer){ clearTimeout(noticeTimer); noticeTimer = null; }
$('sec-notice').classList.add('hidden');
}
$('btn-notice-close').addEventListener('click', hideNotice);
$('btn-accept-mic').addEventListener('click', () => {
if (!outstandingInvite) return;
send({ type:'accept-mic', epoch: outstandingInvite.epoch });
outstandingInvite = null;
$('sec-invite').classList.add('hidden');
});
$('btn-decline-mic').addEventListener('click', () => {
if (!outstandingInvite) return;
send({ type:'decline-mic', epoch: outstandingInvite.epoch });
outstandingInvite = null;
$('sec-invite').classList.add('hidden');
});
/* ==================================================================
* mute / mic input / music mode — same shape as zebra-audio but the
* mute applies to all live senders (we may have many).
* ================================================================== */
let muted = false;
$('btn-mute').addEventListener('click', () => {
if (!micStream) return;
muted = !muted;
micStream.getAudioTracks().forEach(t=>t.enabled=!muted);
$('btn-mute').textContent = muted ? 'unmute' : 'mute';
$('btn-mute').className = muted ? 'invert' : '';
/* flip our own mic icon — mic-state broadcasts inform OTHER peers, but
* without this our own row never updates locally */
const mm = members.get(myUUID);
if (mm){ mm.muted = muted; renderRoom(); }
sendMicState();
});
$('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value;
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','input switch failed: '+err.message); await refreshMicList(); } }
});
$('music-mode').addEventListener('change', async (e) => {
musicMode = e.target.checked;
logLine('', 'mic mode: '+(musicMode?'MUSIC':'VOICE'));
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','mic mode switch failed: '+err.message); } }
});
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
navigator.mediaDevices.addEventListener('devicechange', refreshMicList);
}
/* ==================================================================
* leave / entry buttons
* ================================================================== */
$('btn-enter').addEventListener('click', joinSpace);
$('rdv-code').addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); joinSpace(); } });
$('btn-leave').addEventListener('click', async () => {
wantConnected = false;
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
for (const u of [...peers.keys()]) tearPeer(u);
if (ws){ try { ws.close(); } catch(_){} ws = null; }
await sfuUnpublish(); await sfuUnsubscribe();
dropMic();
members.clear(); handraise.clear(); myUUID=''; myRole=''; hostUUID=''; outstandingInvite=null;
$('sec-room').classList.add('hidden');
$('sec-invite').classList.add('hidden');
$('sec-listener-actions').classList.add('hidden');
hideNotice();
$('dot-call').className='dot warn';
setStatus('left', null);
$('btn-enter').disabled = false;
$('btn-leave').disabled = true;
$('btn-mute').disabled = true;
$('btn-mute').textContent = 'mute'; $('btn-mute').className=''; muted = false;
logLine('', 'left the space');
});
logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
})();
</script>
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-05-31</span><br>
md5 <span class="stamp-md5">acd638938338e18678254c89233f5964</span><br>
sha256 <span class="stamp-sha">d34f0432b7ae1eb209b45baf9954279c2098cefee9625b988223b1101f3897f2</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>
</body>
</html>