zebra-report/web/zebra-spaces.html
Russell Ballestrini 54c03beec3
zebra-spaces: real dark mode — explicit overrides, pure black bg, low light emission
Replaced the filter:invert approach (which produced muddy mid-tones and
left the html canvas + scrollbar areas flashing white) with explicit
.theme-dark overrides for every painted surface:

- bg pure #000 on html + body — no light emission outside content
- text #ccc (dim, easy on eyes; not glaring #fff)
- borders #222-#444 (visible but not loud)
- inputs/textarea #0a0a0a — sit a hair above pure black
- buttons inverted: button.invert (primary) is #ccc-on-black,
  regular buttons are #000-with-#ccc-text-and-#444-border
- badges, dots, meters re-coloured for dark contrast
- latency green/yellow/red shifted toward higher-luminance variants
  so they stay readable on black
- log + notice banners get dark-tinted backgrounds matching their kind
- video/camera tiles unchanged — they were already dark and look fine

Video pixels never get filtered now, so screen-share + camera streams
render their actual colours instead of being inverted.
2026-06-01 21:58:01 -04:00

2183 lines
104 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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: 1rem 1.5rem; max-width: none; margin: 0 auto;
}
/* Dark mode = pure black canvas, dim text, low total light emission.
* Explicit overrides instead of filter:invert so the page looks
* intentional in both modes rather than mathematically flipped. */
html.theme-dark { background: #000; }
html.theme-dark body { background: #000; color: #ccc; }
html.theme-dark a { color: #9cf; }
html.theme-dark .sub, html.theme-dark .sub a, html.theme-dark .note,
html.theme-dark .status-line { color: #888; }
html.theme-dark .timeline { border-right-color: #222; }
html.theme-dark .timeline-frame { background: #000; border-color: #222; }
/* buttons + inputs */
html.theme-dark button,
html.theme-dark .game-tabs button,
html.theme-dark .theme-toggle {
background: #000; color: #ccc; border-color: #444;
}
html.theme-dark button:hover:not(:disabled),
html.theme-dark .game-tabs button:hover:not(.active),
html.theme-dark .theme-toggle:hover { background: #1a1a1a; }
html.theme-dark button.invert { background: #ccc; color: #000; border-color: #ccc; }
html.theme-dark button.invert:hover:not(:disabled) { background: #999; }
html.theme-dark .game-tabs button.active { background: #ccc; color: #000; border-color: #ccc; }
html.theme-dark input[type=text],
html.theme-dark input[type=password],
html.theme-dark textarea,
html.theme-dark select {
background: #0a0a0a; color: #ccc; border-color: #333;
}
html.theme-dark .dot { background: #000; border-color: #444; }
html.theme-dark .dot.ok { background: #0a0; border-color: #0a0; }
html.theme-dark .meter { background: #000; border-color: #444; }
html.theme-dark .meter-fill { background: #ccc; }
/* badges */
html.theme-dark .badge.host { background: #ccc; color: #000; border-color: #ccc; }
html.theme-dark .badge.cohost { background: #666; color: #000; border-color: #666; }
html.theme-dark .badge.speaker { background: #000; color: #ccc; border-color: #ccc; }
html.theme-dark .badge.listener{ background: #000; color: #888; border-color: #444; }
html.theme-dark .pub-short { color: #777; }
html.theme-dark .handle .me { color: #6c6; }
html.theme-dark .raised { color: #f66; }
/* log */
html.theme-dark .log { background: #050505; border-color: #333; }
html.theme-dark .log-line .ts { color: #666; }
html.theme-dark .log-line.err { color: #f66; }
html.theme-dark .status-line.ok { color: #6c6; }
html.theme-dark .status-line.err { color: #f66; }
/* notice banners */
html.theme-dark .notice-banner { background: #1a1a00; border-color: #553; color: #ccc; }
html.theme-dark .notice-banner.warn { background: #1a0000; border-color: #500; color: #f66; }
html.theme-dark .notice-banner.info { background: #001a00; border-color: #050; color: #6c6; }
/* latency rows */
html.theme-dark .lat-row { border-bottom-color: #1a1a1a; }
html.theme-dark .lat-row .lat-name { color: #aaa; }
html.theme-dark .lat-row .lat-loss::before,
html.theme-dark .lat-row .lat-jit::before { color: #666; }
html.theme-dark .lat-row .lat-path { color: #666; }
html.theme-dark .lat-row.lat-good .lat-rtt, html.theme-dark .lat-row.lat-good .lat-loss, html.theme-dark .lat-row.lat-good .lat-jit { color: #6c6; }
html.theme-dark .lat-row.lat-mid .lat-rtt, html.theme-dark .lat-row.lat-mid .lat-loss, html.theme-dark .lat-row.lat-mid .lat-jit { color: #ec0; }
html.theme-dark .lat-row.lat-bad .lat-rtt, html.theme-dark .lat-row.lat-bad .lat-loss, html.theme-dark .lat-row.lat-bad .lat-jit { color: #f66; }
html.theme-dark .lat-row.lat-na .lat-rtt, html.theme-dark .lat-row.lat-na .lat-loss, html.theme-dark .lat-row.lat-na .lat-jit { color: #555; }
/* vault */
html.theme-dark .vault-panel { border-color: #444; }
/* page-integrity footer */
html.theme-dark footer { color: #555 !important; }
html.theme-dark footer span { color: #555 !important; }
html.theme-dark footer a { color: #777 !important; }
.theme-toggle {
position: fixed; top: 0.6rem; right: 0.8rem; z-index: 9999;
background: #fff; color: #000; border: 1px solid #000;
padding: 0.2rem 0.55rem; font-family: monospace; font-size: 0.7rem;
cursor: pointer; line-height: 1;
}
.theme-toggle:hover { background: #f0f0f0; }
/* two-column page on desktop: left = timeline (1fr, takes remaining
* space — hosts screen shares + the running event feed), right =
* controls (kept narrow so the screen share gets maximum pixels).
* 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, 360px);
gap: 1.25rem;
align-items: start;
}
.timeline {
border-right: 1px solid #ddd; padding-right: 1.25rem; min-height: 60vh;
min-width: 0; display: flex; flex-direction: column;
}
.controls { min-width: 0; }
.game-tabs { display: flex; gap: 0.4rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
.game-tabs button {
background: #fff; color: #000; border: 1px solid #000;
padding: 0.3rem 0.8rem; font-family: monospace; font-size: 0.8rem; cursor: pointer;
}
.game-tabs button.active { background: #000; color: #fff; }
.game-tabs button:hover:not(.active) { background: #f0f0f0; }
.timeline-frame {
flex: 1; min-height: 80vh; width: 100%;
border: 1px solid #ddd; background: #fff;
}
@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; }
/* screen-share tiles render in the LEFT (timeline) column at full width.
* When sec-screens is shown the game tabs + iframe are hidden so the
* screen owns the column. */
#screens { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
.screen-tile {
border: 1px solid #000; background: #000; padding: 0;
display: flex; flex-direction: column; position: relative;
}
.screen-tile video {
width: 100%; height: auto; max-height: 92vh; display: block; background: #000;
}
.screen-tile .screen-meta {
background: #111; color: #ddd; font-size: 0.7rem; padding: 0.3rem 0.5rem;
display: flex; justify-content: space-between;
}
/* Firefox Android rejects MediaStream <video> autoplay even when muted.
* When play() rejects we surface a tap-to-play overlay over the video
* area only (the meta bar stays clickable); the click counts as the
* gesture so the retry play() succeeds. */
.screen-tile .tap-play {
position: absolute; left: 0; right: 0; top: 0; bottom: 1.6rem;
display: none; align-items: center; justify-content: center;
background: rgba(0,0,0,0.65); color: #fff; cursor: pointer;
font-family: monospace; font-size: 1rem; user-select: none;
}
.screen-tile.needs-tap .tap-play { display: flex; }
/* sec-screens visible → push everything else in the timeline column down or hide */
.timeline:has(> #sec-screens:not(.hidden)) > .game-tabs,
.timeline:has(> #sec-screens:not(.hidden)) > #game-frame { display: none; }
/* camera tiles: smaller, multi-column grid so several face-cams fit
* side-by-side without dwarfing the screen share above them */
#cameras { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.5rem; }
.camera-tile {
border: 1px solid #000; background: #000; padding: 0;
display: flex; flex-direction: column; position: relative;
}
.camera-tile video {
width: 100%; height: auto; max-height: 40vh; display: block; background: #000;
aspect-ratio: 16 / 9; object-fit: cover;
}
.camera-tile .screen-meta {
background: #111; color: #ddd; font-size: 0.7rem; padding: 0.2rem 0.4rem;
display: flex; justify-content: space-between;
}
/* tap-to-play overlay applies to camera tiles too */
.camera-tile .tap-play {
position: absolute; left: 0; right: 0; top: 0; bottom: 1.2rem;
display: none; align-items: center; justify-content: center;
background: rgba(0,0,0,0.65); color: #fff; cursor: pointer;
font-family: monospace; font-size: 0.9rem; user-select: none;
}
.camera-tile.needs-tap .tap-play { display: flex; }
/* latency panel: one row per live PC, fixed-width columns so the numbers
* line up vertically as values bounce. lat-good < 50ms, mid < 150ms,
* bad ≥ 150ms — colour-tagged so the user can scan at a glance. */
.latency-rows { display: grid; gap: 0.18rem; font-size: 0.78rem; margin-bottom: 0.4rem; }
.lat-row {
display: grid; grid-template-columns: 1fr 3.6rem 2.6rem 2.6rem 2.6rem; gap: 0.35rem;
align-items: baseline; padding: 0.12rem 0;
border-bottom: 1px dotted #eee; font-family: monospace;
}
.lat-row .lat-name { color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lat-row .lat-rtt, .lat-row .lat-loss, .lat-row .lat-jit { text-align: right; font-weight: bold; }
.lat-row .lat-loss::before { content: 'loss '; color: #888; font-weight: normal; font-size: 0.65rem; }
.lat-row .lat-jit::before { content: 'jit '; color: #888; font-weight: normal; font-size: 0.65rem; }
.lat-row .lat-path { text-align: right; color: #888; font-size: 0.7rem; text-transform: uppercase; }
.lat-row.lat-good .lat-rtt, .lat-row.lat-good .lat-loss, .lat-row.lat-good .lat-jit { color: #060; }
.lat-row.lat-mid .lat-rtt, .lat-row.lat-mid .lat-loss, .lat-row.lat-mid .lat-jit { color: #b80; }
.lat-row.lat-bad .lat-rtt, .lat-row.lat-bad .lat-loss, .lat-row.lat-bad .lat-jit { color: #b00; }
.lat-row.lat-na .lat-rtt, .lat-row.lat-na .lat-loss, .lat-row.lat-na .lat-jit { color: #999; }
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>
<script>
/* apply theme before first paint to avoid the brief white flash that
* would happen if we waited for the deferred main script */
try {
if (localStorage.getItem('zebra-spaces-theme-v1') === 'dark'){
document.documentElement.classList.add('theme-dark');
}
} catch(_){}
</script>
</head>
<body>
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">dark</button>
<div class="page">
<main class="timeline">
<!-- shared-screen tiles take priority over the game iframe. When sec-screens
is visible (someone is sharing) the game tabs + iframe get hidden via
CSS so the screen has the full left column. -->
<section id="sec-screens" class="hidden">
<h2>screens</h2>
<div id="screens"></div>
</section>
<section id="sec-cameras" class="hidden">
<h2>cameras</h2>
<div id="cameras"></div>
</section>
<!-- placeholder content until the v0.3 event timeline lands: a game switcher
framing an external playable. Iframes load unsandboxed so the game JS can
run; if a target refuses framing (X-Frame-Options / CSP frame-ancestors)
the iframe goes blank — no impact on the controls column. -->
<div class="game-tabs" id="game-tabs">
<button type="button" class="active" data-game="unmario"
data-src="https://unmario.com/">unmario</button>
<button type="button" data-game="cake"
data-src="https://cuppcb.com/games/cake-murder-adventure/">cake murder adventure</button>
</div>
<iframe id="game-frame" class="timeline-frame"
src="https://unmario.com/"
title="game"
loading="lazy"
referrerpolicy="no-referrer"></iframe>
</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>
<button id="btn-logout" class="small">log out</button>
<span class="note">key lives in this browser only.</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">join as listener; host promotes to mic. end-to-end encrypted.</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-screen-share" class="hidden">
<h2>share</h2>
<div class="row">
<button id="btn-screen-share" class="invert">share screen</button>
<button id="btn-screen-stop" class="hidden">stop sharing</button>
<span class="note">window or tab; tick "share audio" if offered.</span>
</div>
<div class="row" style="margin-top:0.4rem">
<button id="btn-camera-share" class="invert">share camera</button>
<button id="btn-camera-stop" class="hidden">stop camera</button>
<select id="camera-select" title="camera device — applies on next start"><option value="">default camera</option></select>
</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 id="sec-latency" class="hidden">
<h2>latency</h2>
<div id="latency-rows" class="latency-rows"></div>
<span class="note">RTT from <code>candidate-pair.currentRoundTripTime</code> + path kind (host = LAN, srflx = direct WAN, relay = through TURN). Updates every 2s.</span>
</section>
<section id="sec-share" class="hidden">
<h2>link</h2>
<p class="note" style="margin-bottom:0.5rem">share only with people you trust to hear the room.</p>
<div class="row">
<input type="text" id="share-url" readonly>
<button id="btn-copy-share" class="small">copy</button>
</div>
<div id="qr-host" class="row" style="display:none">
<canvas id="share-qr" width="220" height="220" aria-label="QR code for this space"></canvas>
</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 MUSIC_MODE_KEY = 'zebra-spaces-music-mode-v1';
const MIC_DEV_KEY = 'zebra-spaces-mic-device-v1';
const CAM_DEV_KEY = 'zebra-spaces-cam-device-v1';
const THEME_KEY = 'zebra-spaces-theme-v1';
/* theme toggle. The class is already applied pre-paint by the head
* script, so this just wires the click + keeps the button label in
* sync with the current state. */
function applyThemeLabel(){
const dark = document.documentElement.classList.contains('theme-dark');
const btn = $('btn-theme'); if (btn) btn.textContent = dark ? 'light' : 'dark';
}
applyThemeLabel();
$('btn-theme').addEventListener('click', () => {
const root = document.documentElement;
const goDark = !root.classList.contains('theme-dark');
root.classList.toggle('theme-dark', goDark);
try { localStorage.setItem(THEME_KEY, goDark ? 'dark' : 'light'); } catch(_){}
applyThemeLabel();
});
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'));
/* log out — destructive: wipes Ed25519 + handle from localStorage and
* generates a fresh identity. The booted/blocked window keys off the
* pubkey, so logging out is also the escape hatch from a room block.
* The user keeps the same browser so we generate a new key right away;
* otherwise the page would refuse to enter any space (no identity). */
$('btn-logout').addEventListener('click', async () => {
if (ws){ setStatus('leave the space first','err'); return; }
if (!confirm('Log out wipes your identity key from this browser. ' +
'If you havent backed it up you cannot recover it. Continue?')) return;
try { localStorage.removeItem(ID_KEY); localStorage.removeItem(HANDLE_KEY); } catch(_){}
myHandle = ''; $('handle').value = '';
myKeys = await generateIdentity();
renderIdentity();
logLine('', 'logged out — fresh identity '+shortHex(myKeys.pubHex));
});
$('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;
/* preferences that should outlive a reload — music-mode toggle and the
* last-picked mic + camera deviceIds. They're applied here before any
* device enumeration / mic acquisition so the very first getUserMedia
* call uses the right device + constraints. */
try { musicMode = localStorage.getItem(MUSIC_MODE_KEY) === '1'; } catch(_){}
try { micDeviceId = localStorage.getItem(MIC_DEV_KEY) || ''; } catch(_){}
try { cameraDeviceId = localStorage.getItem(CAM_DEV_KEY) || ''; } catch(_){}
if ($('music-mode')) $('music-mode').checked = musicMode;
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;
let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
let sfuCameraPC = null, sfuCameraPeerID = null, sfuCameraStream = null, cameraDeviceId = '';
/* incoming screen/camera streams keyed by publisher pubkey hex (== streamID).
* Cleared when the corresponding publisher leaves (peer-left or boot). */
const screenStreams = new Map(); // pubHex -> MediaStream
const screenVideos = new Map(); // pubHex -> { tile, video }
const cameraStreams = new Map(); // pubHex -> MediaStream
const cameraVideos = new Map(); // pubHex -> { tile, video }
/* 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(_){}
}
}
}
/* shared video-tile renderer for screens + cameras. The two kinds share
* the autoplay + tap-to-play + unmute machinery; only the DOM target
* (container + tile class), label prefix, and backing Map differ.
* Removed on peer-left / boot / SFU track removal via removeVideoTile. */
const TILE_KINDS = {
screen: { container:'screens', section:'sec-screens', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
camera: { container:'cameras', section:'sec-cameras', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
};
function renderVideoTile(kind, pubHex, stream, opts){
const k = TILE_KINDS[kind]; if (!k) return;
const local = !!(opts && opts.local);
/* find the matching member for the title; self isn't in members map */
let label = shortHex(pubHex);
if (local){
label = (myHandle || label) + ' (you)';
} else {
for (const [, mm] of members){
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ label = mm.handle || label; break; } } catch(_){}
}
}
let entry = k.store.get(pubHex);
if (!entry){
const tile = document.createElement('div'); tile.className = k.tileClass;
const video = document.createElement('video');
/* set BOTH the HTML attributes and the IDL properties — Firefox Android
* checks the attribute when deciding autoplay eligibility, not just the
* .muted property. */
video.setAttribute('autoplay', '');
video.setAttribute('playsinline', '');
video.setAttribute('muted', '');
video.autoplay = true; video.playsInline = true; video.muted = true;
const tap = document.createElement('div'); tap.className = 'tap-play';
tap.textContent = 'tap to play';
tap.onclick = () => {
try { video.play().then(() => { tile.classList.remove('needs-tap'); })
.catch(e => logLine('err','play after tap: '+e.message)); } catch(_){}
};
const meta = document.createElement('div'); meta.className = 'screen-meta';
const who = document.createElement('span'); who.textContent = k.labelPrefix+': '+label;
const ctl = document.createElement('span');
const pop = document.createElement('button'); pop.className = 'small';
pop.textContent = 'fullscreen'; pop.onclick = () => video.requestFullscreen && video.requestFullscreen().catch(()=>{});
ctl.appendChild(pop);
meta.appendChild(who); meta.appendChild(ctl);
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
$(k.container).appendChild(tile);
entry = { tile, video, label };
k.store.set(pubHex, entry);
}
entry.video.srcObject = stream;
try {
const p = entry.video.play();
if (p && p.then){
p.then(() => { entry.tile.classList.remove('needs-tap'); })
.catch(e => {
logLine('err','autoplay blocked: '+e.message+' — tap the tile to play');
entry.tile.classList.add('needs-tap');
});
}
} catch(e){
logLine('err','play threw: '+e.message);
entry.tile.classList.add('needs-tap');
}
$(k.section).classList.remove('hidden');
logLine('', k.labelPrefix+' share: '+(local?'local preview ':'receiving ')+label);
}
function removeVideoTile(kind, pubHex){
const k = TILE_KINDS[kind]; if (!k) return;
const entry = k.store.get(pubHex);
if (!entry) return;
try { entry.video.srcObject = null; entry.tile.remove(); } catch(_){}
k.store.delete(pubHex);
k.streams.delete(pubHex);
if (k.store.size === 0) $(k.section).classList.add('hidden');
}
/* back-compat shims — keep the old names callable so the rest of the
* file doesn't have to be rewritten in one shot */
function renderScreenTile(pubHex, stream, opts){ return renderVideoTile('screen', pubHex, stream, opts); }
function removeScreenTile(pubHex){ return removeVideoTile('screen', pubHex); }
function renderCameraTile(pubHex, stream, opts){ return renderVideoTile('camera', pubHex, stream, opts); }
function removeCameraTile(pubHex){ return removeVideoTile('camera', pubHex); }
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'));
const offer = await pc.createOffer();
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
await pc.setLocalDescription(offer);
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.status === 403){ pc.close(); handleBlocked('publish'); return; }
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));
}
/* ----- screen share ----- */
async function sfuPublishScreen(){
if (sfuScreenPC || !myKeys || !roomID) return;
let stream;
try {
/* broadcast-quality capture: 1080p30 video, raw stereo 48kHz audio.
* Browsers treat these as 'ideal' — if a window is smaller it downscales
* gracefully; nothing is rejected. The constraint matters for the audio
* side: without channelCount:2 + sampleRate:48000, getDisplayMedia on
* Chrome can hand back mono 16kHz, which kills music quality. */
stream = await navigator.mediaDevices.getDisplayMedia({
video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} },
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
channelCount:2, sampleRate:48000 }
});
} catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
sfuScreenStream = stream;
const vTracks = stream.getVideoTracks(), aTracks = stream.getAudioTracks();
logLine('', 'screen capture: '+vTracks.length+' video + '+aTracks.length+' audio track(s)');
if (aTracks.length === 0){
/* Firefox getDisplayMedia never captures tab/window audio — only the
* 'entire screen' source carries system audio, and only on some
* platforms. Chrome captures tab audio when the user ticks 'share
* audio' in the picker. Make the limitation visible instead of
* silently broadcasting video-only. */
const ua = navigator.userAgent;
const ff = /Firefox/.test(ua);
logLine('err','no audio captured — '+(ff
? 'Firefox getDisplayMedia ignores tab/window audio. Share "entire screen" with system audio, or stream the audio via mic music mode (toggle music mode + route the tab through your mic).'
: 'tick the "share tab/system audio" checkbox in the picker, or use music-mode mic.'));
}
const pc = new RTCPeerConnection(rtcConfig);
for (const tr of stream.getTracks()){
if (tr.kind === 'video') tr.contentHint = 'detail'; /* favour pixel fidelity over framerate */
if (tr.kind === 'audio') tr.contentHint = 'music';
pc.addTrack(tr, stream);
}
/* the user can stop the share from the browser's native "stop sharing"
* bar — propagate that into a clean unpublish */
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishScreen(); });
const offer = await pc.createOffer();
/* screen-share audio is always music-grade — system audio capture is
* what users actually broadcast, not voice */
offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true });
await pc.setLocalDescription(offer);
await waitForIceGathering(pc);
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
+ '&pub=' + myKeys.pubHex + '&kind=screen';
let res;
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw e; }
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; handleBlocked('publish-screen'); return; }
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw new Error('sfu publish-screen http '+res.status); }
const ans = await res.json();
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
/* raise the RTP-level caps: 6 Mbps for video (high-detail 1080p screen),
* 256 kbps for audio (transparent stereo Opus). The codec-level cap was
* already raised via preferStereoOpus(). */
for (const s of pc.getSenders()){
if (!s.track) continue;
if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
}
logLine('', 'sfu: sharing screen as '+shortHex(sfuScreenPeerID));
/* render a muted local preview so the publisher sees what they're
* sharing — SFU does not echo the publisher's own stream back */
renderScreenTile(myKeys.pubHex, stream, { local: true });
$('btn-screen-share').classList.add('hidden');
$('btn-screen-stop').classList.remove('hidden');
}
async function sfuUnpublishScreen(){
if (!sfuScreenPC && !sfuScreenStream) return;
const pid = sfuScreenPeerID;
if (myKeys) removeScreenTile(myKeys.pubHex);
if (sfuScreenStream){ sfuScreenStream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; }
if (sfuScreenPC){ try { sfuScreenPC.close(); } catch(_){} sfuScreenPC = null; sfuScreenPeerID = null; }
if (pid && roomID){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
}
$('btn-screen-share').classList.remove('hidden');
$('btn-screen-stop').classList.add('hidden');
logLine('', 'screen share stopped');
}
/* ----- camera publish (kind=camera) ----- */
async function sfuPublishCamera(){
if (sfuCameraPC || !myKeys || !roomID) return;
let stream;
const videoConstraints = { width:{ideal:1280}, height:{ideal:720}, frameRate:{ideal:30} };
if (cameraDeviceId) videoConstraints.deviceId = { exact: cameraDeviceId };
try {
/* camera only — mic comes through the separate sfuPublish path so a
* speaker can choose camera-on while still using a different audio
* input (monitor source, music mode, etc) */
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
} catch(e){ logLine('err','camera open cancelled: '+e.message); return; }
sfuCameraStream = stream;
const pc = new RTCPeerConnection(rtcConfig);
for (const tr of stream.getTracks()){
if (tr.kind === 'video') tr.contentHint = 'motion'; /* face/scene cam = motion over detail */
pc.addTrack(tr, stream);
}
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishCamera(); });
await pc.setLocalDescription(await pc.createOffer());
await waitForIceGathering(pc);
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
+ '&pub=' + myKeys.pubHex + '&kind=camera';
let res;
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw e; }
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; handleBlocked('publish-camera'); return; }
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw new Error('sfu publish-camera http '+res.status); }
const ans = await res.json();
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
sfuCameraPC = pc; sfuCameraPeerID = ans.peer_id;
/* 1.5 Mbps is plenty for 720p30 face cam — keeps the screen-share
* headroom intact when both are live */
for (const s of pc.getSenders()){
if (s.track && s.track.kind === 'video') setSenderMaxBitrate(s, 1500000);
}
logLine('', 'sfu: camera on as '+shortHex(sfuCameraPeerID));
renderCameraTile(myKeys.pubHex, stream, { local: true });
$('btn-camera-share').classList.add('hidden');
$('btn-camera-stop').classList.remove('hidden');
}
async function sfuUnpublishCamera(){
if (!sfuCameraPC && !sfuCameraStream) return;
const pid = sfuCameraPeerID;
if (myKeys) removeCameraTile(myKeys.pubHex);
if (sfuCameraStream){ sfuCameraStream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; }
if (sfuCameraPC){ try { sfuCameraPC.close(); } catch(_){} sfuCameraPC = null; sfuCameraPeerID = null; }
if (pid && roomID){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
}
$('btn-camera-share').classList.remove('hidden');
$('btn-camera-stop').classList.add('hidden');
logLine('', 'camera off');
}
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 sid = ev.streams[0] ? ev.streams[0].id : '';
if (!sid) return;
/* streamID format: PUBKEY (mic) | PUBKEY:screen | PUBKEY:camera.
* Route video kinds to the tile renderer; mics to the audio path. */
const colon = sid.indexOf(':');
if (colon > 0){
const pubHex = sid.slice(0, colon);
const kind = sid.slice(colon + 1);
/* skip echo of our own publish — we already render a local preview */
if (myKeys && pubHex === myKeys.pubHex) return;
/* video kinds: when the publisher unshares, the SFU stops the
* transceiver and the corresponding remote track fires 'ended'.
* Wire that to the tile remover so the listener's UI matches the
* publisher's state instead of holding a frozen last frame. */
if (kind === 'screen'){
screenStreams.set(pubHex, ev.streams[0]);
renderScreenTile(pubHex, ev.streams[0]);
ev.track.addEventListener('ended', () => removeScreenTile(pubHex));
return;
}
if (kind === 'camera'){
cameraStreams.set(pubHex, ev.streams[0]);
renderCameraTile(pubHex, ev.streams[0]);
ev.track.addEventListener('ended', () => removeCameraTile(pubHex));
return;
}
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
return;
}
/* mic audio — 400ms jitter-buffer target. Mobile/Wi-Fi peak inter-
* arrival is 3-5x the smoothed jitter reported by getStats, so a
* smoothed 50ms means 150-250ms peaks. 400ms absorbs that and feels
* fine for music broadcasts; conversation gains ~quarter-second of
* delay which is well below noticeable. */
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
const pubHex = sid;
/* 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. */
/* pass our pubkey as `sub` so the SFU can evict us if we're booted —
* server-side boot also calls SFU /internal/block which kicks any
* subscriber whose sub pubkey matches */
const subUrl = SFU_BASE + '/subscribe?room=' + encodeURIComponent(roomID) + '&sub=' + myKeys.pubHex;
const offerRes = await fetch(subUrl, {
method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'
});
if (offerRes.status === 403){ pc.close(); handleBlocked('subscribe'); return; }
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(){
/* music mode = high-fidelity broadcast: stereo, raw, 48kHz so we can
* push 256kbps Opus and let Opus's stereo modes carry music properly.
* voice mode stays mono + the three cleanups so speech is intelligible. */
const base = musicMode
? { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
channelCount:2, sampleRate:48000, sampleSize:16 }
: { 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'; }
/* Firefox sometimes ignores the EC/NS/AGC constraints at getUserMedia time
* for non-mic sources (e.g. PulseAudio monitor) and applies its default
* processing pipeline anyway. applyConstraints() after the fact tends to
* stick. Log the actual settings so we can see what the UA ended up with —
* silent disagreement between requested and effective constraints is what
* makes music-mode-on-a-monitor-source sound 'cleaned up'. */
async function enforceMicConstraints(track){
if (!track) return;
try { await track.applyConstraints(micConstraints()); } catch(e){ logLine('', 'applyConstraints rejected: '+e.message); }
try {
const s = track.getSettings();
logLine('', 'mic track settings: '+JSON.stringify({
ec: s.echoCancellation, ns: s.noiseSuppression, agc: s.autoGainControl,
ch: s.channelCount, hz: s.sampleRate, dev: (s.deviceId||'').slice(0,8)
}));
} catch(_){}
}
async function getMic(){
if (micStream) return micStream;
micStream = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
const t = micStream.getAudioTracks()[0];
tagTrack(t); await enforceMicConstraints(t);
return micStream;
}
async function setSenderBitrate(sender){
if (!sender) return;
try {
const p = sender.getParameters();
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
/* 256 kbps stereo Opus is roughly transparent for music; 40 kbps mono is
* plenty for speech */
p.encodings[0].maxBitrate = musicMode ? 256000 : 40000;
await sender.setParameters(p);
} catch(_){}
}
/* explicit bitrate setter for non-mic senders (screen video, screen audio).
* setSenderBitrate above is locked to the mic's musicMode value. */
async function setSenderMaxBitrate(sender, bps){
if (!sender) return;
try {
const p = sender.getParameters();
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
p.encodings[0].maxBitrate = bps;
await sender.setParameters(p);
} catch(_){}
}
/* munge the offer SDP so Opus negotiates stereo + a high maxaveragebitrate.
* Browsers omit stereo=1 unless they're sure the track is stereo, and the
* codec-level maxaveragebitrate cap (separate from RTP-level maxBitrate)
* has to be raised explicitly for music to actually use the headroom. */
function preferStereoOpus(sdp, maxAvgBps, opts){
/* DTX is great for voice (silence is silence) but its comfort-noise
* transitions audibly pop on continuous music signals — keep it OFF
* in music mode and ON for voice. */
const dtx = !(opts && opts.music) ? '1' : '0';
return sdp.replace(/a=fmtp:(\d+) ([^\r\n]*minptime=10[^\r\n]*)/g, (m, pt, fmtp) => {
/* useinbandfec=1: forward error correction so a single dropped
* packet doesn't audibly chop — Opus reconstructs from FEC. */
const want = {
'stereo': '1', 'sprop-stereo': '1',
'maxaveragebitrate': String(maxAvgBps),
'useinbandfec': '1', 'usedtx': dtx,
};
const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean);
const seen = new Set();
for (let i = 0; i < parts.length; i++){
const k = parts[i].split('=')[0];
seen.add(k);
if (want[k] !== undefined) parts[i] = k + '=' + want[k];
}
for (const k of Object.keys(want)) if (!seen.has(k)) parts.push(k + '=' + want[k]);
return 'a=fmtp:' + pt + ' ' + parts.join(';');
});
}
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;
await enforceMicConstraints(nt);
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);
/* SFU PC needs a full renegotiation — replaceTrack alone doesn't change
* the negotiated Opus fmtp (stereo/maxaveragebitrate), so a mono publish
* keeps emitting mono even after we swap in a stereo track. Tear down
* and re-publish so the new SDP carries the music-mode codec params. */
if (sfuPubPC){
await sfuUnpublish();
}
if (micStream) micStream.getTracks().forEach(t=>t.stop());
micStream = ns;
if (myRole && canSpeak(myRole)){
sfuPublish().catch(e => logLine('err','sfu re-publish: '+e.message));
}
/* 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; }
/* fresh attempt — clear any previous terminal-block state from an
* earlier session in a different room */
blocked = false;
hideNotice();
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');
renderShareUrl();
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');
/* if they were sharing screen or camera, drop the tiles */
if (left && left.pubkey){
try { const h = hex(unb64(left.pubkey)); removeScreenTile(h); removeCameraTile(h); } catch(_){}
}
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. Tear
* down our SFU + mesh PCs too so we actually stop hearing /
* broadcasting — closing the WS alone leaves the WebRTC paths up. */
wantConnected = false;
showNotice('You were removed from this space'+(by?' by '+by.handle:'')+'.', 'warn');
logLine('err','you were removed from this space');
for (const u of [...peers.keys()]) tearPeer(u);
sfuUnpublish().catch(()=>{});
sfuUnpublishScreen().catch(()=>{});
sfuUnpublishCamera().catch(()=>{});
sfuUnsubscribe().catch(()=>{});
dropMic();
}
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');
/* terminal-block signal: stop the reconnect loop and tell the user */
if (/blocked/i.test(m.message)) handleBlocked('signal');
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 updateScreenShareUI(){
/* the share-screen button is only available to people who can speak —
* publishing a screen via the SFU requires being a speaker anyway */
$('sec-screen-share').classList.toggle('hidden', !canSpeak(myRole));
if (!canSpeak(myRole) && (sfuScreenPC || sfuScreenStream)) sfuUnpublishScreen();
/* camera lives in the same section as screen-share — visibility is
* driven by canSpeak() above. If a role demotion lands while the
* camera is live, force it off the same way the screen share is. */
if (!canSpeak(myRole) && (sfuCameraPC || sfuCameraStream)) sfuUnpublishCamera();
}
function updateRoleUI(){
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
updateScreenShareUI();
}
/* ==================================================================
* 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]);
/* 400ms jitter buffer — Wi-Fi peak inter-arrival is multiples of
* the smoothed jitter, so a generous buffer absorbs the bursts */
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
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){
const offer = await pc.createOffer();
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
await pc.setLocalDescription(offer);
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' }); });
$('btn-screen-share').addEventListener('click', () => { sfuPublishScreen().catch(e => logLine('err','screen share: '+e.message)); });
$('btn-screen-stop').addEventListener('click', () => { sfuUnpublishScreen(); });
$('btn-camera-share').addEventListener('click', () => { sfuPublishCamera().catch(e => logLine('err','camera share: '+e.message)); });
$('btn-camera-stop').addEventListener('click', () => { sfuUnpublishCamera(); });
$('camera-select').addEventListener('change', async (e) => {
cameraDeviceId = e.target.value;
try { localStorage.setItem(CAM_DEV_KEY, cameraDeviceId); } catch(_){}
if (sfuCameraPC){
/* restart with the new device — getUserMedia must be re-called with
* the new deviceId; replaceTrack on a sender from a different device
* needs renegotiation anyway, so a clean restart is simpler */
await sfuUnpublishCamera();
sfuPublishCamera().catch(err => logLine('err','camera restart: '+err.message));
}
});
async function refreshCameraList(){
try {
const devs = await navigator.mediaDevices.enumerateDevices();
const cams = devs.filter(d => d.kind === 'videoinput');
const sel = $('camera-select'); if (!sel) return;
sel.innerHTML = '';
if (!cams.length){ sel.innerHTML = '<option value="">default camera</option>'; return; }
cams.forEach((c, i) => {
const o = document.createElement('option');
o.value = c.deviceId; o.textContent = c.label || ('camera '+(i+1));
sel.appendChild(o);
});
if (cameraDeviceId && cams.some(c => c.deviceId === cameraDeviceId)) sel.value = cameraDeviceId;
else cameraDeviceId = sel.value;
} catch(e){ logLine('err','could not list cameras: '+e.message); }
}
navigator.mediaDevices.addEventListener('devicechange', refreshCameraList);
refreshCameraList();
/* ==================================================================
* latency panel — polls getStats() across every live RTCPeerConnection
* and renders one row per PC with RTT (ms) + candidate-pair path kind.
*
* Architecture note: incoming media (mic, screen video, screen audio,
* camera) from every other speaker arrives over the SAME sfuSubPC, so
* 'sfu in' is one row that covers all received streams. Publishers
* have one PC per kind (sfuPubPC, sfuScreenPC, sfuCameraPC), so each
* gets its own row. Mesh peers get one row each.
* ================================================================== */
/* per-PC stats keyed by an arbitrary ID so we can compute deltas
* (packetsLost, packetsReceived) across polls — % loss is a delta over
* a delta, not a cumulative. The pcStatsPrev object holds the last poll. */
const pcStatsPrev = new Map();
async function statsForPC(pc, key){
if (!pc) return { rtt: null, path: '', lossPct: null, jitterMs: null };
try {
const stats = await pc.getStats();
let pair = null, selectedPairId = '';
stats.forEach(s => { if (s.type === 'transport' && s.selectedCandidatePairId) selectedPairId = s.selectedCandidatePairId; });
stats.forEach(s => {
if (s.type !== 'candidate-pair') return;
if (selectedPairId && s.id === selectedPairId){ pair = s; return; }
if (!pair && s.state === 'succeeded' && (s.nominated || s.selected)) pair = s;
});
if (!pair){
stats.forEach(s => { if (!pair && s.type === 'candidate-pair' && s.state === 'succeeded') pair = s; });
}
let path = '', rtt = null;
if (pair){
stats.forEach(s => { if (s.type === 'local-candidate' && s.id === pair.localCandidateId) path = s.candidateType || ''; });
if (typeof pair.currentRoundTripTime === 'number') rtt = Math.round(pair.currentRoundTripTime * 1000);
}
/* sum packetsLost + packetsReceived across inbound-rtp (subscriber
* side: many incoming streams). For publishers we use the remote
* report (remote-inbound-rtp tells the sender what its peer lost +
* the jitter at the receiver). Both directions are interesting. */
let pktsLost = 0, pktsBase = 0, jitter = 0, jitterSamples = 0;
stats.forEach(s => {
if (s.type === 'inbound-rtp' && typeof s.packetsLost === 'number'){
pktsLost += s.packetsLost;
pktsBase += (s.packetsReceived || 0) + s.packetsLost;
if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; }
} else if (s.type === 'remote-inbound-rtp' && typeof s.packetsLost === 'number'){
pktsLost += s.packetsLost;
/* remote report doesn't include packetsReceived; we use it only
* for loss when no inbound-rtp is present (publish-side PC) */
if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; }
}
});
/* convert cumulative loss/received into a delta vs last poll so the
* percentage reflects what's happening NOW, not the session lifetime */
const prev = pcStatsPrev.get(key) || { lost: 0, base: 0 };
const dLost = Math.max(0, pktsLost - prev.lost);
const dBase = Math.max(0, pktsBase - prev.base);
pcStatsPrev.set(key, { lost: pktsLost, base: pktsBase });
const lossPct = dBase > 0 ? (100 * dLost / dBase) : (dLost > 0 ? 100 : 0);
const jitterMs = jitterSamples > 0 ? Math.round(1000 * jitter / jitterSamples) : null;
return { rtt, path, lossPct, jitterMs };
} catch(_){ return { rtt: null, path: '', lossPct: null, jitterMs: null }; }
}
function rttClass(rtt){
if (rtt == null) return 'lat-na';
if (rtt < 50) return 'lat-good';
if (rtt < 150) return 'lat-mid';
return 'lat-bad';
}
function fmtRtt(rtt){ return (rtt == null) ? '—' : rtt + ' ms'; }
function fmtPath(p){
if (!p) return '';
if (p === 'host') return 'LAN';
if (p === 'srflx') return 'WAN';
if (p === 'prflx') return 'WAN';
if (p === 'relay') return 'TURN';
return p;
}
function fmtLoss(v){ return (v == null) ? '—' : (v < 0.05 ? '0%' : v.toFixed(1)+'%'); }
function fmtJitter(v){ return (v == null) ? '—' : v + 'ms'; }
function rowClass(rtt, lossPct, jitterMs){
/* worst of three signals drives the colour, so a green RTT with
* 5% loss still flags red */
let worst = 'lat-good';
const bump = c => { const order = ['lat-na','lat-good','lat-mid','lat-bad']; if (order.indexOf(c) > order.indexOf(worst)) worst = c; };
if (rtt == null) bump('lat-na');
else if (rtt < 50) bump('lat-good');
else if (rtt < 150) bump('lat-mid');
else bump('lat-bad');
if (lossPct != null){
if (lossPct < 0.5) bump('lat-good');
else if (lossPct < 2) bump('lat-mid');
else bump('lat-bad');
}
if (jitterMs != null){
if (jitterMs < 20) bump('lat-good');
else if (jitterMs < 50) bump('lat-mid');
else bump('lat-bad');
}
return worst;
}
async function refreshLatency(){
const rows = [];
/* SFU publishers — only present if you're a speaker */
if (sfuPubPC){ rows.push({ name: 'sfu mic out', pc: sfuPubPC, key: 'pub' }); }
if (sfuScreenPC){ rows.push({ name: 'sfu screen out', pc: sfuScreenPC, key: 'screen' }); }
if (sfuCameraPC){ rows.push({ name: 'sfu camera out', pc: sfuCameraPC, key: 'camera' }); }
/* SFU subscriber — carries every incoming stream from other speakers */
if (sfuSubPC){ rows.push({ name: 'sfu in (host + screen + cams)', pc: sfuSubPC, key: 'sub' }); }
/* mesh peers — one row each */
for (const [uuid, pc] of peers){
const mm = members.get(uuid);
const handle = (mm && mm.handle) || shortHex(uuid);
rows.push({ name: 'peer '+handle, pc, key: 'mesh-'+uuid });
}
const container = $('latency-rows');
if (!container) return;
if (rows.length === 0){
$('sec-latency').classList.add('hidden');
container.innerHTML = '';
return;
}
$('sec-latency').classList.remove('hidden');
/* gather all stats in parallel */
const data = await Promise.all(rows.map(r => statsForPC(r.pc, r.key)));
container.innerHTML = '';
for (let i = 0; i < rows.length; i++){
const { rtt, path, lossPct, jitterMs } = data[i];
const div = document.createElement('div');
div.className = 'lat-row ' + rowClass(rtt, lossPct, jitterMs);
const n = document.createElement('span'); n.className = 'lat-name'; n.textContent = rows[i].name;
const r = document.createElement('span'); r.className = 'lat-rtt'; r.textContent = fmtRtt(rtt);
const lo = document.createElement('span'); lo.className = 'lat-loss'; lo.textContent = fmtLoss(lossPct);
const j = document.createElement('span'); j.className = 'lat-jit'; j.textContent = fmtJitter(jitterMs);
const p = document.createElement('span'); p.className = 'lat-path'; p.textContent = fmtPath(path);
div.appendChild(n); div.appendChild(r); div.appendChild(lo); div.appendChild(j); div.appendChild(p);
container.appendChild(div);
}
}
setInterval(refreshLatency, 2000);
/* kick once on load so the panel doesn't show stale '—' for 2s after each join */
refreshLatency();
/* 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);
/* terminal-block handler — called on any "blocked" signal (signal-server
* error, SFU 403). Stops every reconnect loop, surfaces a clear notice,
* and shuts the session down so the user sees the boot landed instead of
* watching their UI spin trying to rejoin a room they can't enter. */
let blocked = false;
function handleBlocked(source){
if (blocked) return;
blocked = true;
wantConnected = false;
showNotice('You are blocked from this space.', 'warn');
logLine('err','blocked ('+source+') — stopping reconnects');
setStatus('blocked from this space','err');
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
if (ws){ try { ws.close(); } catch(_){} ws = null; }
for (const u of [...peers.keys()]) tearPeer(u);
sfuUnpublish().catch(()=>{});
sfuUnpublishScreen().catch(()=>{});
sfuUnpublishCamera().catch(()=>{});
sfuUnsubscribe().catch(()=>{});
dropMic();
$('btn-leave').disabled = true;
$('btn-enter').disabled = false;
}
$('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;
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
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;
try { localStorage.setItem(MUSIC_MODE_KEY, musicMode ? '1' : '0'); } catch(_){}
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(); } });
/* ?code=… autofills the rendezvous code (used by the share URL). The code
* stays in the URL so a refresh keeps you in the same space; if you want
* to leave it cleanly, hit the leave button or close the tab. */
function renderShareUrl(){
const code = $('rdv-code').value.trim();
if (!code) return;
const u = new URL(location.href);
u.searchParams.set('code', code);
const url = u.toString();
$('share-url').value = url;
$('sec-share').classList.remove('hidden');
}
$('btn-copy-share').addEventListener('click', async () => {
const v = $('share-url').value;
try { await navigator.clipboard.writeText(v); $('btn-copy-share').textContent = 'copied'; setTimeout(()=>{ $('btn-copy-share').textContent='copy'; }, 1500); }
catch(_){ $('share-url').select(); document.execCommand('copy'); }
});
{
const c = new URLSearchParams(location.search).get('code');
if (c) $('rdv-code').value = c;
}
/* game-tabs: click a button → swap iframe src + mark active. data-src is
* the only source of truth so new games just need a button. */
$('game-tabs').addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-src]');
if (!b) return;
const frame = $('game-frame');
if (frame.src !== b.dataset.src) frame.src = b.dataset.src;
for (const el of $('game-tabs').querySelectorAll('button')) el.classList.toggle('active', el === b);
});
$('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 sfuUnpublishScreen(); await sfuUnpublishCamera(); await sfuUnsubscribe();
dropMic();
members.clear(); handraise.clear(); myUUID=''; myRole=''; hostUUID=''; outstandingInvite=null;
/* tear all video tiles regardless of source — fresh slate next time */
for (const k of [...screenVideos.keys()]) removeScreenTile(k);
for (const k of [...cameraVideos.keys()]) removeCameraTile(k);
$('sec-room').classList.add('hidden');
$('sec-invite').classList.add('hidden');
$('sec-listener-actions').classList.add('hidden');
$('sec-share').classList.add('hidden');
$('sec-screen-share').classList.add('hidden');
$('sec-screens').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-06-02</span><br>
md5 <span class="stamp-md5">4eb2aa5b98de772e0982dce36a4e84a1</span><br>
sha256 <span class="stamp-sha">149cc312ddf22b2cc2963de39bbfd3e6413e42073b31acd759b73dddd19451dc</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>