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

3879 lines
179 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>zebra spaces</title>
<style>
@font-face {
font-family: 'chunkfiveregular';
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal; font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: monospace; background: #fff; color: #000;
padding: 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). The cameras column
* grows from its 220px default up to ~35% of the viewport so the
* thumbnails actually use the freed horizontal space — combined with
* the auto-fill #tiles-thumbs grid below, wide columns flow into
* multiple thumb columns instead of leaving the row half-empty. */
.page.controls-collapsed > .controls { display: none; }
.page.controls-collapsed:not(:has(> .cameras-col.hidden)) {
grid-template-columns: minmax(220px, 35%) 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: minmax(220px, 35%) 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 under cameras + screens-thumbs as
* proper tile-thumbs. Click one to spotlight it in the middle (an
* iframe loads at full size); the click also broadcasts the spotlight
* choice through the same channel cameras/screens use, so the log
* shows 'alice now viewing unmario' and the thumb-order popularity
* sort knows people are watching. */
.games-h2 { margin-top: 0.5rem; }
#games-thumbs { display: grid; grid-template-columns: 1fr; gap: 0.45rem; }
/* game thumb is a STATIC tile — never loads the iframe; it's just a
* card with title + 'play' cue. The iframe only mounts when the tile
* is clicked into the spotlight slot. Click → spotlight + broadcast. */
.game-thumb {
border: 1px solid #ddd; background: #fff; padding: 0;
display: grid; grid-template-rows: 1fr auto; gap: 0;
cursor: pointer; position: relative;
aspect-ratio: 16 / 9;
}
.game-thumb:hover { outline: 1px solid #888; }
.game-thumb .game-poster {
display: grid; place-items: center; text-align: center;
font-family: 'chunkfiveregular', serif; font-size: 1.05rem;
padding: 0.5rem; background: #f0f0f0; color: #333;
letter-spacing: 0.02em;
}
.game-thumb .game-meta {
background: #111; color: #ddd; font-size: 0.65rem; padding: 0.2rem 0.4rem;
display: grid; grid-template-columns: 1fr auto; gap: 0.4rem; align-items: center;
}
.game-thumb .game-meta .play-cue { color: #6c6; letter-spacing: 0.04em; }
/* spotlit game shows the iframe at full size in the middle column */
#spotlight > .game-tile {
border: 1px solid #ddd; background: #fff;
display: grid; grid-template-rows: 1fr auto;
}
#spotlight > .game-tile iframe {
width: 100%; height: 80vh;
border: none; background: #fff; display: block;
}
#spotlight > .game-tile .screen-meta {
background: #f0f0f0; color: #333; font-size: 0.7rem; padding: 0.3rem 0.5rem;
display: grid; grid-template-columns: 1fr auto; align-items: center;
}
html.theme-dark .game-thumb { background: #000; border-color: #000; }
html.theme-dark .game-thumb .game-poster { background: #111; color: #ddd; }
html.theme-dark #spotlight > .game-tile { background: #000; border-color: #000; }
html.theme-dark #spotlight > .game-tile iframe { background: #000; }
html.theme-dark #spotlight > .game-tile .screen-meta { background: #111; color: #ddd; }
@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; }
/* hide-panel on mobile must NOT swap the layout into the wide-
* viewport two-column rail (cameras-col + timeline). Keep the
* single-column stack regardless of the controls-collapsed state.
* Wins over the desktop .controls-collapsed selector by being
* inside this media query (later in cascade, equal specificity
* after the matching selector) — using the same class so the
* specificity matches and the order decides. */
.page.controls-collapsed,
html.controls-collapsed-pref .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; }
/* on mobile each thumb takes a full row — a phone viewport is
* narrow enough that splitting into 2 columns of ~200px ends up
* smaller than just stacking. */
#tiles-thumbs { grid-template-columns: minmax(0, 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; }
/* unified popularity-sorted thumb list — screens + cameras + game-shares
* all flow through here, ordered by tileScore(). auto-fill with a
* 180px floor means: narrow columns get 1 thumb per row, wide
* columns (e.g. panel hidden) flow into 2 / 3 / N columns naturally. */
#tiles-thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.45rem;
}
#tiles-h2 { margin-top: 0.5rem; }
#tiles-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.22); color: #fff;
font-family: monospace; font-size: 0.75rem; letter-spacing: 0.05em;
text-transform: uppercase; pointer-events: none;
text-shadow: 0 1px 2px rgba(0,0,0,0.75);
opacity: 0; transition: opacity 0.12s;
z-index: 5;
}
.tile-thumb.viewing { cursor: default; }
.tile-thumb.viewing:hover { outline: none; }
/* keep the underlying stream legible — a soft dim is enough to
* read as 'this is what's in the spotlight slot', no need to
* blacken it. */
.tile-thumb.viewing > video { opacity: 0.75; }
.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 share the unified #tiles-thumbs container with screens
* and game-shares (popularity-sorted). 16:9 letterbox keeps faces
* proportional regardless of incoming stream. */
.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: #000;
/* contain (not cover) so a portrait stream from a mobile camera
* letterboxes inside the 16:9 box instead of being middle-cropped
* into a square. We still reserve a 16:9 slot for layout stability
* across mixed stream orientations. */
aspect-ratio: 16 / 9; object-fit: contain;
}
.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: two-row grid per member — handles stay on one line,
* full pubkey gets its own row (wraps across as many lines as it
* needs). Mod buttons take a third row when present. Never truncate
* the pubkey, never wrap a handle mid-word. */
.member {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr) 1.4rem 5rem;
grid-template-areas:
"badge handle mic meter"
"badge pub pub pub"
"badge acts acts acts";
align-items: center; gap: 0.35rem 0.55rem; padding: 0.35rem 0;
border-bottom: 1px dotted #ccc; font-size: 0.85rem;
}
.member > .badge { grid-area: badge; align-self: start; }
.member > .handle { grid-area: handle; }
.member > .pub-short { grid-area: pub; }
.member > .mic { grid-area: mic; }
.member > .meter { grid-area: meter; }
.member > .mod-actions { grid-area: acts; }
.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 is never truncated. Long handles wrap on word boundaries
* by default; overflow-wrap:break-word allows a single absurdly long
* word to wrap rather than push the column wider. */
.handle { font-weight: bold; overflow-wrap: break-word; min-width: 0; }
.handle .me { color: #060; font-weight: normal; font-size: 0.7rem; margin-left: 0.3rem; }
/* full pubkey, monospace, wraps onto as many lines as it needs */
.pub-short {
font-size: 0.7rem; color: #888;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all; line-height: 1.3;
}
/* full pubkey in the identity panel — same monospace + word-break as
* room rows, slightly bumped readability since this is the one place
* the user is meant to copy their own key for backup. Selectable
* by default. */
.pub-full {
font-size: 0.75rem; color: #555;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all; line-height: 1.3;
user-select: all;
}
html.theme-dark .pub-full { color: #aaa; }
.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;
justify-content: start;
margin-top: 0.2rem;
}
.mod-actions:empty { display: none; } /* no row gap when nothing to do */
/* log lives in its own full-width section beneath the .page grid.
* 240px default, vertical resize handle so the user can drag it
* taller when the chatter outpaces the viewport. */
#sec-log { margin-top: 1.25rem; }
.log {
border: 1px solid #000;
height: 240px;
min-height: 120px;
resize: vertical;
overflow-y: auto;
padding: 0.5rem;
font-size: 0.75rem; line-height: 1.5;
background: #fafafa;
width: 100%;
}
.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) {
/* same template-areas layout, tighter columns. pubkey still
* present on its own row (fox: don't truncate keys). meter row
* trims to the right edge of the visible width. */
.member {
grid-template-columns: 4.5rem minmax(0, 1fr) 1.4rem 3rem;
}
}
</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">
<!-- single popularity-sorted list. Cameras, screen-shares and
game-shares all live here, ordered by tileScore() (host boost
on the owner + viewer count). Games stay below as a fixed
persistent group — they're not popularity-ranked. -->
<h2 id="tiles-h2" class="hidden">shares</h2>
<div id="tiles-thumbs"></div>
<h2 class="games-h2">games</h2>
<div id="games-thumbs"></div>
</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">
</div>
<div class="row" id="row-pubkey">
<span id="pub-full" class="pub-full" title="your persistent ed25519 pubkey — back this up or you can't be 'you' again"></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">
<select id="mic-select" title="audio input device — applies once you have the mic"><option value="">input default</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>
</aside>
</div>
<!-- log moved out of the right column so it can use the full viewport
width. Sits between the .page grid and the page-integrity footer,
starts at 240px tall, vertical-resize handle for growing it. -->
<section id="sec-log">
<h2>log</h2>
<div class="log" id="log"></div>
</section>
<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;
}
/* pubHex straight from a server message — prefers the explicit hex field,
* falls back to decoding the b64 form. Used so role/spot/boot/etc. log lines
* carry the actor's authoritative key (the server already verified the
* signature), independent of the local uuid→handle map. A spoof attempt
* is then visible in the log retroactively: same handle, different pubkey. */
function pubHexFromMsg(m, b64key, hexkey){
if (m && hexkey && m[hexkey]) return m[hexkey];
if (m && b64key && m[b64key]){
try {
const bin = atob(m[b64key].replace(/-/g,'+').replace(/_/g,'/'));
let h=''; for (let i=0;i<bin.length;i++) h += bin.charCodeAt(i).toString(16).padStart(2,'0');
return h;
} catch(_){}
}
return '';
}
/* "handle pubhex" or just "pubhex" if we don't have the member yet. Full
* hex — never truncated. */
function idTag(uuidOrHandle, pubHex){
const mm = (uuidOrHandle && members.get) ? members.get(uuidOrHandle) : null;
const handle = mm ? mm.handle : (uuidOrHandle || '');
if (!pubHex) return handle;
return handle ? (handle+' '+pubHex) : pubHex;
}
/* ==================================================================
* 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';
/* mute state for the current tab — '1' means muted. Survives a hard
* refresh via sessionStorage so the page reconnects in the same state
* the user last set. Cleared on explicit leave. NOT cleared on role
* demotion (mic dropped), because the next promotion should respect
* the user's previously-chosen mute state instead of silently
* un-muting them. */
const MUTE_STATE_KEY = 'zebra-spaces-mute-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(){
const el = $('pub-full');
if (el) el.textContent = myKeys ? 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; }
/* destructive + permanent: the pubkey IS the user — it's how host
* claims, room reservations, cohost grants, blocks, and meeting-life
* blocklists key off you. Without a vault backup there's no way to
* be 'you' again from any device. Spell that out in plain language
* with the hex so the user can copy it if they panic mid-prompt. */
const msg =
'Log out will permanently destroy this identity in this browser.\n\n' +
'pubkey ' + (myKeys ? myKeys.pubHex : '(none)') + '\n\n' +
'After this:\n' +
' • You cannot reclaim host on any space you opened with this key\n' +
' • Cohost / speaker grants tied to this key are gone\n' +
' • The only way back is "backup / restore" — if you have not made a\n' +
' backup, this is one-way.\n\n' +
'Continue?';
if (!confirm(msg)) return;
try { localStorage.removeItem(ID_KEY); localStorage.removeItem(HANDLE_KEY); } catch(_){}
myHandle = ''; $('handle').value = '';
myKeys = await generateIdentity();
renderIdentity();
logLine('', 'logged out — fresh identity '+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;
/* game-share rides its own SFU publisher kind so it can coexist with
* a regular screen share. Region Capture (Chromium) crops the captured
* video to the iframe only so audience members see just the game. */
let sfuGamePC = null, sfuGamePeerID = null, sfuGameStream = 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 }
const gameStreams = new Map(); // pubHex -> MediaStream (kind=game-share)
const gameVideos = 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 || 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 >windowMs = the publisher is
* (probably) gone and we remove the tile. If 'unmute' fires within
* the window — transient network blip, NACK retransmission gap, brief
* CPU pressure on publisher, mobile network handoff, OR (for screen
* shares) a long stretch of static content where the encoder simply
* isn't producing RTP — we cancel the removal.
*
* Window depends on the kind of content (caller passes via windowMs):
* - 60s for cameras: face/motion content keeps RTP flowing most of
* the time, but Firefox Android + cellular handoffs can stall a
* decoded video element on jittery paths well past 15s without
* the publisher being gone. 60s is comfortably above the typical
* NACK + transport-cc recovery window; matches Pion's ICE
* failure timeout, so if the path is genuinely broken the SFU
* fires renegotiation + 'ended' before this anyway.
* - 120s for screen / gameshare: STATIC content (still desktop, a
* paused video, a code editor with no caret motion) doesn't push
* new RTP for long stretches. The encoder genuinely stops emitting
* packets; 120s lets a static screen survive comfortably.
*
* Genuine unshares always resolve through the 'ended' path (within a
* frame of the SFU's removePubFromSub), so these windows only matter
* for slow-failure scenarios (PC dies silently, SSE renegotiation
* dropped, etc.).
*
* Window history:
* - 3s (all kinds): killed live tiles on any transient mute.
* - 15s (all kinds): cameras ok on desktop, static screens vanished.
* - 15s camera / 120s screen+game: cameras OK on desktop, phone on
* cellular still occasionally reaped live faces during jitter.
* - 60s camera / 120s screen+game (current). */
const VIDEO_REMOVE_MUTE_WINDOW_MS = 60000;
const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
function watchVideoTrackForRemoval(track, removeFn, windowMs){
if (!track) return;
if (typeof windowMs !== 'number' || !isFinite(windowMs) || windowMs <= 0){
windowMs = VIDEO_REMOVE_MUTE_WINDOW_MS;
}
let timer = null, removed = false, hasFlowed = false;
const remove = () => {
if (removed) return;
removed = true;
if (timer) { clearTimeout(timer); timer = null; }
try { removeFn(); } catch(_){}
};
const onUnmute = () => {
/* RTP arrived; future mute events are meaningful (a flow that
* existed then stopped — publisher unshared or net dropped) */
hasFlowed = true;
if (timer){
clearTimeout(timer); timer = null;
logLine('', 'video track: RTP resumed before timeout — keeping tile');
}
};
const onMute = () => {
if (removed || timer) return;
/* fresh remote tracks ALWAYS start muted until the first RTP packet
* arrives. If we've never seen data flow on this track, the mute is
* its initial state, not a publisher unshare — don't prune. */
if (!hasFlowed) return;
timer = setTimeout(() => {
timer = null;
if (!removed && track.muted){
logLine('', 'video track muted >'+(windowMs/1000)+'s — removing tile');
remove();
}
}, windowMs);
};
track.addEventListener('ended', remove);
track.addEventListener('mute', onMute);
track.addEventListener('unmute', onUnmute);
}
/* 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:'tiles-thumbs', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
camera: { thumbContainer:'tiles-thumbs', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
gameshare: { thumbContainer:'tiles-thumbs', tileClass:'screen-tile', labelPrefix:'gameplay', store: gameVideos, streams: gameStreams },
};
/* 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;
}
/* games are tiles too. The 'pubHex' for a game is its short id below;
* spotlight broadcasts use 'game:<id>' as the key, so the room sees
* 'alice now viewing unmario' in the log just like 'alice now viewing
* bob's screen'. */
const GAMES = {
unmario: { label: 'unmario', src: 'https://unmario.com/' },
cake: { label: 'cake murder adventure', src: 'https://cuppcb.com/games/cake-murder-adventure/' },
};
function ownerLabel(kind, pubHex){
if (kind === 'game'){
const g = GAMES[pubHex];
return g ? g.label : 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);
}
/* game thumbs are STATIC cards. No iframe → no game JS, no network, no
* audio. The game only loads (in an iframe in the spotlight slot) when
* the tile is clicked. Click also broadcasts the spotlight choice so
* the room sees 'alice now viewing unmario'. */
function buildGameThumb(id){
const g = GAMES[id]; if (!g) return null;
const tile = document.createElement('div');
tile.className = 'game-thumb';
tile.dataset.pubHex = id;
tile.dataset.kind = 'game';
const poster = document.createElement('div');
poster.className = 'game-poster';
poster.textContent = g.label;
tile.appendChild(poster);
const meta = document.createElement('div'); meta.className = 'game-meta';
const who = document.createElement('span'); who.textContent = 'game';
const cue = document.createElement('span'); cue.className = 'play-cue'; cue.textContent = '▶ play';
meta.appendChild(who); meta.appendChild(cue);
tile.appendChild(meta);
const view = document.createElement('div'); view.className = 'viewing-badge'; view.textContent = 'viewing';
tile.appendChild(view);
tile.addEventListener('click', () => {
if (spotlight && spotlight.kind === 'game' && spotlight.pubHex === id) return;
setSpotlight('game', id);
});
return tile;
}
function renderGamesColumn(){
const c = $('games-thumbs'); if (!c) return;
c.innerHTML = '';
for (const id of Object.keys(GAMES)){
const t = buildGameThumb(id);
if (t) c.appendChild(t);
}
}
/* 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;
}
/* one popularity-sorted list across screens + cameras + game-shares.
* Each thumb stamps its kind on tile.dataset.kind so tileScore can read
* the owner role and viewer count via the right TILE_KINDS entry. */
function reorderTiles(){
const c = $('tiles-thumbs'); if (!c) return;
const els = Array.from(c.children);
els.sort((a, b) => tileScore(b.dataset.kind, b.dataset.pubHex) - tileScore(a.dataset.kind, a.dataset.pubHex));
for (const el of els) c.appendChild(el);
}
function broadcastSpotlight(){
/* fire-and-forget to the signal server; no-op if not connected yet.
* Suppressed during role transitions — otherwise removeScreenTile +
* removeCameraTile triggered by sfuUnpublishScreen/Camera during a
* demotion fire pickNextSpotlight → broadcasts an empty spotlight,
* which every other peer logs as 'X looked away'. False signal. */
if (typeof inRoleTransition !== 'undefined' && inRoleTransition) return;
try { send({ type: 'spotlight', key: spotlightKey() }); } catch(_){}
}
function logSpotlightChange(uuid, key, viewerPubHex){
/* viewerPubHex is the authoritative pubkey from the server's spotlight
* broadcast; for self-spotlight calls we fall back to my own key. */
const mm = members.get(uuid);
const pub = viewerPubHex || (uuid === myUUID && myKeys ? myKeys.pubHex : (mm && mm.pubkey ? pubHexFromMsg({pubkey:mm.pubkey},'pubkey') : ''));
const who = idTag(uuid, pub);
if (!key){ logLine('', who + ' looked away'); return; }
const [kind, pubHex] = key.split(':');
logLine('', who + ' now viewing ' + ownerLabel(kind, pubHex) + (pubHex?' ['+pubHex+']':''));
}
function updateContainerVisibility(){
$('sec-spotlight').classList.toggle('hidden', !spotlight);
/* sec-cameras is always visible (games are persistent residents).
* 'shares' header only appears when at least one camera / screen /
* game-share thumb is live. */
const anyThumb = cameraVideos.size + screenVideos.size + gameVideos.size > 0;
$('tiles-h2').classList.toggle('hidden', !anyThumb);
}
/* 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 };
}
/* find a thumb element by kind+pubHex regardless of whether it's a
* camera/screen tile or a game card. Returned element gets the
* 'viewing' class toggle so the thumb→spotlight mapping is obvious. */
function thumbElementFor(kind, pubHex){
if (kind === 'game'){
return document.querySelector('.game-thumb[data-pub-hex="'+pubHex+'"]');
}
const e = getEntry(kind, pubHex);
return e ? e.tile : null;
}
function setSpotlight(kind, pubHex){
/* unmark previous spotlight's thumb + tear its big tile */
if (spotlight){
const prevThumb = thumbElementFor(spotlight.kind, spotlight.pubHex);
if (prevThumb) prevThumb.classList.remove('viewing');
clearSpotlightDOM();
spotlight = null;
}
if (kind === 'game'){
const g = GAMES[pubHex]; if (!g){ updateContainerVisibility(); return; }
const big = document.createElement('div'); big.className = 'game-tile';
const iframe = document.createElement('iframe');
iframe.src = g.src;
iframe.title = g.label;
iframe.setAttribute('referrerpolicy', 'no-referrer');
iframe.loading = 'eager';
const meta = document.createElement('div'); meta.className = 'screen-meta';
const who = document.createElement('span'); who.textContent = 'game: ' + g.label;
const ctl = document.createElement('span');
/* 'share gameplay' publishes the iframe (via Region-Capture cropping
* on Chromium, or whole-tab fallback elsewhere) to a SEPARATE SFU
* publisher kind (kind=game) so it coexists with a regular screen
* share. Listeners and other speakers see a gameplay tile in their
* screens-thumbs column. Only speakers can publish. */
if (canSpeak(myRole)){
const shareBtn = document.createElement('button');
shareBtn.className = 'small';
const refreshLabel = () => { shareBtn.textContent = sfuGamePC ? 'stop sharing' : 'share gameplay'; };
refreshLabel();
shareBtn.onclick = (ev) => {
ev.stopPropagation();
if (sfuGamePC){
sfuUnpublishGame().then(refreshLabel);
} else {
logLine('', 'share gameplay: pick this tab in the picker');
sfuPublishGame(iframe).then(refreshLabel).catch(()=>refreshLabel());
}
};
ctl.appendChild(shareBtn);
}
meta.appendChild(who); meta.appendChild(ctl);
big.appendChild(iframe); big.appendChild(meta);
$('spotlight').appendChild(big);
const thumb = thumbElementFor('game', pubHex);
if (thumb) thumb.classList.add('viewing');
spotlight = { kind, pubHex, tile: big, video: null };
} else {
const entry = getEntry(kind, pubHex);
if (!entry){ updateContainerVisibility(); return; }
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();
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;
}
/* spotlight promotion rules:
* - no spotlight yet → first tile auto-promotes (anyone)
* - listener role + new SCREEN arrives → auto-promote so the
* audience always sees the most recent share without clicking;
* speakers/hosts get to keep their current focus
* - listener role + new CAMERA → don't override an active screen
* spotlight (cameras are less load-bearing than a screen-share)
*/
if (isNew){
const isListener = !canSpeak(myRole);
if (!spotlight){
setSpotlight(kind, pubHex);
} else if (isListener && !local && kind === 'screen' && spotlight.kind !== 'screen'){
/* listener: a screen just arrived and we're spotlighting a camera
* or game — switch to the screen */
setSpotlight(kind, pubHex);
} else if (isListener && !local && kind === 'screen' && spotlight.kind === 'screen'){
/* listener: prefer the newer screen over the older one */
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) return; /* idempotent — re-entry from signal reconnect is fine */
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;
watchPublishPC(pc, 'mic', sfuPublish, () => sfuPubPC === pc, () => { sfuPubPC = null; sfuPubPeerID = null; });
logLine('', 'sfu: publishing as '+sfuPubPeerID);
}
/* Publish-side ICE failure recovery. The SFU reaps a failed publisher
* (OnConnectionStateChange in the Go side calls h.removePublisher), so
* any listener that arrives during the failure window gets an SDP with
* zero m-lines and never sees audio/video until the host re-publishes.
* Bug observed: fxhp's mic/screen/camera all failed ICE; a listener
* who joined while they were down got nothing until fxhp left + rejoined.
*
* The page now auto-rebuilds: on 'failed' on any of the publish PCs we
* tear down the dead PC and call the publish entrypoint again. wasOurs
* guards against the racey case where the user explicitly stopped
* sharing between the 'failed' fire and our reaction (e.g. closing a
* screen-share). cleanup runs synchronously before the rebuild so the
* idempotent guard at the top of sfuPublishX (`if (sfuXPC) return`)
* doesn't bail us out of the recovery. */
function watchPublishPC(pc, label, rebuildFn, wasOurs, cleanup){
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed') return;
if (!wasOurs()) return;
logLine('err', 'sfu '+label+' publish PC failed — rebuilding');
try { pc.close(); } catch(_){}
cleanup();
rebuildFn().catch(e => logLine('err','sfu '+label+' re-publish: '+e.message));
};
}
/* ----- 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);
}
watchPublishPC(pc, 'screen', sfuPublishScreen,
() => sfuScreenPC === pc,
() => { sfuScreenPC = null; sfuScreenPeerID = null; sfuScreenStream = null; });
logLine('', 'sfu: sharing screen as '+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');
}
/* ----- game-share publish (kind=game) — separate slot from regular
* screen-share so the two can coexist. Region Capture (Chromium) crops
* the tab capture to just the iframe element so audience members see
* the gameplay without the surrounding UI. Firefox lacks Region
* Capture so it falls back to whole-tab; the user controls what to
* share via the picker. */
async function sfuPublishGame(iframe){
if (sfuGamePC || !myKeys || !roomID) return;
let stream;
try {
const constraints = {
video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} },
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
channelCount:2, sampleRate:48000 },
};
constraints.preferCurrentTab = true; /* Chromium hint */
stream = await navigator.mediaDevices.getDisplayMedia(constraints);
} catch(e){ logLine('err','game-share cancelled: '+e.message); return; }
sfuGameStream = stream;
/* Region Capture: crop the captured video to the iframe element. Only
* works if user picked the current tab + browser supports CropTarget. */
try {
if (iframe && window.CropTarget && typeof CropTarget.fromElement === 'function'){
const cropTarget = await CropTarget.fromElement(iframe);
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack && typeof videoTrack.cropTo === 'function'){
await videoTrack.cropTo(cropTarget);
logLine('', 'game-share: cropped to iframe');
}
}
} catch(e){ logLine('', 'game-share: crop failed ('+e.message+'), sharing full tab'); }
const pc = new RTCPeerConnection(rtcConfig);
for (const tr of stream.getTracks()){
if (tr.kind === 'video') tr.contentHint = 'motion';
if (tr.kind === 'audio') tr.contentHint = 'music';
pc.addTrack(tr, stream);
}
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishGame(); });
const offer = await pc.createOffer();
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=game';
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()); sfuGameStream = null; throw e; }
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; handleBlocked('publish-game'); return; }
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; throw new Error('sfu publish-game http '+res.status); }
const ans = await res.json();
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
sfuGamePC = pc; sfuGamePeerID = ans.peer_id;
for (const s of pc.getSenders()){
if (!s.track) continue;
if (s.track.kind === 'video') setSenderMaxBitrate(s, 4000000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
}
/* game-share doesn't auto-rebuild on 'failed' — its capture source is
* a user-selected iframe via Region Capture; the user would have to
* pick it again anyway. Just surface the failure clearly and tear
* down the dead PC so the share button comes back. */
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed' || sfuGamePC !== pc) return;
logLine('err', 'sfu game-share PC failed — stop and re-share to recover');
sfuUnpublishGame().catch(()=>{});
};
logLine('', 'sfu: sharing gameplay as '+sfuGamePeerID);
}
async function sfuUnpublishGame(){
if (!sfuGamePC && !sfuGameStream) return;
const pid = sfuGamePeerID;
if (sfuGameStream){ sfuGameStream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; }
if (sfuGamePC){ try { sfuGamePC.close(); } catch(_){} sfuGamePC = null; sfuGamePeerID = null; }
if (pid && roomID){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
}
logLine('', 'game-share stopped');
}
/* ----- camera publish (kind=camera) ----- */
async function sfuPublishCamera(){
if (sfuCameraPC || !myKeys || !roomID) return;
let stream;
/* aspectRatio:{ideal: 16/9} tells Android Chrome / Firefox to capture
* in landscape regardless of the device's current screen orientation.
* Without it the back camera in portrait delivers a portrait stream
* that the receivers then cover-crop into a square. With it the
* sensor reads out landscape and rotation no longer matters. */
const videoConstraints = {
width: { ideal: 1280 }, height: { ideal: 720 },
aspectRatio: { ideal: 16/9 },
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);
}
watchPublishPC(pc, 'camera', sfuPublishCamera,
() => sfuCameraPC === pc,
() => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; });
logLine('', 'sfu: camera on as '+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(_){}
}
/* On mobile the camera sensor's natural read-out orientation is locked
* at getUserMedia() time. Rotating the phone does NOT update the encoded
* frames' orientation — listeners see whatever was captured initially,
* regardless of how the publisher is holding the device now.
*
* Fix: when the device orientation changes while we're publishing, re-
* acquire the camera so the sensor reads out in the new orientation.
* We swap the track in the existing publisher's RTCRtpSender via
* replaceTrack — no SDP renegotiation, no SFU-side supplant, no
* subscriber renegotiation. Subscribers just start receiving frames
* in the new orientation a few hundred ms later.
*
* Debounced 350ms so a fast orientation flick doesn't trigger two
* back-to-back captures. */
let cameraOrientationDebounce = null;
async function reacquireCameraForOrientation(){
if (!sfuCameraPC || !sfuCameraStream || !myKeys) return;
const constraints = {
width: { ideal: 1280 }, height: { ideal: 720 },
aspectRatio: { ideal: 16/9 },
frameRate: { ideal: 30 },
};
if (cameraDeviceId) constraints.deviceId = { exact: cameraDeviceId };
let fresh;
try { fresh = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false }); }
catch(e){ logLine('err','camera re-acquire failed: '+e.message); return; }
const newVideo = fresh.getVideoTracks()[0];
if (!newVideo){ fresh.getTracks().forEach(t=>t.stop()); return; }
newVideo.contentHint = 'motion';
newVideo.addEventListener('ended', () => { sfuUnpublishCamera(); });
/* swap the sender's track */
let swapped = false;
for (const s of sfuCameraPC.getSenders()){
if (s.track && s.track.kind === 'video'){
try { await s.replaceTrack(newVideo); swapped = true; break; }
catch(e){ logLine('err','replaceTrack: '+e.message); }
}
}
if (!swapped){ fresh.getTracks().forEach(t=>t.stop()); return; }
/* stop the old stream's tracks AFTER swap so the encoder has the
* new source ready before the old one ends */
const old = sfuCameraStream;
sfuCameraStream = fresh;
if (old) old.getTracks().forEach(t=>t.stop());
renderCameraTile(myKeys.pubHex, fresh, { local: true });
const w = newVideo.getSettings ? (newVideo.getSettings().width||'?') : '?';
const h = newVideo.getSettings ? (newVideo.getSettings().height||'?') : '?';
logLine('', 'camera re-captured for new orientation ('+w+'x'+h+')');
}
function onCameraOrientationChange(){
if (cameraOrientationDebounce) clearTimeout(cameraOrientationDebounce);
cameraOrientationDebounce = setTimeout(() => {
cameraOrientationDebounce = null;
reacquireCameraForOrientation();
}, 350);
}
if (window.screen && window.screen.orientation && window.screen.orientation.addEventListener){
window.screen.orientation.addEventListener('change', onCameraOrientationChange);
} else {
window.addEventListener('orientationchange', onCameraOrientationChange);
}
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' || kind === 'camera' || kind === 'game'){
logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex +
' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState);
}
/* MSID-supplant safety: the SFU re-uses the same streamID
* (`shortPub-kind`) when a publisher supplants themselves. WebRTC
* merges the new track into the EXISTING MediaStream — ev.streams[0]
* is literally the same instance as before, containing both the
* dead old track AND the new live one. Setting srcObject to that
* stream doesn't switch the playing track; the video element keeps
* showing the (now-ended) old track's last frame and reports muted.
* Construct a fresh MediaStream containing only the new track so
* the video element binds to the new RTP flow cleanly.
*
* Stream-identity guard on removeFn: when the OLD track's mute →
* ended → removeFn would tear down the tile that the NEW track
* just installed. Only remove if the stream we registered against
* is still the one in the store for this pub. */
if (kind === 'screen'){
const s = new MediaStream([ev.track]);
screenStreams.set(pubHex, s);
renderScreenTile(pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
return;
}
if (kind === 'camera'){
const s = new MediaStream([ev.track]);
cameraStreams.set(pubHex, s);
renderCameraTile(pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS);
return;
}
if (kind === 'game'){
/* a publisher is sharing their gameplay (Region-Capture cropped
* iframe). Route to its own TILE_KIND so it coexists with a
* normal screen-share from the same person. */
const s = new MediaStream([ev.track]);
gameStreams.set(pubHex, s);
renderVideoTile('gameshare', pubHex, s);
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS);
return;
}
if (kind !== 'mic'){
logLine('', 'sfu: unknown kind '+kind+' from '+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;
/* survive laptop-lid-close / network suspend. When the OS suspends the
* browser the SFU sub PC goes 'disconnected' first (transient) then
* 'failed'. On 'failed' the existing PC is dead — RTP can never
* recover even if we resume — so we tear it down and rebuild via
* sfuUnsubscribe + sfuSubscribe. The auto-rejoin code that follows
* a hard refresh handles the WS side; this handles the SFU side. */
pc.onconnectionstatechange = () => {
/* 'failed' is terminal ICE failure (we should rebuild).
* 'closed' is OUR OWN sfuUnsubscribe() — never rebuild on that
* (Chrome fires the state change synchronously before sfuSubPC is
* null'd, which used to trigger a subscribe→close→subscribe loop
* every time anyone deliberately tore down the sub).
* 'disconnected' is transient — let WebRTC try to recover before
* we yank the rug. */
if (pc.connectionState !== 'failed') return;
if (sfuSubPC !== pc) return;
logLine('err', 'sfu sub PC failed — rebuilding');
sfuUnsubscribe().then(() => {
if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message));
});
};
/* 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 '+sfuSubPeerID);
}
async function sfuUnsubscribe(){
if (sfuSubEvents){ try { sfuSubEvents.close(); } catch(_){} sfuSubEvents = null; }
/* null out sfuSubPC FIRST, then close — Chrome fires the
* connectionstatechange handler SYNCHRONOUSLY during pc.close(), and
* the handler's `if (sfuSubPC === pc)` guard depends on the global
* already being null. Otherwise the handler thinks the close was a
* 'failed' rebuild trigger and we get a subscribe/close loop. */
const oldPC = sfuSubPC;
sfuSubPC = null;
sfuSubPeerID = null;
if (oldPC){ try { oldPC.close(); } catch(_){} }
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="">input default</option>'; return; }
mics.forEach((m,i)=>{
const o = document.createElement('option');
/* prefix every option with 'input' so the dropdown reads as
* 'input WH-1000XM5' / 'input Built-in Mic'. We dropped the
* separate 'input' label that used to live alongside the row;
* the prefix keeps the meaning while freeing the column. */
o.value = m.deviceId; o.textContent = 'input ' + (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 '+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':
{
/* a second welcome arrives whenever the signal-WS reconnects.
* If our uuid hasn't changed, the SFU + mesh are still alive —
* just refresh state + role, don't re-run the whole setup
* (that's what caused 'sfu publish: already publishing' +
* other duplicate-effect noise during reconnect cycles). */
const reentry = !!myUUID && myUUID === m.your_uuid;
myUUID = m.your_uuid; myRole = m.role; roomEpoch = m.epoch;
applyState(m.state);
roomMachines.call.send('WELCOME', { uuid: myUUID, role: myRole });
if (reentry){
logLine('', 'signal re-welcomed — role still '+myRole);
flushSfuStreams();
renderRoom();
break;
}
}
/* 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 '+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 });
{ const pub = pubHexFromMsg(m, 'pubkey'); logLine('', idTag(m.uuid, pub)+' 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);
const pub = pubHexFromMsg(m, 'pubkey') || (left && left.pubkey ? pubHexFromMsg({pubkey:left.pubkey}, 'pubkey') : '');
if (left || pub) logLine('', idTag(m.uuid, pub)+' 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 '+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, pubHexFromMsg(m,'pubkey'));
reorderTiles();
}
break;
case 'hand-raised':
handraise.add(m.uuid); renderRoom();
{ const pub = pubHexFromMsg(m, 'pubkey'); logLine('', idTag(m.uuid, pub)+' raised hand'); }
break;
case 'hand-lowered':
handraise.delete(m.uuid); renderRoom();
{ const pub = pubHexFromMsg(m, 'pubkey'); logLine('', idTag(m.uuid, pub)+' lowered hand'); }
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:m.from);
$('sec-invite').classList.remove('hidden');
const fromPub = pubHexFromMsg(m, 'from_pubkey');
logLine('', 'you were invited to the mic by '+idTag(m.from, fromPub));
} else {
const fromPub = pubHexFromMsg(m, 'from_pubkey');
const toPub = pubHexFromMsg(m, 'to_pubkey');
logLine('', idTag(m.from, fromPub)+' invited '+idTag(m.to, toPub)+' to the mic');
}
break;
case 'mic-invite-declined':
if (m.from === myUUID){
const toPub = pubHexFromMsg(m, 'to_pubkey');
logLine('', idTag(m.to, toPub)+' 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;
const subjPub = pubHexFromMsg(m, 'pubkey', 'pubkey_hex') || myKeys && (m.uuid===myUUID ? myKeys.pubHex : '');
const byPub = pubHexFromMsg(m, 'by_pubkey');
if (m.uuid === myUUID){
myRole = m.role;
roomMachines.call.send('ROLE_CHANGE', { role: m.role });
logLine('', 'you are now '+m.role+(subjPub?' ['+subjPub+']':'')+(m.by?' by '+idTag(m.by, byPub):''));
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('', idTag(m.uuid, subjPub)+' is now '+m.role+(m.by?' by '+idTag(m.by, byPub):''));
/* mesh adjustments — fox: 'never drop people out of the mesh
* automatically'. Promote: connect if we can speak + they
* can speak + we don't already have them. Demote: do NOT
* tear existing mesh peer connections; let them ride as a
* back-channel until one side actually leaves the room. */
if (canSpeak(myRole) && canSpeak(m.role) && !peers.has(m.uuid)){
connectToPeer(m.uuid, myUUID < m.uuid);
}
}
renderRoom();
reorderTiles();
}
}
break;
case 'peer-booted':
{
const mm = members.get(m.uuid);
const by = members.get(m.by);
const victPub = pubHexFromMsg(m, 'pubkey');
const byPub = pubHexFromMsg(m, 'by_pubkey');
logLine('', idTag(m.uuid, victPub)+' was removed by '+(by||byPub?idTag(m.by, byPub):'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(()=>{});
sfuUnpublishGame().catch(()=>{});
sfuUnsubscribe().catch(()=>{});
dropMic();
}
members.delete(m.uuid); tearPeer(m.uuid); handraise.delete(m.uuid);
renderRoom();
}
break;
case 'host-left':
{ const pub = pubHexFromMsg(m, 'host_pubkey'); logLine('', 'host left the space — '+idTag(m.host_uuid, pub)); }
hostUUID = '';
break;
case 'host-promoted':
roomEpoch = m.epoch;
hostUUID = m.new_host_uuid;
{ const mm = members.get(m.new_host_uuid);
const pub = pubHexFromMsg(m, 'new_host_pubkey');
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('', idTag(m.new_host_uuid, pub)+' is now host'); }
flushSfuStreams();
renderRoom();
reorderTiles();
}
break;
case 'space-closing':
/* host left — 2h deadman is running. Only the host can chime to
* extend; cohosts can moderate the room during the absence but
* cannot keep the room alive. Room WILL close at grace expiry
* unless the host reclaims. */
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));
}
}
let inRoleTransition = false;
async function onRoleChanged(prev, next){
/* suppress spotlight broadcasts triggered by tile cleanup during the
* role transition — otherwise removeScreenTile / removeCameraTile
* fires pickNextSpotlight which broadcasts an empty spotlight key,
* which every other peer logs as 'X looked away'. */
inRoleTransition = true;
try {
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)){
/* demote to listener: drop everything we PUBLISH (mic + screen +
* camera + game) but DON'T tear mesh peers. fox: 'never drop
* people out of the mesh automatically'. The mesh connections
* stay up as a bonus low-latency audio path; they get GC'd
* naturally when the other end leaves or also demotes. */
dropMic(); muted = false;
await sfuUnpublish();
await sfuUnpublishScreen();
await sfuUnpublishCamera();
await sfuUnpublishGame();
}
updateRoleUI();
} finally {
inRoleTransition = false;
}
}
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;
/* restore the user's last mute choice. Hard refresh keeps
* sessionStorage so reconnecting in muted state is preserved
* without anyone hearing a hot-mic moment. */
applyMuteState();
if (muted) sendMicState();
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();
if (!canSpeak(myRole) && (sfuGamePC || sfuGameStream)) sfuUnpublishGame();
}
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 '+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 '+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 = 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;
try { muted = sessionStorage.getItem(MUTE_STATE_KEY) === '1'; } catch(_){}
function applyMuteState(){
/* keep button UI, mic-track enable, room row, and peer broadcast in
* sync. Used by both the click handler and the post-mic-acquire
* restore path (so a hard refresh comes back muted if that's how the
* user left it). Safe to call without a mic — guards each side. */
if (micStream){ micStream.getAudioTracks().forEach(t=>t.enabled=!muted); }
$('btn-mute').textContent = muted ? 'unmute' : 'mute';
$('btn-mute').className = muted ? 'invert' : '';
const mm = members.get(myUUID);
if (mm){ mm.muted = muted; renderRoom(); }
}
$('btn-mute').addEventListener('click', () => {
if (!micStream) return;
muted = !muted;
try { sessionStorage.setItem(MUTE_STATE_KEY, muted ? '1' : '0'); } catch(_){}
applyMuteState();
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();
/* laptop-lid-close / sleep / suspend recovery: when the tab comes back
* to visible, check whether our SFU sub PC is still in a healthy state.
* Some browsers (Chromium on Linux specifically) don't fire
* connectionstatechange on suspend → resume — the PC sits silently in
* 'connected' but no RTP flows. If it's in any non-live state, force
* the rebuild. */
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
if (!wantConnected || !sfuSubPC) return;
/* Only rebuild on terminal failure. 'disconnected' is transient
* (WebRTC tries to recover); 'closed' never happens here because
* sfuUnsubscribe() nulls sfuSubPC before pc.close(). */
if (sfuSubPC.connectionState !== 'failed') return;
logLine('err', 'visibility back, sub PC failed — rebuilding');
sfuUnsubscribe().then(() => {
if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message));
});
});
/* ==================================================================
* 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);
})();
/* games as left-column tiles — click to spotlight, broadcast through
* the same channel cameras/screens use so the room sees the choice */
renderGamesColumn();
$('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);
/* send 'bye' BEFORE closing the WS — server distinguishes a strong
* leave (user clicked leave / closed tab) from a hiccup disconnect
* (refresh, network blip). On bye, the server broadcasts peer-left
* immediately; without it, the server defers the broadcast behind an
* 8s grace so a refresh can rejoin invisibly. */
if (ws){
try { ws.send(JSON.stringify({ type: 'bye' })); } catch(_){}
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;
try { sessionStorage.removeItem(MUTE_STATE_KEY); } catch(_){}
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-03</span><br>
md5 <span class="stamp-md5">c84ac1d4ba2674e2927e3b741b593dfb</span><br>
sha256 <span class="stamp-sha">95f894002cb10ec9fdf575fbc04d6f5c55a99027fb2cb5d93c72ac3b54cb614f</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>