zebra-report/web/zebra-spaces.html
Russell Ballestrini a2dda2d586
zebra-spaces: games move to left column as persistent tiles — cameras+screens push them down
Per fox: 'make unmario and cake murder adventure as tiles on the left
which always push down as stuff joins. this will allow people to play
games during a meeting.'

Layout change:
- game-tabs + iframe moved out of <main class="timeline"> and into
  <aside id="sec-cameras"> as a third group (cameras → screens-thumbs
  → games), DOM order = stacking order, so a new camera or screen
  thumbnail naturally pushes the games further down the column
- iframe sized to fit the 220px column (aspect-ratio: 4/3, ~165px tall),
  tabs stack vertically as full-width buttons
- removed the timeline:has(> #sec-spotlight:not(.hidden)) → hide-games
  rules — no longer needed since games aren't in the timeline anymore
- sec-cameras is always visible now (games live there permanently);
  the 'cameras' h2 hides when no live cameras to avoid an empty header
- :has(> .cameras-col.hidden) page-grid rules stay as harmless dead code

Games + screen-share + cameras now coexist: someone can play unmario
in the corner while a colleague shares their screen at full size in
the middle.
2026-06-02 11:20:15 -04:00

3235 lines
147 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,
html.theme-dark .cameras-col { border-right-color: #222; }
html.theme-dark .game-tile-frame { background: #000; border-color: #222; }
/* buttons + inputs */
html.theme-dark button,
html.theme-dark .game-tabs button,
html.theme-dark .theme-toggle,
html.theme-dark .controls-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,
html.theme-dark .controls-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; }
/* dark mode: tile chrome flips back to the night palette */
html.theme-dark .screen-tile,
html.theme-dark .camera-tile { background: #000; border-color: #000; }
/* video stays transparent in both themes — the tile element's bg
* shows through any letterbox / pre-frame state, so the bars match
* the surrounding page in either palette */
html.theme-dark .screen-tile .screen-meta,
html.theme-dark .camera-tile .screen-meta { background: #111; color: #ddd; }
/* 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, .controls-toggle {
position: fixed; top: 0.6rem; 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 { right: 0.8rem; }
.controls-toggle { right: 4.3rem; }
.theme-toggle:hover, .controls-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. */
/* three columns: cameras (left) | timeline/screen (middle) | controls (right).
* Each child is pinned to its column with grid-column so any one of
* them going display:none can't cause the others to slide. Controls
* column is also clamped to ≤50vw so a very wide window doesn't let
* the side panel eat the screen-share area. */
/* Page grid switches its template based on which columns are live.
* .cameras-col.hidden (no cameras) and .page.controls-collapsed
* (hide-panel) each remove a track; the timeline reassigns its
* grid-column for each combination so it always lands in the
* remaining slot. Four states:
* cams + ctrls : 220 1fr min(360,50vw) timeline col 2
* cams : 220 1fr timeline col 2
* ctrls only : 1fr min(360,50vw) timeline col 1
* alone : 1fr timeline col 1
*/
.page {
display: grid;
grid-template-columns: 220px minmax(0, 1fr) minmax(0, min(360px, 50vw));
gap: 1.25rem;
align-items: start;
}
.cameras-col { grid-column: 1; }
.timeline { grid-column: 2; }
.controls { grid-column: 3; }
.cameras-col {
min-width: 0;
border-right: 1px solid #ddd; padding-right: 1.25rem;
display: grid;
grid-auto-rows: max-content;
gap: 0.5rem;
}
/* state: no cameras (column track gone, timeline + controls take 2-col) */
.page:has(> .cameras-col.hidden) {
grid-template-columns: minmax(0, 1fr) minmax(0, min(360px, 50vw));
}
.page:has(> .cameras-col.hidden) > .timeline { grid-column: 1; }
.page:has(> .cameras-col.hidden) > .controls { grid-column: 2; }
/* state: controls collapsed (right column gone) */
.page.controls-collapsed > .controls { display: none; }
.page.controls-collapsed:not(:has(> .cameras-col.hidden)) {
grid-template-columns: 220px minmax(0, 1fr);
}
/* state: controls collapsed AND no cameras → timeline owns the viewport */
.page.controls-collapsed:has(> .cameras-col.hidden) {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.page.controls-collapsed:has(> .cameras-col.hidden) > .timeline {
grid-column: 1; border-right: none; padding-right: 0;
}
/* pre-paint pref so a 'hidden' reload doesn't flash the panel */
html.controls-collapsed-pref .page > .controls { display: none; }
html.controls-collapsed-pref .page:not(:has(> .cameras-col.hidden)) {
grid-template-columns: 220px minmax(0, 1fr);
}
html.controls-collapsed-pref .page:has(> .cameras-col.hidden) {
grid-template-columns: minmax(0, 1fr); gap: 0;
}
html.controls-collapsed-pref .page:has(> .cameras-col.hidden) > .timeline {
grid-column: 1; border-right: none; padding-right: 0;
}
.timeline {
border-right: 1px solid #ddd; padding-right: 1.25rem; min-height: 60vh;
min-width: 0; display: grid; grid-auto-rows: max-content;
position: relative;
}
.controls { min-width: 0; }
/* games live in the LEFT column now (under cameras + screens-thumbs)
* so people can play casually during a meeting without competing
* with screen-shares for the middle slot. The tabs wrap if the
* column is narrow; the iframe is sized for the column. */
.games-h2 { margin-top: 0.5rem; }
.game-tabs {
display: grid; grid-auto-columns: minmax(0, 1fr); grid-auto-flow: row;
gap: 0.25rem; margin-bottom: 0.35rem;
}
.game-tabs button {
background: #fff; color: #000; border: 1px solid #000;
padding: 0.25rem 0.5rem; font-family: monospace; font-size: 0.72rem;
cursor: pointer; text-align: left;
}
.game-tabs button.active { background: #000; color: #fff; }
.game-tabs button:hover:not(.active) { background: #f0f0f0; }
.game-tile-frame {
width: 100%; aspect-ratio: 4 / 3;
border: 1px solid #ddd; background: #fff; display: block;
}
@media (max-width: 800px) {
body { padding: 1.25rem; }
/* mobile: single column, all three sections stack to grid-column:1 */
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
.controls, .timeline, .cameras-col { grid-column: 1; }
.controls { grid-row: 1; }
.timeline { grid-row: 2;
border-right: none; border-top: 1px solid #ddd;
padding-right: 0; padding-top: 1rem; min-height: 0; }
.cameras-col { grid-row: 3;
border-right: none; border-top: 1px solid #ddd;
padding-right: 0; padding-top: 1rem; }
#cameras { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
}
/* 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; }
/* spotlight: the active tile (camera OR screen) renders full-width
* in the middle column. All other tiles render as thumbnails inside
* the cameras-col. Clicking a thumbnail promotes it to the spotlight
* and demotes the current one back to the column. */
#spotlight { display: grid; grid-template-columns: 1fr; gap: 0; }
#screens-thumbs { display: grid; grid-template-columns: 1fr; gap: 0.45rem; margin-top: 0.5rem; }
.screens-thumbs-h2 { margin-top: 0.5rem; }
.screens-thumbs-h2.hidden { display: none; }
/* a thumbnail tile is the same DOM as a spotlight tile but with a
* thumb modifier class; clicking it triggers a spotlight swap */
.tile-thumb { cursor: pointer; position: relative; }
.tile-thumb:hover { outline: 1px solid #888; }
/* viewing state: the same tile is currently in the spotlight slot.
* Don't hide the thumb — gray it out and overlay 'viewing' so the
* user can see which thumb maps to the spotlight tile. */
.tile-thumb .viewing-badge {
position: absolute; inset: 0;
display: grid; place-items: center;
background: rgba(0,0,0,0.55); color: #fff;
font-family: monospace; font-size: 0.75rem; letter-spacing: 0.05em;
text-transform: uppercase; pointer-events: none;
opacity: 0; transition: opacity 0.12s;
z-index: 5;
}
.tile-thumb.viewing { cursor: default; }
.tile-thumb.viewing:hover { outline: none; }
.tile-thumb.viewing > video { opacity: 0.35; }
.tile-thumb.viewing .viewing-badge { opacity: 1; }
/* spotlight sizing — a camera in the spotlight uses contain so the
* whole frame is visible (not cropped like the thumb cover) */
#spotlight > .camera-tile video {
aspect-ratio: 16 / 9;
object-fit: contain;
max-height: 92vh;
}
/* thumb sizing for either kind: small, cropped 16:9 */
.tile-thumb > video {
width: 100%; height: auto;
aspect-ratio: 16 / 9; object-fit: cover;
max-height: 22vh;
}
.tile-thumb > .screen-meta {
font-size: 0.6rem; padding: 0.15rem 0.3rem;
}
/* tile chrome (border + bg + meta) defaults to light-theme palette so
* the screen-share blends with a white page. Dark-theme overrides
* further down restore the black tile chrome for the night palette. */
.screen-tile {
border: 1px solid #ddd; background: #fff; padding: 0;
display: grid; grid-auto-rows: max-content; position: relative;
}
.screen-tile video {
width: 100%; height: auto; max-height: 92vh; display: block;
background: transparent; /* let the tile bg show through letterbox */
}
.screen-tile .screen-meta {
background: #f0f0f0; color: #333; font-size: 0.7rem; padding: 0.3rem 0.5rem;
display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center;
}
/* 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; place-items: 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: grid; }
/* spotlight active → hide the game iframe so the spotlight tile owns the column */
/* (games live in sec-cameras now — no timeline-side hide rule needed) */
/* camera tiles live in the LEFT column now — single column, stacked
* vertically. Each tile keeps a 16:9 letterbox so faces don't squash. */
#cameras { display: grid; grid-template-columns: 1fr; gap: 0.45rem; }
.camera-tile {
border: 1px solid #ddd; background: #fff; padding: 0;
display: grid; grid-auto-rows: max-content; position: relative;
}
.camera-tile video {
width: 100%; height: auto; display: block;
background: transparent;
aspect-ratio: 16 / 9; object-fit: cover;
}
.camera-tile .screen-meta {
background: #f0f0f0; color: #333; font-size: 0.7rem; padding: 0.2rem 0.4rem;
display: grid; grid-template-columns: 1fr auto; gap: 0.4rem; align-items: center;
}
/* 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; place-items: 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: grid; }
/* 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; }
/* form-row style guide — every form row is a grid.
* default children pack at max-content width, no stretch
* <something> <input/select> ... → template "auto 1fr ..." so the
* input cell grows
* <input/select> ... → template "1fr ..." so the field fills,
* packed siblings follow
* any <span.note> child → moved to its own row inside the grid so
* long text never competes with the
* buttons it explains
* Items beyond the templated cells flow via grid-auto-columns:max-content. */
.row {
display: grid;
grid-auto-flow: column;
/* minmax(0,max-content) so a long single-child label (e.g. the
* 'music mode — …' description) can shrink and wrap inside the
* controls column instead of overflowing horizontally */
grid-auto-columns: minmax(0, max-content);
align-items: center; gap: 0.6rem; margin-bottom: 0.6rem;
min-width: 0;
}
.row:has(> :first-child + input[type=text]),
.row:has(> :first-child + input[type=password]),
.row:has(> :first-child + select) {
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
}
.row:has(> input[type=text]:first-child),
.row:has(> input[type=password]:first-child),
.row:has(> select:first-child) {
grid-template-columns: minmax(0, 1fr);
}
/* notes inside a row drop to their own line under the buttons/inputs
* instead of fighting them for horizontal space */
.row > .note {
grid-column: 1 / -1;
margin-top: 0.05rem; line-height: 1.4;
}
/* labels wrapping a checkbox + long text break naturally */
.row > label { white-space: normal; }
input[type=text], input[type=password] {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
padding: 0.4rem; background: #fff; color: #000; min-width: 0; width: 100%;
}
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; min-width: 0; width: 100%; cursor: pointer;
}
.dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #000; background: #fff; }
.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: grid; grid-auto-flow: column; grid-auto-columns: max-content;
gap: 0.3rem;
grid-column: 1 / -1; /* second row, full width */
justify-content: 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: grid; grid-auto-rows: max-content; gap: 0.6rem; justify-items: start;
}
.invite-banner .invite-actions {
display: grid; grid-auto-flow: column; grid-auto-columns: max-content; gap: 0.5rem;
}
.notice-banner {
padding: 0.7rem 0.8rem; margin-bottom: 1rem;
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
gap: 0.6rem; 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 a flash of the wrong theme.
* Default IS dark — only the explicit 'light' choice opts out, so a
* first-time visitor lands in dark without a flash. One unified key
* across every zebra page so the preference follows the user. */
try {
var pref = localStorage.getItem('zebra-theme-v1');
if (!pref) pref = localStorage.getItem('zebra-spaces-theme-v1'); // legacy migration
if (pref !== 'light'){
document.documentElement.classList.add('theme-dark');
}
} catch(_){ document.documentElement.classList.add('theme-dark'); }
/* sidebar-collapsed pre-paint: apply to <html> so the .page child can
* read it via :has() or via a body-level class added on DOMContentLoaded */
try {
if (localStorage.getItem('zebra-spaces-controls-v1') === 'hidden'){
document.documentElement.classList.add('controls-collapsed-pref');
}
} catch(_){}
</script>
</head>
<body>
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">dark</button>
<button id="btn-controls-toggle" class="controls-toggle" type="button" aria-label="hide side panel" title="hide right side panel">hide panel</button>
<div class="page">
<!-- left column: cameras at top, then screen-thumbs, then games at the
bottom. Games are persistent (always visible) so the column is
always on; cameras + screens push the games further down as people
join + share. People can play during the meeting without the games
ever competing with a live screen-share for the middle slot. -->
<aside id="sec-cameras" class="cameras-col">
<h2>cameras</h2>
<div id="cameras"></div>
<h2 class="screens-thumbs-h2 hidden">screens</h2>
<div id="screens-thumbs"></div>
<h2 class="games-h2">games</h2>
<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="game-tile-frame"
src="https://unmario.com/"
title="game"
loading="lazy"
referrerpolicy="no-referrer"></iframe>
</aside>
<main class="timeline">
<!-- spotlight: the active camera or screen tile occupies this slot at
full size. All other tiles render as thumbnails in the cameras
column. Click a thumbnail to swap it into the spotlight. -->
<section id="sec-spotlight" class="hidden">
<div id="spotlight"></div>
</section>
</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</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>
</div>
<div class="row">
<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;
}
/* ==================================================================
* state-machine framework — pure transitions, testable in isolation.
*
* spec = {
* initial: 'off',
* context: { ... }, // shared mutable state
* states: {
* off: { entry?, exit?, on: { START: 'acquiring' } },
* acquiring: { on: { ACQUIRED: { target: 'live', action(ctx, ev) } } },
* ...
* }
* }
*
* Transition table values are either a target state string OR an object
* with { target, action }. Actions and entry/exit hooks are SYNCHRONOUS
* and may NOT call send() during their own entry (queue would race).
* Async work belongs in observers — observers fire after each transition
* and may call send() to advance the machine.
*
* No external deps: runs in browser AND Node so unit tests can drive
* any FSM with synthetic events and assert transition tables. */
function createFSM(spec){
let state = spec.initial;
const ctx = Object.assign({}, spec.context || {});
const observers = new Set();
let started = false;
function notify(prev, ev){
for (const fn of observers) try { fn({ state, prev, ev, ctx }); } catch(_){}
}
function runEntry(ev){
const def = spec.states[state];
if (def && def.entry) try { def.entry(ctx, ev); } catch(_){}
}
function runExit(ev){
const def = spec.states[state];
if (def && def.exit) try { def.exit(ctx, ev); } catch(_){}
}
function send(type, payload){
if (!started) start();
const def = spec.states[state];
if (!def || !def.on) return false;
const t = def.on[type];
if (!t) return false;
const target = (typeof t === 'string') ? t : t.target;
if (!target || !spec.states[target]) return false;
const action = (typeof t === 'string') ? null : t.action;
const prev = state;
const ev = { type, payload };
runExit(ev);
state = target;
if (action) try { action(ctx, ev); } catch(_){}
runEntry(ev);
notify(prev, ev);
return true;
}
function start(){
if (started) return api;
started = true;
runEntry(null);
notify(null, null);
return api;
}
const api = {
get state(){ return state; },
get context(){ return ctx; },
send,
observe(fn){ observers.add(fn); return () => observers.delete(fn); },
start,
};
return api;
}
/* ==================================================================
* PublishFSM — one instance per kind (mic / screen / camera).
*
* off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
* ▲ │ │ │
* │ FAILED FAILED STOP / LOST
* │ ▼ ▼ ▼
* └──────────── (off) (off) stopping
* │
* DONE
* ▼
* off
*
* Pure transition spec. The runtime wires up actual getUserMedia /
* getDisplayMedia / fetch / PeerConnection via observers and feeds
* results back as events. */
const publishSpec = {
initial: 'off',
context: { kind: '', stream: null, pc: null, peerID: null, lastError: null },
states: {
off: {
entry: (ctx) => { ctx.stream = null; ctx.pc = null; ctx.peerID = null; },
on: {
START: 'acquiring',
},
},
acquiring: {
on: {
ACQUIRED: {
target: 'negotiating',
action: (ctx, ev) => { ctx.stream = ev.payload && ev.payload.stream; },
},
FAILED: {
target: 'off',
action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; },
},
STOP: 'off', /* cancelled before media acquired */
},
},
negotiating: {
on: {
NEGOTIATED: {
target: 'live',
action: (ctx, ev) => {
if (ev.payload){ ctx.pc = ev.payload.pc || ctx.pc; ctx.peerID = ev.payload.peerID || ctx.peerID; }
},
},
FAILED: {
target: 'stopping',
action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; },
},
STOP: 'stopping',
},
},
live: {
on: {
STOP: 'stopping',
LOST: 'stopping', /* track ended via device disappearance */
},
},
stopping: {
on: {
DONE: 'off',
},
},
},
};
/* ==================================================================
* SubscribeFSM — one instance for the SFU subscribe leg.
*
* off ──START──▶ connecting ──CONNECTED──▶ subscribed
* ▲ │ FAILED │ RENEG (SSE offer arrives)
* │ ▼ ▼
* │ off renegotiating
* │ │ RENEG_DONE / RENEG_FAILED
* │ ▼
* │ subscribed
* │ │ LOST (SSE drop)
* │ ▼
* │ reconnecting ──CONNECTED──▶ subscribed
* │ │
* │ STOP
* │ ▼
* └────────────── DONE ──────────────── stopping
*
* Renegotiation lives in its own state so RENEG offers queued during an
* in-flight one can't race setRemoteDescription (the bug we hit in
* commit ae9721e). When RENEG fires while already renegotiating, the
* spec parks the SDP on ctx.pendingOffers; the runtime drains the queue
* on RENEG_DONE via an observer, so the FSM itself stays pure. */
const subscribeSpec = {
initial: 'off',
context: { pc: null, peerID: null, events: null, lastError: null, pendingOffers: [] },
states: {
off: {
entry: (ctx) => { ctx.pc = null; ctx.peerID = null; ctx.events = null; ctx.pendingOffers = []; },
on: { START: 'connecting' },
},
connecting: {
on: {
CONNECTED: {
target: 'subscribed',
action: (ctx, ev) => {
if (ev.payload){
ctx.pc = ev.payload.pc || ctx.pc;
ctx.peerID = ev.payload.peerID || ctx.peerID;
ctx.events = ev.payload.events || ctx.events;
}
},
},
FAILED: { target: 'off', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
STOP: 'off',
},
},
subscribed: {
on: {
RENEG: {
target: 'renegotiating',
action: (ctx, ev) => { const sdp = ev.payload && ev.payload.sdp; if (sdp) ctx.pendingOffers.push(sdp); },
},
LOST: 'reconnecting',
STOP: 'stopping',
},
},
renegotiating: {
on: {
/* extra offers queue up; we re-enter renegotiating so observers
* can see the transition but the existing run continues */
RENEG: {
target: 'renegotiating',
action: (ctx, ev) => { const sdp = ev.payload && ev.payload.sdp; if (sdp) ctx.pendingOffers.push(sdp); },
},
RENEG_DONE: 'subscribed',
RENEG_FAILED: { target: 'subscribed', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
LOST: 'reconnecting',
STOP: 'stopping',
},
},
reconnecting: {
on: {
CONNECTED: 'subscribed',
FAILED: { target: 'off', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
STOP: 'stopping',
},
},
stopping: {
on: { DONE: 'off' },
},
},
};
/* ==================================================================
* RemoteTileFSM — one instance per incoming screen / camera track from
* a remote publisher. Formalises the lifecycle that f71e9e7 patched
* imperatively (frozen-thumb after unshare):
*
* inactive ──TRACK_ARRIVED──▶ receiving ──MUTED──▶ muted
* ▲ │
* │ UNMUTED │ PRUNE / ENDED
* └────────────────────┤
* ▼
* removed
*
* {receiving, muted} + ENDED → removed
* * + LEFT → removed (peer-left wipes the tile from every state)
*
* MUTED is a debounce gate, not a deletion: if UNMUTED arrives within
* the runtime's debounce window (~1.5s) we stay receiving — the mute
* was a transient network blip. If PRUNE fires (debounce expired and
* still muted) the publisher really unshared and the tile dies.
* ENDED skips the debounce. removed is terminal — a new tile gets
* a fresh FSM. */
const remoteTileSpec = {
initial: 'inactive',
context: { kind: '', pubHex: '', stream: null, lastError: null },
states: {
inactive: {
on: {
TRACK_ARRIVED: {
target: 'receiving',
action: (ctx, ev) => { if (ev.payload) ctx.stream = ev.payload.stream || ctx.stream; },
},
LEFT: 'removed',
},
},
receiving: {
on: {
MUTED: 'muted',
ENDED: 'removed',
LEFT: 'removed',
/* a fresh ontrack for the same pubHex+kind — publisher re-shared
* before our prune fired; keep the new stream and stay receiving */
TRACK_ARRIVED: {
target: 'receiving',
action: (ctx, ev) => { if (ev.payload && ev.payload.stream) ctx.stream = ev.payload.stream; },
},
},
},
muted: {
on: {
UNMUTED: 'receiving',
PRUNE: 'removed',
ENDED: 'removed',
LEFT: 'removed',
TRACK_ARRIVED: {
target: 'receiving',
action: (ctx, ev) => { if (ev.payload && ev.payload.stream) ctx.stream = ev.payload.stream; },
},
},
},
removed: {
entry: (ctx) => { ctx.stream = null; },
/* terminal — no outbound transitions */
},
},
};
/* ==================================================================
* CallFSM — the user's overall lifecycle in a space. Orchestrates
* (but doesn't replace) the per-leg FSMs above: a Joined call has
* exactly one SubscribeFSM and zero-to-three PublishFSMs running
* underneath; the runtime starts/stops them on entry/exit of joined.
*
* idle ──ENTER──▶ connecting ──WELCOME──▶ joined ──LEAVE──▶ leaving ──DONE──▶ idle
* ▲ │ FAILED │ ▲
* │ ▼ │ WS_DROPPED │
* │ idle ▼ │
* │ reconnecting ──WELCOME──▶ joined │
* │ │ LEAVE / FAILED │
* │ ▼ │
* │ leaving ─────────────────────────────┘
* │ ▲
* │ │ ACK
* └─────────────────────────────────── booted ◀── BOOTED ── (any live state)
*
* Role lives in ctx (host / cohost / speaker / listener). ROLE_CHANGE
* fires re-entry of joined so observers can swap mesh/sub/publish
* topology without inventing a new state per role permutation. */
const callSpec = {
initial: 'idle',
context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, lastError: null },
states: {
idle: {
entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; },
on: {
ENTER: {
target: 'connecting',
action: (ctx, ev) => { if (ev.payload){ ctx.code = ev.payload.code || ''; ctx.handle = ev.payload.handle || ''; } },
},
},
},
connecting: {
on: {
WELCOME: {
target: 'joined',
action: (ctx, ev) => { if (ev.payload){ ctx.uuid = ev.payload.uuid || ''; ctx.role = ev.payload.role || ''; } },
},
FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
LEAVE: 'idle',
},
},
joined: {
on: {
/* ROLE_CHANGE re-enters joined so observers get an event to act
* on (swap mesh peers, start/stop publish, etc.) */
ROLE_CHANGE: {
target: 'joined',
action: (ctx, ev) => { if (ev.payload && ev.payload.role) ctx.role = ev.payload.role; },
},
WS_DROPPED: 'reconnecting',
LEAVE: 'leaving',
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
},
},
reconnecting: {
/* signal-server WebSocket dropped; the SubscribeFSM + publish
* legs stay alive because WebRTC PCs are independent of the WS.
* On WELCOME (a re-issued one after the WS comes back) we land
* back in joined and ROLE_CHANGE may follow. */
on: {
WELCOME: {
target: 'joined',
action: (ctx, ev) => { if (ev.payload && ev.payload.role) ctx.role = ev.payload.role; },
},
FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
LEAVE: 'leaving',
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
},
},
leaving: {
on: { DONE: 'idle' },
},
booted: {
/* user has been removed from the room. ACK transitions back to
* idle so the entry screen comes back; the room's auto-rejoin
* sessionStorage key is cleared by the runtime when entering booted. */
on: { ACK: 'idle' },
},
},
};
/* ==================================================================
* wireZebraMachines — orchestrator. Composes one CallFSM, one
* SubscribeFSM, three PublishFSMs (mic/screen/camera), and a Map of
* RemoteTileFSMs into a coherent room. Observers wire transitions
* between machines; no side effects in this layer — the page's
* runtime attaches its OWN observers on top to drive actual WebRTC
* and DOM work. That separation keeps this function fully testable
* in Node with synthetic events.
*
* Returns { call, sub, pubs, remoteTiles, tileFor, tileLeft }. */
function wireZebraMachines(){
const call = createFSM(callSpec);
const sub = createFSM(subscribeSpec);
const pubs = {
mic: createFSM(publishSpec),
screen: createFSM(publishSpec),
camera: createFSM(publishSpec),
};
pubs.mic.context.kind = 'mic';
pubs.screen.context.kind = 'screen';
pubs.camera.context.kind = 'camera';
/* RemoteTileFSMs keyed by `${kind}:${pubHex}` — lazily created. */
const remoteTiles = new Map();
function tileFor(kind, pubHex){
const key = kind + ':' + pubHex;
let t = remoteTiles.get(key);
if (!t){
t = createFSM(remoteTileSpec);
t.context.kind = kind;
t.context.pubHex = pubHex;
remoteTiles.set(key, t);
}
return t;
}
/* peer-left: any tile keyed by this pubHex (any kind) goes LEFT */
function tileLeft(pubHex){
for (const [key, t] of remoteTiles){
if (key.endsWith(':' + pubHex)) t.send('LEFT');
}
}
/* CallFSM → SubscribeFSM: subscribe whenever joined, stop when not.
* Coming back from reconnecting → joined doesn't re-START because the
* SubscribeFSM stayed alive through the signal-WS drop. */
call.observe(({ state, prev }) => {
if (state === prev) return;
if (state === 'joined' && prev !== 'reconnecting'){
sub.send('START');
}
if (state === 'leaving' || state === 'booted'){
sub.send('STOP');
}
});
/* CallFSM → PublishFSMs: tearing down the call stops every live publish */
call.observe(({ state, prev }) => {
if (state === prev) return;
if (state === 'leaving' || state === 'booted'){
for (const k of Object.keys(pubs)){
const ps = pubs[k].state;
if (ps === 'acquiring' || ps === 'negotiating' || ps === 'live'){
pubs[k].send('STOP');
}
}
}
});
return { call, sub, pubs, remoteTiles, tileFor, tileLeft };
}
/* ==================================================================
* 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-theme-v1';
/* sessionStorage (per-tab) — tracks which call this tab is in so a
* hard refresh auto-rejoins. Different tabs can sit in different
* spaces because sessionStorage doesn't bleed across tabs. */
const ACTIVE_CALL_KEY = 'zebra-spaces-active-call-v1';
/* camera resume only — screen-share recovery isn't possible silently
* (getDisplayMedia needs a fresh gesture) and offering a separate
* 'resume screen' button is the same friction as clicking 'share
* screen' again, so we just don't track screen state across reload. */
const ACTIVE_CAM_KEY = 'zebra-spaces-active-cam-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();
});
/* controls-panel collapse — promotes the pre-paint <html> pref onto the
* .page element, then keeps both in sync on click. Pre-paint class on
* <html> stays so a fresh load with a saved 'hidden' pref still shows
* the layout collapsed instantly. */
const CONTROLS_KEY = 'zebra-spaces-controls-v1';
function applyControlsLabel(){
const hidden = document.querySelector('.page').classList.contains('controls-collapsed');
const btn = $('btn-controls-toggle'); if (btn) btn.textContent = hidden ? 'show panel' : 'hide panel';
}
if (document.documentElement.classList.contains('controls-collapsed-pref')){
document.querySelector('.page').classList.add('controls-collapsed');
}
applyControlsLabel();
$('btn-controls-toggle').addEventListener('click', () => {
const page = document.querySelector('.page');
const goHidden = !page.classList.contains('controls-collapsed');
page.classList.toggle('controls-collapsed', goHidden);
document.documentElement.classList.toggle('controls-collapsed-pref', goHidden);
try { localStorage.setItem(CONTROLS_KEY, goHidden ? 'hidden' : 'shown'); } catch(_){}
applyControlsLabel();
});
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. Declared up here so the restore
* runs before their downstream `let` would put them in the temporal
* dead zone; downstream code now reads from these existing bindings. */
let musicMode = false, micDeviceId = '', cameraDeviceId = '';
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 is declared earlier so localStorage restore can write to it
* before the device-list refresh runs */
/* 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(_){}
}
}
}
/* When a publisher unshares, the SFU stops its transceiver on every
* subscriber's pc and renegotiates. Browsers don't reliably fire
* 'ended' on remote tracks under this path (Chromium half-fires,
* Firefox stays silent). They DO fire 'mute' when RTP stops arriving.
* Watch both: a sustained mute for >1.5s = the publisher is gone and
* we should remove the tile. If 'unmute' fires within the window
* (transient network blip) we cancel the removal. */
function watchVideoTrackForRemoval(track, removeFn){
if (!track) return;
let timer = null, removed = false;
const remove = () => {
if (removed) return;
removed = true;
if (timer) { clearTimeout(timer); timer = null; }
try { removeFn(); } catch(_){}
};
const onMute = () => {
if (removed || timer) return;
timer = setTimeout(() => {
timer = null;
if (!removed && track.muted) remove();
}, 1500);
};
const onUnmute = () => {
if (timer){ clearTimeout(timer); timer = null; }
};
track.addEventListener('ended', remove);
track.addEventListener('mute', onMute);
track.addEventListener('unmute', onUnmute);
/* defensive: if the track is already muted at attach time (the
* 'mute' event may have fired before we got here), schedule the
* pruning right away */
if (track.muted) onMute();
}
/* Spotlight model — every tile keeps its thumbnail permanently in the
* cameras column (cameras above, screens-thumbs below). When a tile is
* spotlit, a SECOND larger tile renders in the middle slot pointing at
* the same MediaStream; the thumbnail gets a 'viewing' overlay + gray
* tint so the user can see which tile is up big. Clicking another
* thumbnail swaps the spotlight.
*
* Containers:
* #spotlight middle column (zero or one larger 'spotlight tile')
* #cameras left column: camera thumbnails (always rendered)
* #screens-thumbs left column: screen thumbnails (always rendered) */
const TILE_KINDS = {
screen: { thumbContainer:'screens-thumbs', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
camera: { thumbContainer:'cameras', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
};
/* the active spotlight, or null when nothing is spotlit */
let spotlight = null; // { kind, pubHex, tile (DOM), video (DOM) }
/* who in the room is viewing what big tile. Keyed by uuid → key
* ("kind:pubHex" or "" for none). Drives popularity sort + log lines. */
const spotlights = new Map();
function spotlightKey(){ return spotlight ? spotlight.kind+':'+spotlight.pubHex : ''; }
function getEntry(kind, pubHex){ const k = TILE_KINDS[kind]; return k ? k.store.get(pubHex) : null; }
function memberOf(pubHex){
for (const [uuid, mm] of members){
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex) return { uuid, mm }; } catch(_){}
}
return null;
}
function ownerLabel(kind, pubHex){
const m = memberOf(pubHex);
const handle = m ? (m.mm.handle || shortHex(pubHex)) : shortHex(pubHex);
return handle + "'s " + (TILE_KINDS[kind] ? TILE_KINDS[kind].labelPrefix : kind);
}
/* tile score = role-based boost on the OWNER + count of current viewers.
* Host always at top, co-hosts second, then speakers sorted by viewers.
* Listeners can't publish so they never appear. */
function tileScore(kind, pubHex){
let boost = 0;
const m = memberOf(pubHex);
if (m){
if (m.mm.role === 'host') boost = 10000;
else if (m.mm.role === 'cohost') boost = 5000;
else if (m.mm.role === 'speaker') boost = 0;
}
const key = kind + ':' + pubHex;
let viewers = 0;
for (const [, k] of spotlights) if (k === key) viewers++;
return boost + viewers;
}
function sortThumbContainer(containerId, kind){
const c = $(containerId); if (!c) return;
const els = Array.from(c.children);
els.sort((a, b) => tileScore(kind, b.dataset.pubHex) - tileScore(kind, a.dataset.pubHex));
for (const el of els) c.appendChild(el);
}
function reorderTiles(){
sortThumbContainer('cameras', 'camera');
sortThumbContainer('screens-thumbs', 'screen');
}
function broadcastSpotlight(){
/* fire-and-forget to the signal server; no-op if not connected yet */
try { send({ type: 'spotlight', key: spotlightKey() }); } catch(_){}
}
function logSpotlightChange(uuid, key){
const handle = (members.get(uuid) || {}).handle || shortHex(uuid);
if (!key){ logLine('', handle + ' looked away'); return; }
const [kind, pubHex] = key.split(':');
logLine('', handle + ' now viewing ' + ownerLabel(kind, pubHex));
}
function updateContainerVisibility(){
$('sec-spotlight').classList.toggle('hidden', !spotlight);
/* sec-cameras is ALWAYS visible now — the games tiles live there as
* persistent residents that get pushed down by arriving cameras +
* screens. Only the cameras-header and screens-header toggle. */
const anyCam = cameraVideos.size > 0;
const anyScreen = screenVideos.size > 0;
/* hide the 'cameras' h2 when no live cameras to avoid an empty header */
const camH2 = document.querySelector('#sec-cameras > h2:first-of-type');
if (camH2) camH2.classList.toggle('hidden', !anyCam);
document.querySelector('.screens-thumbs-h2').classList.toggle('hidden', !anyScreen);
}
/* build a tile DOM. opts.thumb = true for thumb-style (no fullscreen
* button, gets .tile-thumb class). Returns { tile, video, fsBtn }. */
function buildTile(kind, pubHex, label, opts){
const k = TILE_KINDS[kind];
const isThumb = !!(opts && opts.thumb);
const tile = document.createElement('div');
tile.className = k.tileClass + (isThumb ? ' tile-thumb' : '');
tile.dataset.pubHex = pubHex;
tile.dataset.kind = kind;
const video = document.createElement('video');
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 = (ev) => {
ev.stopPropagation();
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');
let fsBtn = null;
if (!isThumb){
fsBtn = document.createElement('button'); fsBtn.className = 'small';
fsBtn.textContent = 'fullscreen';
fsBtn.onclick = (ev) => {
ev.stopPropagation();
if (video.requestFullscreen) video.requestFullscreen().catch(()=>{});
};
ctl.appendChild(fsBtn);
}
meta.appendChild(who); meta.appendChild(ctl);
/* "viewing" overlay only ever shown on thumbs whose tile is currently spotlit */
if (isThumb){
const view = document.createElement('div'); view.className = 'viewing-badge';
view.textContent = 'viewing';
tile.appendChild(view);
}
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
return { tile, video, fsBtn };
}
function setSpotlight(kind, pubHex){
const entry = getEntry(kind, pubHex);
if (!entry){ if (spotlight) clearSpotlightDOM(); spotlight = null; updateContainerVisibility(); return; }
/* unmark the previous spotlight's thumb, tear its big tile DOM */
if (spotlight){
const prev = getEntry(spotlight.kind, spotlight.pubHex);
if (prev) prev.tile.classList.remove('viewing');
clearSpotlightDOM();
}
/* build a fresh big tile for the new selection, hand it the same stream */
const big = buildTile(kind, pubHex, entry.label, { thumb: false });
big.video.srcObject = entry.video.srcObject;
try { const p = big.video.play(); if (p && p.catch) p.catch(()=>{ big.tile.classList.add('needs-tap'); }); } catch(_){}
$('spotlight').appendChild(big.tile);
entry.tile.classList.add('viewing');
spotlight = { kind, pubHex, tile: big.tile, video: big.video };
updateContainerVisibility();
/* track our own choice, broadcast it, log it, re-sort */
if (myUUID){
spotlights.set(myUUID, spotlightKey());
logSpotlightChange(myUUID, spotlightKey());
broadcastSpotlight();
}
reorderTiles();
}
function clearSpotlightDOM(){
const sp = $('spotlight');
while (sp.firstChild){
const v = sp.firstChild.querySelector && sp.firstChild.querySelector('video');
if (v) try { v.srcObject = null; } catch(_){}
sp.removeChild(sp.firstChild);
}
}
function pickNextSpotlight(){
/* called when the current spotlight tile is removed — prefer a screen */
for (const [pubHex] of screenVideos){ setSpotlight('screen', pubHex); return; }
for (const [pubHex] of cameraVideos){ setSpotlight('camera', pubHex); return; }
if (spotlight) clearSpotlightDOM();
spotlight = null;
updateContainerVisibility();
if (myUUID){
spotlights.set(myUUID, '');
logSpotlightChange(myUUID, '');
broadcastSpotlight();
}
}
function renderVideoTile(kind, pubHex, stream, opts){
const k = TILE_KINDS[kind]; if (!k) return;
const local = !!(opts && opts.local);
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);
let isNew = false;
if (!entry){
isNew = true;
/* thumbnail lives in the cameras column permanently */
const built = buildTile(kind, pubHex, label, { thumb: true });
built.tile.addEventListener('click', () => {
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex) return;
setSpotlight(kind, pubHex);
});
$(k.thumbContainer).appendChild(built.tile);
entry = { tile: built.tile, video: built.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');
}
/* if the same tile is currently spotlit, mirror the stream into the big tile */
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex && spotlight.video){
spotlight.video.srcObject = stream;
}
/* first arrival auto-spotlights — no spotlight up yet AND this is new */
if (isNew && !spotlight){
setSpotlight(kind, pubHex);
}
updateContainerVisibility();
if (isNew) 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;
const wasSpotlight = spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex;
try { entry.video.srcObject = null; entry.tile.remove(); } catch(_){}
k.store.delete(pubHex);
k.streams.delete(pubHex);
if (wasSpotlight){
clearSpotlightDOM();
spotlight = null;
pickNextSpotlight();
} else {
updateContainerVisibility();
}
}
/* 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');
try { sessionStorage.setItem(ACTIVE_CAM_KEY, '1'); } catch(_){}
}
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');
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
}
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 (RFC 7941 compliant — Firefox enforces 1*64 token-
* chars and rejects ':'): SHORT16HEX (mic) | SHORT16HEX-screen |
* SHORT16HEX-camera. Resolve the 16-char prefix back to a member's
* full pubkey via lookup so the rest of the code keeps using full
* pubhex as identity. */
const dash = sid.indexOf('-');
let pubHex16, kind;
if (dash > 0){
pubHex16 = sid.slice(0, dash);
kind = sid.slice(dash + 1);
} else {
pubHex16 = sid;
kind = 'mic';
}
/* skip echo of our own publish — match by prefix */
if (myKeys && myKeys.pubHex.startsWith(pubHex16)) return;
/* resolve short prefix → full pubhex via member roster */
let pubHex = pubHex16;
for (const [, mm] of members){
try {
if (mm.pubkey){
const fh = hex(unb64(mm.pubkey));
if (fh.startsWith(pubHex16)){ pubHex = fh; break; }
}
} catch(_){}
}
if (kind === 'screen'){
screenStreams.set(pubHex, ev.streams[0]);
renderScreenTile(pubHex, ev.streams[0]);
watchVideoTrackForRemoval(ev.track, () => removeScreenTile(pubHex));
return;
}
if (kind === 'camera'){
cameraStreams.set(pubHex, ev.streams[0]);
renderCameraTile(pubHex, ev.streams[0]);
watchVideoTrackForRemoval(ev.track, () => removeCameraTile(pubHex));
return;
}
if (kind !== 'mic'){
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
return;
}
/* mic audio — 400ms jitter-buffer target absorbs Wi-Fi peak jitter */
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
/* cache by full pubkey (already resolved above) so it survives the
* member's session uuid changing across leave/rejoin */
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
for (const [uuid, mm] of members){
try {
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
/* speakers get their peers' audio via mesh (lower latency) —
* skip the duplicate SFU mic. screens + cameras still came
* through above. */
if (canSpeak(myRole) && peers.has(uuid)) return;
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.
*
* Renegotiation must be SERIALISED. Each offer transitions the PC
* through have-remote-offer → stable, and if we kick off the next
* setRemoteDescription before the previous has applied its answer,
* the second one throws ('failed to set remote offer sdp: Called in
* wrong state'). When a speaker publishes mic + screen + camera in
* quick succession the SFU fires three offers in a row — without
* serialisation we drop the later ones, browser-side track set goes
* out of sync with the SFU, and existing screen/camera tiles can
* stop receiving RTP. Chain through a single promise queue. */
let renegQueue = Promise.resolve();
sfuSubEvents = new EventSource(SFU_BASE + '/events?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID);
sfuSubEvents.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch(_){ return; }
if (m.type !== 'offer' || !sfuSubPC) return;
renegQueue = renegQueue.then(async () => {
if (!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 + micDeviceId declared earlier so localStorage restore can
* populate them before getMic() reads micConstraints() */
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);
watchMicTrack(t);
return micStream;
}
/* If the underlying mic device disappears (BT disconnect, USB unplug, OS
* audio swap), the track fires 'ended' or stops emitting samples but the
* MediaStream object stays alive — so existing RTP senders keep pointing
* at a dead track and the host goes silent until they leave + rejoin.
* Watch the track and re-acquire on the fly. */
let micReacquireInFlight = false;
function watchMicTrack(track){
if (!track) return;
track.addEventListener('ended', () => {
logLine('err', 'mic track ended — re-acquiring');
reacquireMic().catch(e => logLine('err','mic re-acquire failed: '+e.message));
});
}
async function reacquireMic(){
if (micReacquireInFlight) return;
micReacquireInFlight = true;
try {
const ns = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
const nt = ns.getAudioTracks()[0];
tagTrack(nt); nt.enabled = !muted;
await enforceMicConstraints(nt);
watchMicTrack(nt);
/* hot-swap onto every existing sender — no renegotiation needed
* since the codec / SDP didn't change, just the track behind it */
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(_){} }
}
for (const [, pc] of peers) await swap(pc);
if (sfuPubPC) await swap(sfuPubPC);
if (micStream) micStream.getTracks().forEach(t=>t.stop());
micStream = ns;
if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); }
logLine('', 'mic re-acquired');
} finally {
micReacquireInFlight = false;
}
}
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);
watchMicTrack(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
* ================================================================== */
/* ROOM MACHINES — shadow instance. Runs alongside the imperative
* logic during the FSM rollout: the existing handlers stay
* authoritative for side effects, but we ALSO send synthetic events
* here so the state machines track reality. fox can inspect via
* `roomMachines.call.state` / `roomMachines.sub.state` in DevTools
* during QA. As each imperative path is rewritten to be driven by
* the FSM, the shadowing comes out. */
const roomMachines = wireZebraMachines();
if (typeof window !== 'undefined'){ window.roomMachines = roomMachines; }
/* visible trace of every CallFSM transition so QA can correlate UI
* symptoms with state changes. Will tighten the log noise once the
* runtime takes over from the imperative handlers. */
roomMachines.call.observe(({ state, prev, ev }) => {
if (prev === null || state === prev) return;
logLine('', 'call: ' + prev + ' → ' + state + (ev && ev.type ? ' [' + ev.type + ']' : ''));
});
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…');
roomMachines.call.send('ENTER', { code, handle: myHandle });
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);
roomMachines.call.send('WELCOME', { uuid: myUUID, role: myRole });
/* any tile auto-spotlit before welcome (during fast-path subscribe)
* needs to be broadcast now so the room sees our viewing state */
spotlights.set(myUUID, spotlightKey());
broadcastSpotlight();
reorderTiles();
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');
/* remember which space this tab is in so a hard refresh auto-rejoins.
* sessionStorage is per-tab so tab A in space X + tab B in space Y
* stay independent and clear cleanly on tab close. */
try { sessionStorage.setItem(ACTIVE_CALL_KEY, $('rdv-code').value.trim()); } catch(_){}
/* publish-resume: only meaningful for roles that can speak. If our
* role is listener (was speaker before but room demoted us on
* rejoin), drop the flags so we don't endlessly prompt. */
if (canSpeak(myRole)){
/* camera silent attempt — getUserMedia normally remembers
* per-origin permission, so the browser won't prompt. If it
* does prompt + the user denies, the flag self-clears. */
let wantCam = false;
try { wantCam = sessionStorage.getItem(ACTIVE_CAM_KEY) === '1'; } catch(_){}
if (wantCam){
setTimeout(() => {
sfuPublishCamera().catch(e => {
logLine('err', 'camera resume failed: ' + e.message);
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
});
}, 250);
}
} else {
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
}
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);
spotlights.delete(m.uuid);
tearPeer(m.uuid);
if (hostUUID === m.uuid) hostUUID = '';
renderRoom();
reorderTiles();
}
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 'spotlight':
/* who's looking at what — drives thumbnail popularity sort + log
* line so the room can see where attention is going */
{ const prev = spotlights.get(m.uuid) || '';
const next = m.key || '';
if (prev === next) break;
spotlights.set(m.uuid, next);
if (m.uuid !== myUUID) logSpotlightChange(m.uuid, next);
reorderTiles();
}
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;
roomMachines.call.send('ROLE_CHANGE', { role: 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();
reorderTiles();
}
}
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();
reorderTiles();
}
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();
/* Everyone subscribes to the SFU. Listeners use it for mic+screen+camera.
* Speakers use it for screen+camera (and as a backup audio path); the
* SFU mic track is suppressed by the ontrack handler when we already
* have a mesh peer for that pubkey, so we don't get double audio. */
sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message));
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));
}
}
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);
}
/* keep sfuSub alive — screen + camera tracks still ride it. The
* ontrack handler suppresses SFU mic when mesh is also up. */
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();
/* still subscribed — listener role needs the same incoming streams */
}
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;
roomMachines.call.send('BOOTED', { by: source });
/* booted = clear auto-rejoin so a refresh doesn't immediately retry */
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
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();
/* belt-and-suspenders for browsers that don't fire track.onended on
* device disappearance (Firefox/BT swap can leave the track in
* 'live' state but emitting silence). If our current track has
* gone dead since the last devicechange, re-acquire. */
if (micStream){
const t = micStream.getAudioTracks()[0];
if (t && (t.readyState === 'ended' || t.muted)){
reacquireMic().catch(e => logLine('err','mic re-acquire on devicechange: '+e.message));
}
}
});
}
/* initial population so the dropdown lists the user's inputs before they
* enter a space (matches what the camera-select does). Labels are blank
* until mic permission is granted; deviceIds still populate so the user
* sees how many inputs exist. */
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;
}
/* auto-rejoin after a hard refresh — sessionStorage carried the rendezvous
* code through the reload. Only fires when we have a handle AND the code,
* and the input hasn't been overridden by ?code= (which still wins so a
* fresh-share URL drops you into the linked space). Multi-tab safe because
* sessionStorage is per-tab. */
(function autoRejoin(){
let saved = '';
try { saved = sessionStorage.getItem(ACTIVE_CALL_KEY) || ''; } catch(_){}
if (!saved) return;
if (!myHandle){ logLine('', 'auto-rejoin pending: no handle yet'); return; }
/* ?code= in URL takes priority over the saved session — if they differ,
* trust the URL (user intentionally navigated to a different space) */
const urlCode = new URLSearchParams(location.search).get('code');
if (urlCode && urlCode !== saved){
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
return;
}
$('rdv-code').value = saved;
logLine('', 'auto-rejoining last space …');
setTimeout(() => { joinSpace().catch(e => logLine('err', 'auto-rejoin failed: ' + e.message)); }, 100);
})();
/* 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;
roomMachines.call.send('LEAVE');
/* explicit leave wipes the auto-rejoin state — user said 'out' */
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
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-spotlight').classList.add('hidden');
spotlight = null;
roomMachines.call.send('DONE');
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">947c4c514376d2995517d8576ac3e3a4</span><br>
sha256 <span class="stamp-sha">583d56867cce3995325c8f35b83388dcbd6fb14584f64dfcac3a7dcb28556f1c</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>