The grid was packing badge | handle | pubkey | mic | meter | buttons on one line, which got crowded with three or four actions visible. Drop mod-actions to a second grid row spanning the full width, indented under the handle column. Hides itself entirely when empty so rows with nothing to act on don't reserve dead space.
1030 lines
46 KiB
HTML
1030 lines
46 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: left = timeline (blank for now, ~50%), right = controls.
|
|
* stacks below 800px so the controls stay usable on a phone. */
|
|
.page {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2rem;
|
|
align-items: start;
|
|
}
|
|
.timeline {
|
|
border-right: 1px solid #ddd; padding-right: 2rem; min-height: 60vh;
|
|
}
|
|
.timeline-placeholder {
|
|
font-size: 0.8rem; color: #999; padding: 1rem 0; line-height: 1.6;
|
|
}
|
|
@media (max-width: 800px) {
|
|
body { padding: 1rem; }
|
|
.page { grid-template-columns: 1fr; gap: 1rem; }
|
|
.timeline { border-right: none; border-bottom: 1px solid #ddd;
|
|
padding-right: 0; padding-bottom: 1rem; min-height: 0; }
|
|
}
|
|
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; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
|
}
|
|
.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 · webrtc · rendezvous ·
|
|
<a href="zebra-audio.html">1:1 call</a> ·
|
|
<a href="host-your-own.html">host your own</a> ·
|
|
<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">
|
|
<strong>you're invited to the mic.</strong>
|
|
<span id="invite-from" class="note"></span>
|
|
<button id="btn-accept-mic" class="invert small">accept</button>
|
|
<button id="btn-decline-mic" class="small">decline</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=>({'&':'&','<':'<','>':'>'}[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));
|
|
}
|
|
|
|
/* ==================================================================
|
|
* 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 */
|
|
const ns = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
|
|
const nt = ns.getAudioTracks()[0];
|
|
tagTrack(nt); nt.enabled = !muted;
|
|
for (const [uuid, pc] of peers){
|
|
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); }
|
|
}
|
|
if (micStream) micStream.getTracks().forEach(t=>t.stop());
|
|
micStream = ns;
|
|
}
|
|
function meterFor(stream, elId){
|
|
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
|
|
const src = audioCtx.createMediaStreamSource(stream);
|
|
const an = audioCtx.createAnalyser(); an.fftSize = 512;
|
|
src.connect(an);
|
|
const buf = new Uint8Array(an.fftSize);
|
|
const fill = typeof elId==='string' ? $(elId) : elId;
|
|
(function tick(){
|
|
if (!fill || !document.contains(fill)) return; /* meter removed with member row */
|
|
an.getByteTimeDomainData(buf);
|
|
let peak=0; for (let i=0;i<buf.length;i++){ const v=Math.abs(buf[i]-128)/128; if(v>peak)peak=v; }
|
|
fill.style.width = Math.min(100, Math.round(peak*180))+'%';
|
|
requestAnimationFrame(tick);
|
|
})();
|
|
}
|
|
|
|
/* ==================================================================
|
|
* 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); 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);
|
|
/* 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');
|
|
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);
|
|
if (mm) logLine('', mm.handle+' was removed by '+(members.get(m.by)?members.get(m.by).handle:'a mod'));
|
|
members.delete(m.uuid); tearPeer(m.uuid); handraise.delete(m.uuid);
|
|
if (m.uuid === myUUID){
|
|
/* we got booted — connection will be closed by server */
|
|
logLine('err','you were removed from this space');
|
|
}
|
|
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'); }
|
|
logLine('', (mm.handle||shortHex(m.new_host_uuid))+' is now host'); }
|
|
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. */
|
|
async function onRoleEntered(){
|
|
if (canSpeak(myRole)) await ensureMicAndUI();
|
|
else updateRoleUI();
|
|
/* fan-out PCs to all current speakers we should mesh with */
|
|
if (canSpeak(myRole)){
|
|
for (const [uuid, mm] of members){
|
|
if (uuid === myUUID) continue;
|
|
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
|
|
}
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
} else if (canSpeak(prev) && !canSpeak(next)){
|
|
/* demoted to listener — tear all PCs, drop the mic, switch UI */
|
|
for (const u of [...peers.keys()]) tearPeer(u);
|
|
dropMic(); muted = false;
|
|
}
|
|
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;
|
|
} catch(e){ logLine('err','mic blocked: '+e.message); }
|
|
updateRoleUI();
|
|
}
|
|
function dropMic(){
|
|
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]);
|
|
try {
|
|
const m = members.get(uuid);
|
|
if (m && m._meterEl) meterFor(a.srcObject, m._meterEl);
|
|
} catch(_){}
|
|
};
|
|
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){
|
|
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 });
|
|
}
|
|
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';
|
|
b.textContent = handraise.has(m.uuid) ? 'give the mic' : 'invite mic';
|
|
b.onclick = () => 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' }); });
|
|
$('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' : '';
|
|
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', () => {
|
|
wantConnected = false;
|
|
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
|
for (const u of [...peers.keys()]) tearPeer(u);
|
|
if (ws){ try { ws.close(); } catch(_){} ws = null; }
|
|
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');
|
|
$('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> · built <span class="stamp-date">2026-05-31</span><br>
|
|
md5 <span class="stamp-md5">c5bb52fca1002251e888aa7effa2cf91</span><br>
|
|
sha256 <span class="stamp-sha">d1082ceeb95d3c616a63d522922e919b60cc7bfef9bb5bc49921008b75064310</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>
|