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

6390 lines
302 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<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;
/* extra 1.4rem column for the per-speaker stream toggle — lives on the
* top row next to the mic icon so it never wraps + never adds row
* height (the original 3-row layout is preserved). Single-char button
* (○ / ◉) keeps the column tight. */
grid-template-columns: 5.5rem minmax(0, 1fr) 1.4rem 1.4rem 5rem;
grid-template-areas:
"badge handle mic strm meter"
"badge pub pub pub pub"
"badge acts 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 > .stream-toggle { grid-area: strm; justify-self: center; }
.member > .meter { grid-area: meter; }
.member > .mod-actions { grid-area: acts; }
/* single-char stream toggle: ○ when off, ◉ when on. Keep the button
* compact so it fits the 1.4rem column without forcing the row taller. */
.stream-toggle {
font-size: 0.95rem; line-height: 1; padding: 0.1rem 0.3rem;
background: transparent; border: 1px solid #888; cursor: pointer;
color: inherit;
}
.stream-toggle.on { background: #000; color: #fff; border-color: #000; }
html.theme-dark .stream-toggle { border-color: #aaa; }
html.theme-dark .stream-toggle.on { background: #fff; color: #000; border-color: #fff; }
.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: two block-level row-groups stacked vertically
* (.acts-primary, .acts-removal); buttons inside flow as
* inline-block so they pack horizontally and naturally wrap to a
* second line only when the panel is genuinely too narrow.
*
* Previous attempts used `repeat(auto-fit, max-content)` per row-
* group, but that grid template collapses to one column when no
* fixed sizing function is provided — every button ended up on
* its own row in a stack. Inline-block + text-flow wrap is the
* older, more reliable pattern here and doesn't need flex. */
.mod-actions { margin-top: 0.2rem; }
.mod-actions:empty { display: none; }
.acts-primary, .acts-removal {
display: block;
margin-top: 0.2rem;
}
.acts-primary:empty, .acts-removal:empty { display: none; }
.acts-primary > button, .acts-removal > button {
margin: 0 0.3rem 0.2rem 0;
vertical-align: middle;
}
/* 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" id="row-entry">
<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" class="hidden" disabled>mute</button>
<button id="btn-leave" class="hidden" 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-speaker-controls">
<select id="speaker-select" title="audio output device — route peers' audio to a specific speaker or DAC"><option value="">output 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-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>
<p class="note" style="margin-top:0.4rem">you join muted so you don't interrupt the current conversation — tap "unmute" when you're ready to speak.</p>
</section>
<!-- Role-transition notices (you are now a speaker / you were stepped
down / you were moved to listener / etc.) live BELOW the 'your hand'
section so the user reads them in the same gaze as the controls they
affect — they're status updates about you, not page-level alerts. -->
<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>
</aside>
</div>
<!-- latency, link, and log all sit BELOW the .page grid so they can
use the full viewport width — useful on desktop (more room for
latency rows) and essential on mobile (where the right-column
panel is narrow and these rows wrap awkwardly inside it). -->
<section id="sec-latency" class="hidden">
<h2>latency</h2>
<div id="latency-rows" class="latency-rows"></div>
<span class="note">RTT from <code>candidate-pair.currentRoundTripTime</code> + path kind (host = LAN, srflx = direct WAN, relay = through TURN). Updates every 2s.</span>
</section>
<section id="sec-share" class="hidden">
<h2>link</h2>
<p class="note" style="margin-bottom:0.5rem">share only with people you trust to hear the room.</p>
<div class="row">
<input type="text" id="share-url" readonly>
<button id="btn-copy-share" class="small">copy</button>
</div>
<div id="qr-host" class="row" style="display:none">
<canvas id="share-qr" width="220" height="220" aria-label="QR code for this space"></canvas>
</div>
</section>
<section 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');
/* Global error handler — runtime errors used to die silently (no
* stack trace anywhere), so a broken button just stopped working
* with no signal-server visibility. Now every uncaught exception
* AND unhandled-promise-rejection forwards to CLIENT_LOG as kind=err. */
window.addEventListener('error', (ev) => {
try { logLine('err', 'JS error: '+ev.message+' at '+(ev.filename||'?')+':'+(ev.lineno||'?')); } catch(_){}
});
window.addEventListener('unhandledrejection', (ev) => {
try { logLine('err', 'JS unhandled: '+(ev.reason && (ev.reason.message || ev.reason.toString()) || 'unknown')); } catch(_){}
});
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;
/* Forward to signal so an operator can grep /var/log/zebra-spaces-
* signal.log for CLIENT_LOG across every connected peer without
* copy-pasting from individual browser tabs. Best-effort: silently
* drops if the WS isn't open. Only RTC/system events flow through
* logLine — no user-typed content. */
try {
if (typeof ws !== 'undefined' && ws && ws.readyState === 1){
ws.send(JSON.stringify({ type: 'client-log', kind: kind || '', msg: String(msg).slice(0, 2000) }));
}
} catch(_){}
}
/* 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',
/* bootedAction — 'kick' | 'ban' | 'blocked' | null. The UI observer
* derives the kicked-vs-banned-vs-blocked status text + whether
* btn-enter should be re-enabled (kick: yes, ban/blocked: no). */
context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, bootedAction: null, lastError: null },
states: {
idle: {
entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; ctx.bootedAction = 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; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } },
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; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } },
},
},
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; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } },
},
},
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' },
},
},
};
/* ==================================================================
* SelfListenerFSM — a speaker / cohost / host who's flipped their
* own row's stream toggle to consume the room via the buffered HTTP
* Ogg/Opus path instead of the live WebRTC mesh.
*
* off ──ENABLE / TOGGLE──▶ on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off
*
* The UNMUTE edge encodes fox's invariant: "unmuting should
* seamlessly switch them back to the now of the conversation
* webrtc mesh" — if the user clicks unmute while in on, they
* implicitly want to go back to the live path.
*
* Pure spec — side effects (mic mute, streamMode population,
* remoteAudio muting) live in the runtime's observer attached to
* this FSM. Keeps it testable in Node. */
const selfListenerSpec = {
initial: 'off',
context: { enrolledCount: 0 },
states: {
off: {
on: {
TOGGLE: 'on',
ENABLE: 'on',
UNMUTE: 'off', /* no-op self-transition for symmetry */
DEMOTED: 'off',
CLEAR: 'off',
},
},
on: {
on: {
TOGGLE: 'off',
DISABLE: 'off',
UNMUTE: 'off',
DEMOTED: 'off',
CLEAR: 'off',
},
},
},
};
/* ==================================================================
* MuteFSM — speaker's own mic mute state. Two states (on / off)
* with the source tracked in ctx so observers can render the right
* UI ("muted by mod", "muted by self-listener", etc.) and the
* unmute logic knows whether to clear the source.
*
* on ──TOGGLE / FORCE_MUTE / AUTO_MUTE / RESTORE_MUTED──▶ off
* off ──TOGGLE / AUTO_UNMUTE / RESTORE_UNMUTED──▶ on
*
* `source` (null when on, set when off):
* - 'self' — user clicked btn-mute / restored from session
* - 'mod' — server peer-force-muted
* - 'self-listener' — auto-muted because self-listener-mode enabled
*
* Pure spec — side effects (track.enabled, btn-mute text/class,
* member-row icon, sendMicState broadcast) ride observers attached
* during room setup. */
const muteSpec = {
initial: 'on',
context: { source: null },
states: {
on: {
entry: (ctx) => { ctx.source = null; },
on: {
TOGGLE: { target: 'off', action: (ctx) => { ctx.source = 'self'; } },
FORCE_MUTE: { target: 'off', action: (ctx) => { ctx.source = 'mod'; } },
AUTO_MUTE: { target: 'off', action: (ctx) => { ctx.source = 'self-listener'; } },
RESTORE_MUTED: { target: 'off', action: (ctx) => { ctx.source = 'self'; } },
ROLE_PROMOTED: { target: 'off', action: (ctx) => { ctx.source = 'self'; } }, /* default-mute on promote so no hot-mic surprises */
RESTORE_UNMUTED: 'on',
},
},
off: {
on: {
TOGGLE: 'on',
AUTO_UNMUTE: 'on', /* self-listener-mode exited */
RESTORE_UNMUTED: 'on',
RESTORE_MUTED: { target: 'off', action: (ctx) => { ctx.source = 'self'; } }, /* self-transition keeps source */
FORCE_MUTE: { target: 'off', action: (ctx) => { ctx.source = 'mod'; } }, /* upgrade source to mod */
AUTO_MUTE: { target: 'off', action: (ctx) => { ctx.source = 'self-listener'; } },
},
},
},
};
/* ==================================================================
* wireZebraMachines — orchestrator. Composes one CallFSM, one
* SubscribeFSM, three PublishFSMs (mic/screen/camera), one
* SelfListenerFSM, one MuteFSM, 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, selfListener, remoteTiles, tileFor,
* tileLeft }. */
function wireZebraMachines(){
const call = createFSM(callSpec);
const sub = createFSM(subscribeSpec);
const selfListener = createFSM(selfListenerSpec);
const mute = createFSM(muteSpec);
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, selfListener, mute, 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 MIC_LABEL_KEY = 'zebra-spaces-mic-label-v1';
const SPK_DEV_KEY = 'zebra-spaces-spk-device-v1';
const SPK_LABEL_KEY = 'zebra-spaces-spk-label-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';
/* screen-share state is deliberately NOT tracked across reload. Even
* with a click banner the user has to re-pick the surface (browser
* security: getDisplayMedia always shows the picker; selection cannot
* be remembered across a reload). A re-pick is the same friction as
* just clicking 'share screen' again, so we keep the single canonical
* button instead of adding a banner that adds zero net clicks. */
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);
}
/* ed25519 verify against a raw 32-byte pubkey (base64). Returns false
* on any error so callers can treat unsigned / malformed / wrong-pubkey
* uniformly without throwing. */
async function verifyEd25519(pubB64, msgBytes, sigB64){
try {
const pubKey = await crypto.subtle.importKey(
'raw', unb64(pubB64), { name: 'Ed25519' }, false, ['verify']);
return await crypto.subtle.verify('Ed25519', pubKey, unb64(sigB64), msgBytes);
} catch(_){ return false; }
}
/* 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 = '', micDeviceLabel = '', speakerDeviceId = '', speakerDeviceLabel = '', cameraDeviceId = '';
try { musicMode = localStorage.getItem(MUSIC_MODE_KEY) === '1'; } catch(_){}
try { micDeviceId = localStorage.getItem(MIC_DEV_KEY) || ''; } catch(_){}
try { micDeviceLabel = localStorage.getItem(MIC_LABEL_KEY) || ''; } catch(_){}
try { speakerDeviceId = localStorage.getItem(SPK_DEV_KEY) || ''; } catch(_){}
try { speakerDeviceLabel = localStorage.getItem(SPK_LABEL_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
/* Web Audio fallback for listeners. Firefox Android grants <audio>.play()
* autoplay engagement PER ELEMENT PER SOURCE — playing a SILENCE_WAV in
* the pre-blessed pool at entry-click does NOT carry over when
* srcObject is later swapped to a WebRTC MediaStream. Telemetry from
* fox 2026-06-04 confirmed: receiver decodes audio (level=0.399 in
* inbound-rtp stats) but every <audio>.play() rejects with
* "play method is not allowed by the user agent" — pool exhausts,
* recovery still fails. AudioContext has a different gesture model:
* one resume() inside the user gesture covers every MediaStreamSource
* routed through it, no per-source re-engagement needed.
*
* Listener-only because speakers/cohosts/hosts have an active mic +
* setSinkId requirements that still want <audio> elements. Listeners
* don't pick speaker output devices (no UI for it) and don't talk —
* the AudioContext path is simpler and survives Firefox Android. */
const listenerAudioNodes = new Map(); /* uuid -> { src, gain, jbuf?, stream } */
/* Inline AudioWorklet processor — a manual jitter buffer.
*
* Browser-native jitterBufferTarget is a target the receiver "must aim
* for" per spec — but Firefox Android's audio path for high-bitrate
* stereo Opus apparently doesn't yet wire that target into its
* decoder. Observed 2026-06-04: voice tracks hit 1.8s, video hit 4s,
* but a 256 kbps stereo Opus music stream stayed at 0.060.21s on
* the same phone with the same setting. So we buffer ourselves —
* 128-sample blocks pile up in `queue`, we don't start emitting
* until `targetSamples` are buffered, and we cap at `maxSamples` to
* absorb clock drift without growing unbounded.
*
* Loaded as a Blob URL because this is a single-file app — no
* separate JS file shipped. */
const JITTER_BUFFER_WORKLET_CODE = `
class JitterBufferProcessor extends AudioWorkletProcessor {
constructor(opts){
super();
const o = (opts && opts.processorOptions) || {};
this.targetSeconds = o.targetSeconds || 4.0;
this.maxSeconds = o.maxSeconds || (this.targetSeconds * 1.5);
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
/* re-arm only after this many consecutive empty blocks. 128 samples
* per block at 48 kHz = 2.67 ms; 100 blocks ≈ 267 ms of silence.
* brief upstream drains (a single empty process() tick) MUST NOT
* tear down playback, or a 4s re-buffer kicks in every time —
* which is what made the phone choppy. */
this.rearmThresholdBlocks = 100;
/* periodic buffer-depth report so JS can drive the lip-sync
* algorithm (video.playoutDelayHint must match audio total delay
* including this worklet's cushion, else mouths move ~4s ahead
* of the words on listeners). 256 blocks * 128 samples / 48000Hz
* ≈ 683ms — frequent enough to track real changes, sparse enough
* to not flood the port. */
this.bufferedReportEvery = 256;
this.bufferedReportCounter = 0;
this.queue = [];
this.buffered = 0;
this.started = false;
this.emptyStreak = 0;
this.dropped = 0;
/* role-change retarget — JS posts {cmd:'retarget', targetSeconds}
* when the user is promoted/demoted; we recompute the sample
* targets and shrink the queue if the new max is smaller. */
this.port.onmessage = (e) => {
if (!e.data || e.data.cmd !== 'retarget') return;
const t = +e.data.targetSeconds;
if (!isFinite(t) || t <= 0) return;
this.targetSeconds = t;
this.maxSeconds = t * 1.5;
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
while (this.buffered > this.maxSamples && this.queue.length > 0){
const drop = this.queue.shift();
this.buffered -= drop[0].length;
this.dropped += drop[0].length;
}
};
}
process(inputs, outputs){
const inBlk = inputs[0];
const outBlk = outputs[0];
if (!outBlk || outBlk.length === 0) return true;
const nch = outBlk.length;
/* push the incoming block (must copy — host may reuse the buffer
* after process() returns) */
if (inBlk && inBlk.length > 0 && inBlk[0] && inBlk[0].length > 0){
const copy = [];
for (let c = 0; c < inBlk.length; c++) copy.push(new Float32Array(inBlk[c]));
this.queue.push(copy);
this.buffered += copy[0].length;
/* overflow guard — drop oldest if clock drift or network surge
* pushes us above the cap */
while (this.buffered > this.maxSamples && this.queue.length > 0){
const drop = this.queue.shift();
this.buffered -= drop[0].length;
this.dropped += drop[0].length;
}
}
/* lock onto the buffer once it fills. Do NOT un-lock on a single
* empty queue tick — that's what made the phone choppy: any
* 2.67ms drain forced a full 4s re-buffer. emptyStreak tracks
* sustained silence and only re-arms after ~267ms. */
if (!this.started && this.buffered >= this.targetSamples){
this.started = true;
/* notify JS — listener UI stays in "buffering" state until the
* first started message arrives. */
try { this.port.postMessage({ cmd: 'started', targetSeconds: this.targetSeconds }); } catch(_){}
}
if (this.started && this.queue.length > 0){
const head = this.queue.shift();
this.buffered -= head[0].length;
this.emptyStreak = 0;
for (let c = 0; c < nch; c++){
const srcCh = head[c] || head[0]; /* mono → stereo: dup L→R */
outBlk[c].set(srcCh.subarray(0, outBlk[c].length));
}
} else {
for (let c = 0; c < nch; c++) outBlk[c].fill(0);
if (this.started){
this.emptyStreak++;
if (this.emptyStreak >= this.rearmThresholdBlocks){
this.started = false;
this.emptyStreak = 0;
}
}
}
if (++this.bufferedReportCounter >= this.bufferedReportEvery){
this.bufferedReportCounter = 0;
try { this.port.postMessage({ cmd: 'buffered', seconds: this.buffered / sampleRate }); } catch(_){}
}
return true;
}
}
registerProcessor('jitter-buffer', JitterBufferProcessor);
`;
let workletReady = false, workletLoading = false;
function loadJitterWorklet(ctx){
if (workletReady || workletLoading) return;
workletLoading = true;
const blob = new Blob([JITTER_BUFFER_WORKLET_CODE], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
ctx.audioWorklet.addModule(url).then(() => {
URL.revokeObjectURL(url);
workletReady = true; workletLoading = false;
/* swap every existing listener stream through the buffer */
for (const [uuid, node] of listenerAudioNodes) installJitterBuffer(uuid, node);
logLine('', 'jitter-buffer worklet ready (target='+RECV_PLAYOUT_DELAY_SEC+'s)');
}).catch(e => {
URL.revokeObjectURL(url);
workletLoading = false;
logLine('err', 'jitter-buffer worklet load: '+e.message+' — listener audio direct');
});
}
function installJitterBuffer(uuid, node){
if (!node || node.jbuf || !workletReady) return;
const target = node.targetSeconds || RECV_PLAYOUT_DELAY_SEC;
try {
const jbuf = new AudioWorkletNode(audioCtx, 'jitter-buffer', {
processorOptions: {
targetSeconds: target,
maxSeconds: target * 1.5,
},
outputChannelCount: [2],
});
/* worklet → JS: 'started' fires the moment the buffer first fills
* to target. Listener UI sits in "buffering" state until this
* lands — the user sees "connecting (buffering audio)" → 4s
* later → "connected as listener". Multiple worklets each fire;
* we mark the listener buffer ready on the FIRST one (audio is
* audible by then). */
jbuf.port.onmessage = (e) => {
if (!e.data) return;
if (e.data.cmd === 'started'){
logLine('', 'jitter-buffer started uuid='+uuid.slice(0,4)+' target='+(e.data.targetSeconds||target)+'s');
onWorkletStarted(uuid);
} else if (e.data.cmd === 'buffered'){
/* worklet's current buffer depth (seconds). Used by lip-sync
* to make video receivers track the audio's total delay. */
const n = listenerAudioNodes.get(uuid);
if (n) n.bufferedSeconds = e.data.seconds;
refreshLipSyncForUuid(uuid);
}
};
try { node.src.disconnect(node.gain); } catch(_){}
node.src.connect(jbuf).connect(node.gain);
node.jbuf = jbuf;
logLine('', 'jitter-buffer installed uuid='+uuid.slice(0,4)+' target='+target+'s');
} catch (e) {
logLine('err', 'jitter-buffer install '+uuid.slice(0,4)+': '+e.message);
}
}
/* Listener buffer-ready gating. Stays false during entry as listener
* (and on demote to listener) until at least one AudioWorklet has
* reported its buffer filled. While false, the call-status text shows
* "connecting — buffering 4s audio…" instead of "connected as
* listener" so the user knows why they don't hear anything yet. */
let listenerBufferReady = false;
function setListenerStatusAware(role){
if (role === 'listener' && !listenerBufferReady){
setStatus('connecting — buffering '+RECV_PLAYOUT_DELAY_SEC+'s audio…', 'warn');
} else if (role){
setStatus('connected as '+role, 'ok');
}
}
function onWorkletStarted(uuid){
if (listenerBufferReady) return;
if (myRole !== 'listener') return;
listenerBufferReady = true;
setListenerStatusAware(myRole);
}
function resetListenerBufferReady(){
/* called on entry-as-listener and on demote-to-listener so the
* buffering wait shows again. The 4s cushion has to fill from
* scratch every time the role transitions into listener. */
listenerBufferReady = false;
}
/* ==================================================================
* lip-sync — per-publisher dynamic video playoutDelayHint
*
* Without this, listener video leads listener audio by up to 4s on
* music streams: video receivers honor playoutDelayHint=4s natively,
* but audio adds the worklet's 4s cushion ON TOP of whatever the
* native audio buffer is doing — total audio delay = native_jbuf +
* worklet_buffered_seconds, video delay = native_jbuf only.
*
* Algorithm: every time the worklet reports its current buffer depth
* (every ~683ms via {cmd:'buffered'}), recompute the audio total delay
* for that publisher, then set each of that publisher's video
* receivers' playoutDelayHint + jitterBufferTarget to match.
*
* FEC-style smoothing per fox 2026-06-04 request: maintain a rolling
* history of the last LIP_SYNC_HISTORY samples; apply the MEDIAN, not
* the latest. Single-sample outliers from network jitter or getStats
* noise can't whip the video target. Median-of-5 is a poor man's
* Hamming for a control signal — needs ≥3 of 5 samples to agree
* before a new target locks in. Hysteresis on top: only retarget if
* the new median differs by more than LIP_SYNC_THRESHOLD from the
* last applied value. */
const lipSync = new Map(); /* pubHex → state */
const LIP_SYNC_HISTORY = 5;
const LIP_SYNC_THRESHOLD = 0.05; /* 50ms — below this is within perception noise */
function lipSyncEntry(pubHex){
let e = lipSync.get(pubHex);
if (!e){
e = { audioUuid: null, audioReceiver: null,
nativeJbufSec: 0, videoReceivers: new Map(),
history: [], lastApplied: 0 };
lipSync.set(pubHex, e);
}
return e;
}
function registerLipSyncAudio(pubHex, uuid, receiver){
const e = lipSyncEntry(pubHex);
e.audioUuid = uuid;
e.audioReceiver = receiver;
}
function registerLipSyncVideo(pubHex, kind, receiver){
const e = lipSyncEntry(pubHex);
e.videoReceivers.set(kind, receiver);
}
function medianOf(arr){
if (arr.length === 0) return 0;
const s = arr.slice().sort((a,b) => a-b);
return s[Math.floor(s.length/2)];
}
async function refreshLipSyncForUuid(uuid){
/* find which publisher this audio uuid belongs to */
let pubHex = null, e = null;
for (const [ph, st] of lipSync){
if (st.audioUuid === uuid){ pubHex = ph; e = st; break; }
}
if (!e || !e.audioReceiver || e.videoReceivers.size === 0) return;
const node = listenerAudioNodes.get(uuid);
if (!node) return;
const workletBufferedSec = node.bufferedSeconds || 0;
/* refresh native jbuf via getStats; this is a slow path but we
* only do it on the ~683ms cadence the worklet posts, and getStats
* is cheap (~1-2ms on Firefox Android). */
try {
const stats = await e.audioReceiver.getStats();
stats.forEach(r => {
if (r.type === 'inbound-rtp' && r.kind === 'audio'){
if (r.jitterBufferEmittedCount > 0){
e.nativeJbufSec = r.jitterBufferDelay / r.jitterBufferEmittedCount;
}
}
});
} catch(_){}
const audioTotal = e.nativeJbufSec + workletBufferedSec;
e.history.push(audioTotal);
while (e.history.length > LIP_SYNC_HISTORY) e.history.shift();
if (e.history.length < 3) return; /* wait for ≥3 samples */
const med = medianOf(e.history);
if (Math.abs(med - e.lastApplied) < LIP_SYNC_THRESHOLD) return;
for (const [kind, rx] of e.videoReceivers){
try { rx.playoutDelayHint = med; } catch(_){}
try { rx.jitterBufferTarget = med * 1000; } catch(_){}
}
e.lastApplied = med;
logLine('', 'lip-sync pub='+pubHex.slice(0,4)+' delay='+med.toFixed(2)+'s ('+e.videoReceivers.size+' video rx, history median of '+e.history.length+')');
}
/* Shared audio attach path. Every role routes through here now so the
* worklet can apply role-appropriate buffer depth uniformly. Listener
* gets a fat 4s cushion (lean-back, latency doesn't matter); speakers
* / cohosts / hosts get ~0.5s (small enough for conversation, big
* enough to smooth ordinary jitter). The OS Media-Session hook is
* applied only when role==='listener' — speakers don't need lock-
* screen transport controls. */
function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
if (!audioCtx){
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e){ logLine('err','audioCtx create: '+e.message); return false; }
}
if (audioCtx.state === 'suspended'){
audioCtx.resume().catch(()=>{});
}
const existing = listenerAudioNodes.get(uuid);
if (existing){
try { existing.src.disconnect(); } catch(_){}
try { if (existing.jbuf) existing.jbuf.disconnect(); } catch(_){}
try { existing.gain.disconnect(); } catch(_){}
listenerAudioNodes.delete(uuid);
}
let src;
try { src = audioCtx.createMediaStreamSource(stream); }
catch(e){ logLine('err','createMediaStreamSource '+uuid.slice(0,4)+': '+e.message); return false; }
const gain = audioCtx.createGain();
gain.gain.value = 1.0;
src.connect(gain);
gain.connect(audioCtx.destination);
const node = { src, gain, stream, targetSeconds };
listenerAudioNodes.set(uuid, node);
/* fire-and-forget worklet load on first use; once ready, every
* existing stream is swapped through the buffer (see loadJitterWorklet) */
loadJitterWorklet(audioCtx);
if (workletReady) installJitterBuffer(uuid, node);
logLine('', 'audio via AudioContext '+uuid.slice(0,4)+' target='+targetSeconds+'s ctxState='+audioCtx.state);
return true;
}
function attachListenerStreamViaAudioContext(uuid, stream){
const ok = attachAudioStreamViaWorklet(uuid, stream, RECV_PLAYOUT_DELAY_SEC);
if (!ok) return false;
/* Media Session API — tell the OS this tab is playing media. On
* Android Firefox + iOS Safari, this:
* - keeps the tab in media-priority mode (less aggressive JS
* throttling, AudioContext stays running)
* - surfaces lock-screen / notification-area transport controls
* - signals "do not freeze this tab" to the OS scheduler
* Combined with the server-side skip of alive-ttl for listeners,
* a mobile user can tab away to email / browser / chat and the
* zebra-spaces audio keeps playing in the background. Fox
* 2026-06-04. */
try {
if (navigator.mediaSession){
const title = 'zebra-spaces';
const artist = roomID ? ('room ' + roomID.slice(0,8)) : 'live';
navigator.mediaSession.metadata = new MediaMetadata({ title, artist });
navigator.mediaSession.playbackState = 'playing';
/* Provide a no-op pause handler so the OS knows we accept the
* 'pause' action — without one, some platforms refuse to keep
* the session active. */
navigator.mediaSession.setActionHandler('pause', () => {
navigator.mediaSession.playbackState = 'paused';
});
navigator.mediaSession.setActionHandler('play', () => {
navigator.mediaSession.playbackState = 'playing';
});
}
} catch(_){}
return true;
}
function detachListenerStream(uuid){
const node = listenerAudioNodes.get(uuid);
if (!node) return;
try { node.src.disconnect(); } catch(_){}
try { if (node.jbuf) node.jbuf.disconnect(); } catch(_){}
try { node.gain.disconnect(); } catch(_){}
listenerAudioNodes.delete(uuid);
}
function attachSfuTrack(uuid, stream){
/* Every role routes through the AudioContext + worklet path now.
* Listener gets 4s buffer (lean-back, latency doesn't matter, ride
* out wiggle-stalls); speakers/cohosts/hosts get 0.5s (small enough
* for conversation, big enough to smooth jitter and let music
* decode cleanly on browsers that don't honor jitterBufferTarget on
* high-bitrate stereo Opus). Meter still uses startMeter()'s own
* analyser source from the same stream — multiple MediaStreamSource
* nodes per stream is allowed. Firefox Android autoplay survives
* AudioContext where <audio>.play() doesn't. */
if (myRole === 'listener'){
if (attachListenerStreamViaAudioContext(uuid, stream)){
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx 4s)');
return;
}
/* AudioContext failed — fall through to <audio> path as last resort */
logLine('err','listener audioctx attach failed, falling back to <audio>');
} else {
/* speaker / cohost / host: smaller buffer, same path. */
if (attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC)){
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx '+SPEAKER_PLAYOUT_DELAY_SEC+'s)');
return;
}
logLine('err', myRole+' audioctx attach failed, falling back to <audio>');
}
let a = remoteAudio.get(uuid);
const fresh = !a;
if (!a){
/* lease from the pre-blessed audio pool so Firefox Android's
* per-element autoplay grant carries over from the entry-button
* gesture. attachSfuTrack runs after several async hops (WS join,
* SFU subscribe negotiation, ontrack) so the click activation
* window has expired by now — only a pre-played pool element
* still has the engagement bit set. */
a = leaseAudioElement();
remoteAudio.set(uuid, a);
applySinkTo(a);
/* one-shot breadcrumbs so we can see whether the new path lights
* up at all, vs. the silent-but-state=connected pattern. */
a.addEventListener('playing', () => logLine('', 'rtc playing '+uuid.slice(0,4)+' ct='+a.currentTime.toFixed(2)), { once: true });
a.addEventListener('pause', () => logLine('err','rtc paused '+uuid.slice(0,4)+' ct='+a.currentTime.toFixed(2)));
a.addEventListener('ended', () => logLine('err','rtc ended '+uuid.slice(0,4)));
a.addEventListener('stalled', () => logLine('err','rtc stalled '+uuid.slice(0,4)));
a.addEventListener('error', () => logLine('err','rtc error '+uuid.slice(0,4)+' code='+(a.error?a.error.code:'?')));
}
a.srcObject = stream;
const tracks = stream && stream.getAudioTracks ? stream.getAudioTracks() : [];
const t0 = tracks[0];
logLine('', 'sfu attach '+uuid.slice(0,4)+' fresh='+(fresh?1:0)+
' tracks='+tracks.length+
(t0 ? ' tr0={en='+t0.enabled+' mu='+t0.muted+' rs='+t0.readyState+'}' : ''));
try {
const p = a.play();
if (p && p.catch) p.catch(e => logLine('err','rtc autoplay '+uuid.slice(0,4)+': '+e.message));
} catch(e){
logLine('err','rtc play threw '+uuid.slice(0,4)+': '+e.message);
}
/* If play() returns a resolved promise but the element stays paused
* at ct=0 (Firefox Android srcObject-swap kills the pre-blessed
* engagement silently), the telemetry tick's wedge-recovery will
* detect that within 5s and lease a fresh pool element. No user-
* visible notice — fox: tap-anywhere from 6c9d1b8 was rejected for
* adding noise, automatic silent recovery is the right shape. */
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;
/* Receiver-side jitter buffer target in seconds. Bigger = more
* absorption of network jitter / burst loss = cleaner playback under
* stress, at the cost of conversational latency. The history of this
* number:
* - 0.4s: original — too tight for mixed Wi-Fi + cellular,
* produced robot-voice artifacts on hand-offs.
* - 0.7s: covered most cross-AP cases, still chopped on
* music-mode / loaded uplinks.
* - 2.0s (current): fox 2026-06-03 asked for a long buffer to
* prevent chop "ever". Conversational latency goes up, but
* this is a music/DJ-focused stack and the stream/HTTP-pull
* DJ mode already commits to ~2s, so RTC matching that is
* consistent. If you need lower latency for actual
* conversation, drop this back to 0.7 and accept the
* occasional under-run. */
/* Listener cushion — they lean back, latency doesn't matter, ride
* out wiggle-stalls without a glitch. Big enough to survive any
* realistic publisher-side hiccup. */
const RECV_PLAYOUT_DELAY_SEC = 4.0;
/* Speaker/cohost/host cushion — they're in active conversation, so
* the buffer trades a tiny bit of jitter smoothing for sub-second
* round-trip. ~500ms feels natural; 4s would make every back-and-forth
* impossible. Native receiver jitterBufferTarget honors this for
* voice-rate Opus and the worklet matches it for music-rate. */
const SPEAKER_PLAYOUT_DELAY_SEC = 0.5;
function playoutDelayForRole(role){
return role === 'listener' ? RECV_PLAYOUT_DELAY_SEC : SPEAKER_PLAYOUT_DELAY_SEC;
}
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);
}
/* watchFirstFrame — log a warning if a freshly-attached SFU video track
* never unmutes within the deadline. Fresh remote tracks start `muted`
* until the first RTP packet arrives; if the decoder never gets a
* keyframe (PLI dropped in UDP, publisher encoder stalled, codec
* mismatch), the tile renders permanently black with no clear signal in
* the log. This emits one line so we can tell next session which path
* actually broke. No state change — purely diagnostic. */
function watchFirstFrame(track, kind, pubHex){
if (!track) return;
if (!track.muted){
/* already flowing — nothing to wait for */
return;
}
const deadline = 2500;
const start = Date.now();
let done = false;
const settle = (note) => {
if (done) return;
done = true;
track.removeEventListener('unmute', onUnmute);
if (note) logLine('', note);
};
const onUnmute = () => settle('first frame: kind=' + kind + ' pub=' + pubHex + ' in ' + (Date.now()-start) + 'ms');
track.addEventListener('unmute', onUnmute);
setTimeout(() => {
if (done) return;
if (track.readyState !== 'live'){ settle(null); return; }
if (!track.muted){ settle('first frame: kind=' + kind + ' pub=' + pubHex + ' (unmute event missed)'); return; }
settle('err: ' + kind + ' from ' + pubHex + ' still black after ' + deadline + 'ms — no keyframe?');
}, deadline);
}
/* 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);
}
/* Replace a <video> with a freshly-built one carrying the same
* autoplay/playsinline/muted attributes, in the same DOM slot. Returns
* the new element so the caller can update its reference. Browsers
* treat a brand-new <video> as eligible for muted-autoplay even when
* the old element had its play() rejected — so use this on any
* supplant where srcObject would otherwise be swapped mid-life. */
function swapFreshVideoElement(oldVideo){
if (!oldVideo) return oldVideo;
const fresh = document.createElement('video');
fresh.setAttribute('autoplay', '');
fresh.setAttribute('playsinline', '');
fresh.setAttribute('muted', '');
fresh.autoplay = true; fresh.playsInline = true; fresh.muted = true;
if (oldVideo.parentNode){
try { oldVideo.srcObject = null; } catch(_){}
oldVideo.parentNode.replaceChild(fresh, oldVideo);
}
return fresh;
}
/* 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);
} else {
/* MSID-supplant retarget: when the same pubHex+kind republishes
* (publisher refreshed / sfu PC rebuilt), reassigning srcObject on
* the existing <video> hits the browser's autoplay policy as a
* mid-life srcObject swap and a fresh play() rejects with
* "fetching process for the media resource was aborted by the
* user agent at the user's request" — the tile stays paused on a
* black frame. Building a fresh <video> element resets that
* decision: muted-video autoplay applies again because the
* element has no prior gesture-consumed play() to defend against.
* Fox 2026-06-03 ("still black share camera from will" after a
* 16s supplant). */
entry.video = swapFreshVideoElement(entry.video);
}
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 — same supplant-vs-autoplay trap, same fix. */
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex && spotlight.video){
spotlight.video = swapFreshVideoElement(spotlight.video);
spotlight.video.srcObject = stream;
try {
const p = spotlight.video.play();
if (p && p.catch) p.catch(()=>{ spotlight.tile.classList.add('needs-tap'); });
} catch(_){}
}
/* 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;
}
/* Drive the existing publishSpec FSM in lock-step with the
* imperative flow. The mic publisher transitions
* off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
* — and back to off via stopping on STOP / FAILED. ctx tracks stream
* + pc + peerID. The page's imperative state (sfuPubPC, sfuPubPeerID)
* remains the source of truth for now; the FSM is a parallel view
* that future observers can hang side effects off of. Fox 2026-06-04
* "all systems need state machines." */
const fsm = roomMachines.pubs.mic;
fsm.send('START');
fsm.send('ACQUIRED', { stream: micStream });
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'));
try {
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(); fsm.send('FAILED', { error: 'blocked' }); fsm.send('DONE'); handleBlocked('publish'); return; }
if (!res.ok){ pc.close(); fsm.send('FAILED', { error: 'http '+res.status }); fsm.send('DONE'); 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;
fsm.send('NEGOTIATED', { pc, peerID: ans.peer_id });
watchPublishPC(pc, 'mic', sfuPublish, () => sfuPubPC === pc, () => { sfuPubPC = null; sfuPubPeerID = null; fsm.send('STOP'); fsm.send('DONE'); });
logLine('', 'sfu: publishing as '+sfuPubPeerID);
} catch(e){
fsm.send('FAILED', { error: e.message });
fsm.send('DONE');
throw e;
}
}
/* 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;
const fsm = roomMachines.pubs.screen;
fsm.send('START');
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 },
/* picker UX hints. Chrome respects these — biases toward the
* previously-shared surface family and shows tab audio toggle by
* default. Firefox / Safari currently ignore them. */
surfaceSwitching: 'include',
selfBrowserSurface: 'include',
systemAudio: 'include',
});
} catch(e){ logLine('err','screen share cancelled: '+e.message); fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); return; }
fsm.send('ACQUIRED', { stream });
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; fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); throw e; }
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; fsm.send('FAILED', { error: 'blocked' }); fsm.send('DONE'); handleBlocked('publish-screen'); return; }
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; fsm.send('FAILED', { error: 'http '+res.status }); fsm.send('DONE'); 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;
fsm.send('NEGOTIATED', { pc, peerID: ans.peer_id });
/* raise the RTP-level caps: 6 Mbps for video (high-detail 1080p screen),
* 510 kbps for audio (Opus spec ceiling, transparent stereo). 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);
}
/* Screen share recovery is asymmetric to mic/camera. getDisplayMedia
* needs a fresh user gesture, so we CAN'T auto-fire the picker after
* a 'failed'. BUT: the existing MediaStream usually survives a PC
* failure — the browser's "you are sharing" indicator stays on, the
* tracks remain in readyState=live. In that case we can rebuild the
* RTCPeerConnection while reusing sfuScreenStream, no picker needed,
* no gesture needed. Only fall back to manual re-share if the
* underlying tracks have actually ended (user clicked browser-native
* "stop sharing", source window closed, etc.) — fox 2026-06-04. */
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed' || sfuScreenPC !== pc) return;
const tracksLive = sfuScreenStream && sfuScreenStream.getTracks().every(t => t.readyState === 'live');
if (tracksLive){
logLine('err', 'sfu screen publish PC failed — tracks still live, rebuilding silently');
sfuRebuildScreenPC().catch(e => {
logLine('err','silent screen rebuild failed: '+e.message+' — tap "share screen" to re-share');
sfuUnpublishScreen().catch(()=>{});
});
return;
}
logLine('err', 'sfu screen publish PC failed — tracks ended; tap "share screen" to re-share (browsers need a fresh click for screen capture)');
sfuUnpublishScreen().catch(()=>{});
};
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 });
/* share / stop button visibility is driven by applyPublishStateUI
* via the publishSpec observer (pubs.screen state). */
}
/* sfuRebuildScreenPC — silent recovery path used by the screen
* publisher's onconnectionstatechange when the PC fails BUT the
* underlying MediaStream is still alive. Reuses sfuScreenStream
* verbatim, builds a fresh PC, re-publishes to the SFU. No
* getDisplayMedia call → no user gesture required → no picker
* popping up mid-share. Caller decides what to do on failure (the
* onconnectionstatechange handler falls back to sfuUnpublishScreen
* which surfaces the manual "share screen" button). */
async function sfuRebuildScreenPC(){
if (!sfuScreenStream || !myKeys || !roomID) throw new Error('no live stream to rebuild against');
/* tear down the dead PC first; sfuScreenPC must be null'd BEFORE
* pc.close() so the dead PC's stale onconnectionstatechange (which
* may fire one more time during teardown) sees sfuScreenPC===null
* and bails out instead of recursing into another rebuild. */
const oldPC = sfuScreenPC;
const oldPid = sfuScreenPeerID;
sfuScreenPC = null;
sfuScreenPeerID = null;
if (oldPC){ try { oldPC.close(); } catch(_){} }
/* unpublish the old peer_id at the SFU so it doesn't keep a stale
* publisher entry around that fights the new one for the same
* (pubkey, kind=screen) slot. */
if (oldPid){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + oldPid, { method:'POST' }); } catch(_){}
}
const pc = new RTCPeerConnection(rtcConfig);
for (const tr of sfuScreenStream.getTracks()){
pc.addTrack(tr, sfuScreenStream);
}
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=screen';
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sdp: pc.localDescription.sdp }) });
if (res.status === 403){ pc.close(); handleBlocked('publish-screen'); throw new Error('blocked'); }
if (!res.ok){ pc.close(); 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;
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);
}
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed' || sfuScreenPC !== pc) return;
const tracksLive = sfuScreenStream && sfuScreenStream.getTracks().every(t => t.readyState === 'live');
if (tracksLive){
logLine('err', 'sfu screen publish PC failed — tracks still live, rebuilding silently');
sfuRebuildScreenPC().catch(e => {
logLine('err','silent screen rebuild failed: '+e.message+' — tap "share screen" to re-share');
sfuUnpublishScreen().catch(()=>{});
});
return;
}
logLine('err', 'sfu screen publish PC failed — tracks ended; tap "share screen" to re-share (browsers need a fresh click for screen capture)');
sfuUnpublishScreen().catch(()=>{});
};
logLine('', 'sfu: screen PC rebuilt silently — sharing as '+sfuScreenPeerID);
}
async function sfuUnpublishScreen(){
if (!sfuScreenPC && !sfuScreenStream) return;
const fsm = roomMachines.pubs.screen;
fsm.send('STOP');
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(_){}
}
fsm.send('DONE');
/* share / stop visibility is driven by applyPublishStateUI from
* the publishSpec 'off' state. */
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;
const fsm = roomMachines.pubs.camera;
fsm.send('START');
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); fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); return; }
fsm.send('ACQUIRED', { stream });
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; fsm.send('FAILED', { error: 'blocked' }); fsm.send('DONE'); handleBlocked('publish-camera'); return; }
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; fsm.send('FAILED', { error: 'http '+res.status }); fsm.send('DONE'); 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;
fsm.send('NEGOTIATED', { pc, peerID: 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; fsm.send('STOP'); fsm.send('DONE'); });
logLine('', 'sfu: camera on as '+sfuCameraPeerID);
renderCameraTile(myKeys.pubHex, stream, { local: true });
/* share / stop visibility is driven by applyPublishStateUI via
* the publishSpec observer (pubs.camera state). */
try { sessionStorage.setItem(ACTIVE_CAM_KEY, '1'); } catch(_){}
}
async function sfuUnpublishCamera(){
if (!sfuCameraPC && !sfuCameraStream) return;
const fsm = roomMachines.pubs.camera;
fsm.send('STOP');
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(_){}
}
fsm.send('DONE');
/* share / stop visibility is driven by applyPublishStateUI from
* the publishSpec 'off' state. */
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 fsm = roomMachines.pubs.mic;
fsm.send('STOP');
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(_){}
}
fsm.send('DONE');
}
/* handleRemoteSfuTrack — receive-side mesh state machine. Called once
* per ontrack on the SFU sub PC. Extracted into a named function so
* test/multi-peer-mesh.test.js can drive it directly with synthetic
* events and verify the mesh invariants without needing a real
* RTCPeerConnection or SFU. The signature mirrors a real RTCTrackEvent:
* .streams[0] (MediaStream), .track (MediaStreamTrack), .receiver
* (RTCRtpReceiver — optional, only used for playoutDelayHint). */
function handleRemoteSfuTrack(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);
/* Video receivers (screen / camera / game) match the audio receiver's
* playoutDelayHint so the picture stays in sync with the voice. The
* mic jitter buffer is 4s; without a matching hint on video, mouse
* clicks / mouth movement / keystrokes lead the voice by ~4s. Audio
* is more vital than video — video adapts to audio's delay, never
* the other way around. */
try { if (ev.receiver) ev.receiver.playoutDelayHint = playoutDelayForRole(myRole); } catch(_){}
try { if (ev.receiver) ev.receiver.jitterBufferTarget = playoutDelayForRole(myRole) * 1000; } catch(_){}
/* register for the dynamic lip-sync algorithm — the next worklet
* 'buffered' message will recompute this video receiver's target
* to match the audio's total delay (native jbuf + worklet). */
if (ev.receiver) registerLipSyncVideo(pubHex, kind, ev.receiver);
}
/* 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);
watchFirstFrame(ev.track, kind, pubHex);
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);
watchFirstFrame(ev.track, kind, pubHex);
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);
watchFirstFrame(ev.track, kind, pubHex);
return;
}
if (kind !== 'mic'){
logLine('', 'sfu: unknown kind '+kind+' from '+pubHex);
return;
}
/* mic audio — see RECV_PLAYOUT_DELAY_SEC for the buffer-vs-latency
* tradeoff rationale. Bumped 0.7 → 2.0 after fox 2026-06-03 chasing
* persistent chop that survived every SDP-side dial-back.
*
* playoutDelayHint is a HINT the browser is free to ignore. Fox
* 2026-06-04: "the phone as a listener doesn't seem to be 4 secs
* behind ever" — Firefox Android wasn't honoring the hint, so the
* jitter buffer stayed near-zero and any encoder stall on the host
* was instantly audible. jitterBufferTarget (Chromium 113+, FF 124+)
* is NOT a hint — it sets a target the receiver MUST aim for.
* Setting both for cross-browser coverage. Role-aware now —
* listener gets 4s, others get 0.5s (conversational latency). The
* userland AudioWorklet downstream of this receiver gives the real
* cushion regardless of what the native buffer honors. */
try { if (ev.receiver) ev.receiver.playoutDelayHint = playoutDelayForRole(myRole); } catch(_){}
try { if (ev.receiver) ev.receiver.jitterBufferTarget = playoutDelayForRole(myRole) * 1000; } 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){
/* register the audio receiver for lip-sync. We use the audio
* receiver's getStats jbuf + the worklet's buffer depth to set
* matching video target on the same publisher's screen/camera
* receivers. Register here (mic kind) BEFORE attachSfuTrack
* so the first worklet 'buffered' message lands on a paired
* publisher state. */
if (ev.receiver) registerLipSyncAudio(pubHex, uuid, ev.receiver);
/* speakers get their peers' audio via mesh (lower latency)
* AT THE TIMES THE MESH PC IS CONNECTED. A stale or failing
* mesh PC must NOT block the SFU fallback — that's how the
* 'phone re-promoted but nobody hears them' regression
* appeared: the peers map still had an entry whose state
* was 'failed', so we skipped SFU and the receiver got no
* audio at all. */
if (canSpeak(myRole)){
const meshPC = peers.get(uuid);
const meshState = meshPC ? meshPC.connectionState : 'none';
if (meshPC && meshState === 'connected'){
logLine('', 'sfu mic skipped for '+uuid+' — mesh peer connected');
return;
}
if (meshPC){
logLine('', 'sfu mic taking over for '+uuid+' — mesh state='+meshState);
}
}
attachSfuTrack(uuid, ev.streams[0]);
return;
}
} catch(_){}
}
/* no matching member yet — flushSfuStreams will attach on peer-joined */
}
async function sfuSubscribe(){
if (sfuSubPC || !roomID) return;
const pc = new RTCPeerConnection(rtcConfig);
pc.ontrack = handleRemoteSfuTrack;
/* 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' coming from our OWN sfuUnsubscribe() must NOT rebuild
* (subscribe→close→subscribe loop). sfuUnsubscribe nulls
* sfuSubPC BEFORE pc.close(), so the `sfuSubPC === pc` guard
* catches self-teardown — the guard fails and we early-return.
* 'closed' coming from the SFU (server-side close — e.g. our
* own wedge-recovery in renegotiateLocked) DOES need a rebuild
* because sfuSubPC === pc is still true (we didn't tear down).
* Without this branch the host's sub stays at sub=none after
* any server-driven close, every subsequent track add goes
* nowhere. Fox 2026-06-04: "lost cohost camera / screen
* share" was downstream of this missing rebuild.
* 'disconnected' is transient — let WebRTC try to recover before
* we yank the rug. */
const s = pc.connectionState;
if (s !== 'failed' && s !== 'closed') return;
if (sfuSubPC !== pc) return;
logLine('err', 'sfu sub PC '+s+' (remote-driven) — 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);
});
/* Pre-permission Firefox returns empty deviceIds for every device; the
* old `else: micDeviceId = sel.value` clobbered our saved selection to
* '' the moment we ran this on page load (before any gUM grant). After
* that, getMic() picked the default mic instead of the saved monitor,
* and the host had to re-pick the input every refresh.
*
* Detection: if every enumerated device has deviceId === '', we're
* still pre-permission — DON'T touch the saved micDeviceId. Wait until
* a real enumerate (post-getMic) before fixing up sel.value.
*
* Also fall back to LABEL-match when the saved deviceId doesn't match
* but the saved label does (Chrome rotates deviceIds across sessions,
* label is more stable). When a label match wins, refresh micDeviceId
* to the current session's deviceId so micConstraints() works. */
const allEmpty = mics.every(m => !m.deviceId);
if (allEmpty) return; /* pre-permission — preserve saved selection */
if (micDeviceId && mics.some(m=>m.deviceId===micDeviceId)){
sel.value = micDeviceId;
return;
}
if (micDeviceLabel){
const byLabel = mics.find(m => m.label === micDeviceLabel);
if (byLabel){
micDeviceId = byLabel.deviceId;
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
sel.value = micDeviceId;
logLine('', 'mic input restored by label match: '+micDeviceLabel);
return;
}
}
micDeviceId = sel.value;
} catch(e){ logLine('err','could not list inputs: '+e.message); }
}
/* Speaker output picker — routes peer audio to a specific sink (studio
* monitors, external DAC, headphones). The codec gains stop at the
* <audio> element; this is the one knob that actually upgrades the
* playback chain itself.
*
* setSinkId() needs a non-default deviceId, and labels are gated behind
* mic permission (same gUM gate as enumerateDevices for inputs). Safari
* lacks setSinkId on HTMLMediaElement entirely — we hide the row in
* that case so it doesn't look broken. */
const spkSupported = (typeof HTMLMediaElement !== 'undefined') &&
('setSinkId' in HTMLMediaElement.prototype);
async function refreshSpeakerList(){
if (!spkSupported){
const row = $('row-speaker-controls'); if (row) row.classList.add('hidden');
return;
}
try {
const devs = await navigator.mediaDevices.enumerateDevices();
const outs = devs.filter(d=>d.kind==='audiooutput');
const sel = $('speaker-select'); if (!sel) return;
sel.innerHTML = '';
if (!outs.length){ sel.innerHTML = '<option value="">output default</option>'; return; }
outs.forEach((o,i)=>{
const opt = document.createElement('option');
opt.value = o.deviceId; opt.textContent = 'output ' + (o.label || ('speaker '+(i+1)));
sel.appendChild(opt);
});
/* same restore pattern as refreshMicList: don't clobber the saved
* speaker selection while we're pre-permission (all deviceIds empty),
* and fall back to label-match when deviceId rotation invalidates the
* saved id. */
const allEmpty = outs.every(o => !o.deviceId);
if (allEmpty) return;
if (speakerDeviceId && outs.some(o=>o.deviceId===speakerDeviceId)){
sel.value = speakerDeviceId;
return;
}
if (speakerDeviceLabel){
const byLabel = outs.find(o => o.label === speakerDeviceLabel);
if (byLabel){
speakerDeviceId = byLabel.deviceId;
try { localStorage.setItem(SPK_DEV_KEY, speakerDeviceId); } catch(_){}
sel.value = speakerDeviceId;
logLine('', 'output restored by label match: '+speakerDeviceLabel);
return;
}
}
speakerDeviceId = sel.value;
} catch(e){ logLine('err','could not list outputs: '+e.message); }
}
/* Apply current speakerDeviceId to a single <audio> element. Safe to call
* before the element is in the DOM. Browsers that lack setSinkId silently
* skip — we feature-check at the top so this stays cheap on cold paths. */
async function applySinkTo(el){
if (!spkSupported || !el || typeof el.setSinkId !== 'function') return;
try { await el.setSinkId(speakerDeviceId || ''); }
catch(e){ logLine('err','setSinkId rejected: '+e.message); }
}
/* Re-route every live remote-audio element to the newly picked sink. */
async function applySinkToAll(){
if (!spkSupported) return;
for (const [, a] of remoteAudio){ await applySinkTo(a); }
}
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 = [{}];
/* 510 kbps is the Opus spec ceiling — transparent stereo with headroom
* for transient-heavy material (orchestral, drums). 40 kbps mono is plenty
* for speech. Opus VBR only spends what it needs; idle music stays around
* 200300 kbps and only peaks claim the ceiling. */
p.encodings[0].maxBitrate = musicMode ? 256000 : 40000;
/* Audio rides ahead of video in the sender's egress queue. Fox
* 2026-06-04: wiggling a terminal window caused 1-5s cutouts on
* listeners even with a 4s playoutDelayHint buffer. Cause: the
* wiggle triggered a big screen-share keyframe burst that
* monopolized the egress queue; audio packets queued behind it
* arrived too late and the buffer drained. Setting priority='high'
* + networkPriority='high' on audio tells the browser to drain
* audio first when its outgoing queue is contended. Video keeps
* the default ('low' on the screen/camera senders below). */
p.encodings[0].priority = 'high';
p.encodings[0].networkPriority = 'high';
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. Audio kind
* gets high priority like the mic; video kind gets low priority so audio
* wins the egress queue under contention. */
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;
const isVideo = sender.track && sender.track.kind === 'video';
p.encodings[0].priority = isVideo ? 'low' : 'high';
p.encodings[0].networkPriority = isVideo ? 'low' : 'high';
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.
*
* NACK is gated by RTCRtpSender.getCapabilities so each browser only
* advertises what it can back up — no LCD across mixed-browser rooms. */
function preferStereoOpus(sdp, maxAvgBps, opts){
const music = !!(opts && opts.music);
/* 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 = music ? '0' : '1';
/* Find every Opus payload type from rtpmap. Anchoring on minptime=10
* (as we used to) silently no-op'd on Firefox publishers, because
* Firefox emits `a=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1`
* with no minptime — the entire music-mode SDP munge was skipped on FF
* for years. rtpmap is reliably present for every negotiated codec. */
const opusPTs = [];
const rtpmapRe = /a=rtpmap:(\d+) opus\/48000\/2/gi;
let mm;
while ((mm = rtpmapRe.exec(sdp)) !== null) opusPTs.push(mm[1]);
if (!opusPTs.length) return sdp;
/* useinbandfec=1: forward error correction so a single dropped
* packet doesn't audibly chop — Opus reconstructs from FEC.
*
* maxplaybackrate / sprop-maxcapturerate=48000 and cbr=0 were tried
* here and rolled back: choppy persisted after every other dial-back
* (NACK off, maxptime off, bitrate to 320k). The "fullband pin" was
* the only Chrome-publisher-side remaining new param. Removing it
* restores Chrome's original music-mode behaviour. Don't re-add
* without verifying it doesn't reintroduce the choppy. */
const want = {
'stereo': '1', 'sprop-stereo': '1',
'maxaveragebitrate': String(maxAvgBps),
'useinbandfec': '1', 'usedtx': dtx,
};
for (const pt of opusPTs){
const fmtpRe = new RegExp('a=fmtp:' + pt + ' ([^\\r\\n]*)', 'g');
if (fmtpRe.test(sdp)){
/* update existing fmtp params in place */
sdp = sdp.replace(new RegExp('a=fmtp:' + pt + ' ([^\\r\\n]*)', 'g'), (_, fmtp) => {
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(';');
});
} else {
/* no fmtp line exists for this PT — insert one right after rtpmap */
const parts = Object.keys(want).map(k => k + '=' + want[k]);
sdp = sdp.replace(
new RegExp('(a=rtpmap:' + pt + ' opus[^\\r\\n]*\\r?\\n)', 'i'),
'$1a=fmtp:' + pt + ' ' + parts.join(';') + '\r\n'
);
}
}
/* Audio NACK is asymmetric — the SENDER has to respond to retransmit
* requests. Chrome implements both sides; Firefox implements neither
* for audio (Mozilla never shipped it). Blanket-advertising NACK in
* Firefox-published offers made Chrome receivers wait for retransmits
* that never arrived and skip audibly.
*
* Gate on the actual local capability via RTCRtpSender.getCapabilities:
* - Chrome: opus rtcpFeedback includes nack → we advertise → Chrome
* receivers get reactive recovery, Firefox receivers ignore.
* - Firefox: opus rtcpFeedback has no nack → we don't advertise →
* Chrome receivers won't NACK us → no choppy.
* Each browser only offers what it can back up. No LCD across the
* room, no per-peer signaling needed — the SDP itself is honest. */
if (senderSupportsAudioNack()){
for (const pt of opusPTs){
const nackLine = 'a=rtcp-fb:' + pt + ' nack';
if (sdp.indexOf(nackLine) === -1){
sdp = sdp.replace(new RegExp('(a=rtpmap:' + pt + ' opus[^\\r\\n]*\\r?\\n)', 'i'),
'$1' + nackLine + '\r\n');
}
}
}
/* maxptime advertisement removed: tried a=maxptime:120 to encourage
* larger encode windows, but Chrome publishers under it produced choppy
* playback on receivers (likely the encoder actually packed at higher
* ptime and the receiver's jitter buffer / playoutDelayHint=0.7 didn't
* keep up). Default 20ms packetization stays. */
return sdp;
}
/* Probe the local browser's audio sender capabilities — does Opus
* advertise nack feedback? Cached after first call: capabilities are
* static per UA. Returns true on Chromium (Chrome / Edge / Brave) and
* false on Firefox / Safari / older browsers. */
let _senderNackCached = null;
function senderSupportsAudioNack(){
if (_senderNackCached !== null) return _senderNackCached;
try {
const caps = (window.RTCRtpSender && RTCRtpSender.getCapabilities)
? RTCRtpSender.getCapabilities('audio') : null;
const opus = caps && caps.codecs && caps.codecs.find(c =>
(c.mimeType || '').toLowerCase() === 'audio/opus');
_senderNackCached = !!(opus && opus.rtcpFeedback &&
opus.rtcpFeedback.some(fb => fb.type === 'nack'));
} catch(_){ _senderNackCached = false; }
logLine('', 'audio NACK as sender: '+(_senderNackCached ? 'on' : 'off'));
return _senderNackCached;
}
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); }
/* ==================================================================
* notification tones — synthesised via Web Audio so no asset needed.
*
* playToneJoin() — ascending major-third chime (523→659 Hz):
* friendly 'welcome' signature
* playToneRaise() — single bell-like 880 Hz tone: gentle attention
*
* A single shared AudioContext is lazily created on first use; the
* user already gestured to enter the room (clicked 'enter'), so the
* autoplay policy is satisfied. Each tone uses a short exponential
* fade-out to avoid click artifacts.
*
* Tones play even when the tab is in the background — the doorman /
* mod use case wants to know a guest just arrived without having to
* watch the tab. Browsers throttle some background work but Web
* Audio scheduling is preserved as long as the AudioContext was
* created after a user gesture (the 'enter' click counts).
* ================================================================== */
let _toneCtx = null;
function _toneCtxGet(){
if (_toneCtx) return _toneCtx;
try { _toneCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(_){ return null; }
return _toneCtx;
}
function playToneBlip(freq, durationMs, type){
const ctx = _toneCtxGet(); if (!ctx) return;
/* if the user gesture was throttled (some mobile browsers suspend
* the context on tab hide), nudge it back to running before
* scheduling — resume() on a suspended context restarts the
* timeline so the tone actually fires. */
if (ctx.state === 'suspended'){ try { ctx.resume(); } catch(_){} }
try {
const t0 = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type || 'sine';
osc.frequency.value = freq;
osc.connect(gain); gain.connect(ctx.destination);
/* short attack, exponential decay → no click on start or end */
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.12, t0 + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durationMs / 1000);
osc.start(t0);
osc.stop(t0 + durationMs / 1000 + 0.02);
} catch(_){}
}
function playToneJoin(){
/* C5 → E5 ascending major third — a classic 'someone arrived' lift */
playToneBlip(523.25, 140, 'sine');
setTimeout(() => playToneBlip(659.25, 200, 'sine'), 120);
}
function playToneRaise(){
/* single A5 bell-ish tone — light, attention-getting */
playToneBlip(880.0, 260, 'triangle');
}
function playToneLeave(){
/* E5 → C5 descending major third — mirror of playToneJoin, conveys
* 'departed'. Fires on peer-left + peer-booted. */
playToneBlip(659.25, 140, 'sine');
setTimeout(() => playToneBlip(523.25, 200, 'sine'), 120);
}
function playToneStepDown(){
/* single G4 low blip — softer than join/leave, conveys 'still here
* but stepped off the stage'. Fires on canSpeak -> listener role
* transitions for other peers. */
playToneBlip(392.0, 220, 'sine');
}
/* ==================================================================
* 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. */
roomMachines.call.observe(({ state, prev, ev }) => {
if (prev === null || state === prev) return;
logLine('', 'call: ' + prev + ' → ' + state + (ev && ev.type ? ' [' + ev.type + ']' : ''));
});
/* Call-FSM-driven UI. Single source of truth for the entry/connected/
* kicked/banned UI states (dot color, button visibility, status text,
* entry-row visibility). Previously these were scattered classList +
* setStatus writes across `welcome`, `peer-booted` (self branch),
* btn-leave click, and handleBlocked — easy to drift, hard to test.
*
* Fox 2026-06-04: "all systems need state machines." This observer
* makes the existing callSpec the authoritative UI driver; the
* imperative paths now just dispatch events into the FSM and trust
* the observer to update the chrome.
*
* Side-effect-only — never reads anything except its ctx. Idempotent
* because it only fires on REAL transitions (prev !== state). */
function applyCallStateUI(state, prev, ctx){
const dot = $('dot-call'), status = $('call-status');
const btnEnter = $('btn-enter'), btnLeave = $('btn-leave'), btnMute = $('btn-mute');
const rowEntry = $('row-entry'), secRoom = $('sec-room');
switch (state){
case 'idle': {
/* fresh page, or post-leave */
if (dot) dot.className = 'dot warn';
if (rowEntry) rowEntry.classList.remove('hidden');
if (secRoom) secRoom.classList.add('hidden');
if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; }
if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; }
if (btnEnter) btnEnter.disabled = false;
if (status && prev === 'leaving') setStatus('left', null);
break;
}
case 'connecting': {
if (dot) dot.className = 'dot warn';
if (btnEnter) btnEnter.disabled = true;
if (status) setStatus('connecting…');
break;
}
case 'joined': {
if (dot) dot.className = 'dot ok';
if (rowEntry) rowEntry.classList.add('hidden');
if (secRoom) secRoom.classList.remove('hidden');
if (btnLeave){ btnLeave.classList.remove('hidden'); btnLeave.disabled = false; }
if (btnMute) btnMute.classList.remove('hidden');
if (status && ctx.role) setListenerStatusAware(ctx.role);
break;
}
case 'reconnecting': {
if (dot) dot.className = 'dot warn';
if (status) setStatus('rendezvous dropped — reconnecting…');
break;
}
case 'leaving': {
if (dot) dot.className = 'dot warn';
if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; }
if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; }
break;
}
case 'booted': {
/* kicked or banned (or signal-blocked); ctx.bootedAction may be
* 'kick' | 'ban' | 'blocked'. Each gets a distinct status. */
if (dot) dot.className = 'dot warn';
if (secRoom) secRoom.classList.add('hidden');
if (rowEntry) rowEntry.classList.remove('hidden');
if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; }
if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; }
if (btnEnter) btnEnter.disabled = (ctx.bootedAction === 'ban' || ctx.bootedAction === 'blocked');
const verb = ctx.bootedAction === 'ban' ? 'banned from this space'
: ctx.bootedAction === 'blocked' ? 'blocked from this space'
: 'kicked from this space';
const cls = ctx.bootedAction === 'ban' || ctx.bootedAction === 'blocked' ? 'err' : 'warn';
if (status) setStatus(verb, cls);
break;
}
}
}
roomMachines.call.observe(({ state, prev, ctx }) => {
if (prev === null || state === prev) return;
try { applyCallStateUI(state, prev, ctx); } catch(e){ logLine('err','call UI observer: '+e.message); }
});
/* Publish-FSM trace observers — one per kind (mic / screen / camera).
* Logs every transition so the page log shows the publish lifecycle
* synchronously with the imperative sfuPublish* / sfuUnpublish* flow. */
for (const kind of ['mic', 'screen', 'camera']){
const m = roomMachines.pubs[kind];
if (!m) continue;
m.observe(({ state, prev, ev }) => {
if (prev === null || state === prev) return;
logLine('', 'pub.'+kind+': '+prev+' → '+state+(ev && ev.type ? ' ['+ev.type+']' : ''));
});
}
/* Share-button visibility observer — derives btn-{kind}-share /
* btn-{kind}-stop visibility from the publishSpec state. Single
* source of truth: the buttons reflect the FSM, not whoever last
* called sfuPublishX. Fox 2026-06-04 directive — every system as a
* state machine, scattered classList writes converge into one
* observer per FSM. */
function applyPublishStateUI(kind, state){
const share = $('btn-'+kind+'-share');
const stop = $('btn-'+kind+'-stop');
if (!share || !stop) return;
/* sharing = anywhere between START and STOP. The 'stopping' state
* is transient and we treat it as "still showing the stop button"
* so the UI doesn't flicker during teardown. */
const isSharing = state === 'acquiring' || state === 'negotiating' || state === 'live' || state === 'stopping';
share.classList.toggle('hidden', isSharing);
stop.classList.toggle('hidden', !isSharing);
}
for (const kind of ['screen', 'camera']){
const m = roomMachines.pubs[kind];
if (!m) continue;
m.observe(({ state, prev }) => {
if (prev === null || state === prev) return;
try { applyPublishStateUI(kind, state); } catch(e){ logLine('err','pub.'+kind+' UI: '+e.message); }
});
}
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 || !myKeys) return;
/* Anti-spoof: sign the (room_id, payload) tuple with our identity
* key so receivers verify the message originated from the pubkey
* the server attached as me.uuid -> mm.pubkey. Anti-replay: include
* a fresh timestamp; receivers reject anything older than 30s or
* not newer than the last accepted mic-state from this peer. */
const blob = JSON.stringify({ muted, t: Date.now() });
const sigInput = new TextEncoder().encode('mic-state|' + roomID + '|' + blob);
const sig = await signBytes(sigInput);
try {
send({
type: 'mic-state',
data: b64(await aesEncrypt(sigKey, blob)),
sig,
});
} 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 disable + 'connecting…' status are applied by the
* call FSM observer once we land in the 'connecting' state. */
roomMachines.call.send('ENTER', { code, handle: myHandle });
openSignal();
}
/* JS-bound heartbeat — proves to the signal-server that this tab's
* event loop is still running. WS-level pings can't do this because
* browsers auto-pong below JS even when the tab is backgrounded /
* frozen, so a closed-but-undestroyed tab would silently squat in
* the room indefinitely. JS timers throttle hard on backgrounded
* tabs (1Hz Android, ~once/minute iOS, sometimes paused on power-
* save), so missing heartbeats → server's aliveTTL fires → standard
* hiccup-grace path → peer-left + SFU evict. Fox 2026-06-03 fix. */
const ALIVE_INTERVAL_MS = 15000;
let aliveTimer = null;
function startAlivePings(){
if (aliveTimer) return;
aliveTimer = setInterval(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try { ws.send(JSON.stringify({ type: 'alive' })); } catch(_){}
}, ALIVE_INTERVAL_MS);
}
function stopAlivePings(){
if (!aliveTimer) return;
clearInterval(aliveTimer); aliveTimer = null;
}
/* Telemetry tick — fired every TELEMETRY_INTERVAL_MS while in a room.
* Dumps PC states + receiver RTP counters + <audio> element state to
* the page log so we can see in real time whether a "silent" listener
* is starved (no RTP arriving), decoded-but-not-playing (RTP fine but
* <audio> paused/muted), or DJ-mode-stalled (streamAudio not loading).
*
* One line per tick per device gives a timeline that we can scroll
* back through after a regression and pinpoint exactly when the chain
* broke. Verbose by design — fox: "way more telemetry NOW".
*
* Numbers we lean on:
* - sub.aud.pkt : packetsReceived on sub PC's audio receiver
* - sub.aud.bytes : bytesReceived on the same
* - sub.aud.jitter : current jitter in seconds
* - sub.aud.level : audioLevel (0..1, 0 = silence)
* - rtc[id]/stream[id] : <audio> element diagnostics
* - ct = currentTime (advancing = audio playing)
* - rs = readyState (4 = enough data)
* - ns = networkState (1=idle 2=loading 3=stalled)
* - pa = paused, mu = muted, vol = volume, err = MediaError code
*/
const TELEMETRY_INTERVAL_MS = 5000;
let telemetryTimer = null;
async function dumpTelemetry(){
const parts = [];
parts.push('role=' + myRole);
parts.push('sub=' + (sfuSubPC ? sfuSubPC.connectionState + '/' + sfuSubPC.iceConnectionState : 'none'));
parts.push('pub=' + (sfuPubPC ? sfuPubPC.connectionState + '/' + sfuPubPC.iceConnectionState : 'none'));
parts.push('mesh=' + peers.size);
parts.push('sListen=' + (selfListenerMode ? '1' : '0'));
parts.push('streamMode=' + streamMode.size);
parts.push('muted=' + (muted ? '1' : '0'));
/* receiver stats from sfuSubPC (the main listener path) */
if (sfuSubPC && typeof sfuSubPC.getStats === 'function'){
try {
const stats = await sfuSubPC.getStats(null);
const nowMs = Date.now();
stats.forEach(r => {
if (r.type === 'inbound-rtp' && r.kind === 'audio'){
/* lastPacketReceivedTimestamp pins the EXACT real-time
* moment audio RTP stopped — pkt-delta only shows it after
* the next tick. Express as 'ago' so a frozen flow stands
* out at a glance ('lp=0.1s' = fine, 'lp=12s' = dead).
*
* jbuf is the ACTUAL average jitter-buffer depth in seconds.
* jitterBufferDelay accumulates "total seconds of buffer
* delay applied to emitted samples" and jitterBufferEmittedCount
* counts the emitted samples — ratio is the average. Fox
* 2026-06-04: playoutDelayHint is a hint, not a contract;
* we need to SEE whether the receiver actually holds 4s. If
* jbuf << 4s the receiver is ignoring the hint and any
* upstream stall is instantly audible. */
const lp = r.lastPacketReceivedTimestamp
? ((nowMs - r.lastPacketReceivedTimestamp) / 1000).toFixed(1) + 's'
: '?';
const jbuf = (r.jitterBufferEmittedCount > 0)
? (r.jitterBufferDelay / r.jitterBufferEmittedCount).toFixed(2) + 's'
: '?';
parts.push('aud.recv pkt=' + (r.packetsReceived|0) + ' lost=' + (r.packetsLost|0) +
' bytes=' + (r.bytesReceived|0) + ' jitter=' + (r.jitter || 0).toFixed(4) +
' level=' + (r.audioLevel || 0).toFixed(3) +
' jbuf=' + jbuf +
' lp=' + lp);
}
if (r.type === 'inbound-rtp' && r.kind === 'video'){
const lp = r.lastPacketReceivedTimestamp
? ((nowMs - r.lastPacketReceivedTimestamp) / 1000).toFixed(1) + 's'
: '?';
const jbuf = (r.jitterBufferEmittedCount > 0)
? (r.jitterBufferDelay / r.jitterBufferEmittedCount).toFixed(2) + 's'
: '?';
parts.push('vid.recv pkt=' + (r.packetsReceived|0) + ' lost=' + (r.packetsLost|0) +
' frames=' + (r.framesDecoded|0) +
' jbuf=' + jbuf +
' lp=' + lp);
}
});
} catch(e){ parts.push('stats.err=' + e.message); }
}
/* publisher-side stats — what we are actually emitting on the wire.
* One line per kind so screen-audio shows up independently of
* screen-video (earlier collapse hid scr.send.aud entirely under
* scr.send.vid). Critical for diagnosing whether Firefox
* getDisplayMedia couples its audio + video source producers under
* X11 contention. gap = now - lastPacketSent — a gap > frame-
* interval is a wire stall. rtt is the most recent round-trip from
* remote-inbound-rtp (the SFU's view of our sender). */
const pubPCs = [
['mic.send', sfuPubPC],
['cam.send', sfuCameraPC],
['scr.send', sfuScreenPC],
['game.send', sfuGamePC],
];
for (const [label, pc] of pubPCs){
if (!pc || typeof pc.getStats !== 'function') continue;
try {
const stats = await pc.getStats(null);
const nowMs = Date.now();
/* keyed by ssrc so audio + video each get a slot; remote-inbound
* pairs back via .ssrc on remote-inbound-rtp → ssrc on outbound. */
const outsBySSRC = new Map();
const remoteInsBySSRC = new Map();
stats.forEach(r => {
if (r.type === 'outbound-rtp' && (r.kind === 'audio' || r.kind === 'video')){
outsBySSRC.set(r.ssrc, r);
}
if (r.type === 'remote-inbound-rtp' && typeof r.ssrc !== 'undefined') {
remoteInsBySSRC.set(r.ssrc, r);
}
});
for (const out of outsBySSRC.values()){
const gap = out.lastPacketSentTimestamp
? ((nowMs - out.lastPacketSentTimestamp) / 1000).toFixed(1) + 's'
: '?';
const kindLabel = out.kind === 'audio' ? '.aud' : '.vid';
let s = label + kindLabel + ' pkt=' + (out.packetsSent|0) +
' bytes=' + (out.bytesSent|0) +
' gap=' + gap;
if (out.kind === 'video') s += ' frames=' + (out.framesEncoded|0);
const remoteIn = remoteInsBySSRC.get(out.ssrc);
if (remoteIn) {
s += ' rtt=' + ((remoteIn.roundTripTime||0).toFixed(3)) +
' rlost=' + (remoteIn.packetsLost|0) +
' rjit=' + ((remoteIn.jitter||0).toFixed(4));
}
parts.push(s);
}
} catch(_){}
}
/* every <audio> element in the room — both the WebRTC remoteAudio
* pool (rtc[]) and the DJ HTTP-pull streamAudio (stream[]). Capture
* wedged elements (rs>=2 HAVE_CURRENT_DATA but paused at ct=0) so
* the recovery path below can re-lease fresh pool elements for
* them. This mimics what host hard-refresh achieves: fresh
* attachSfuTrack on a fresh element from the pre-blessed pool. */
const wedgedRtc = [];
let i = 0;
for (const [uuid, a] of remoteAudio){
if (a.paused && a.currentTime === 0 && a.readyState >= 2 && a.srcObject){
wedgedRtc.push([uuid, a]);
}
parts.push('rtc[' + uuid.slice(0,4) + '] rs=' + a.readyState + ' ns=' + a.networkState +
' pa=' + (a.paused?1:0) + ' mu=' + (a.muted?1:0) + ' ct=' + a.currentTime.toFixed(2) +
' err=' + (a.error?a.error.code:'_'));
if (++i > 4) { parts.push('rtc…+' + (remoteAudio.size - i) + ' more'); break; }
}
i = 0;
for (const [uuid, a] of streamAudio){
parts.push('stream[' + uuid.slice(0,4) + '] rs=' + a.readyState + ' ns=' + a.networkState +
' pa=' + (a.paused?1:0) + ' mu=' + (a.muted?1:0) + ' ct=' + a.currentTime.toFixed(2) +
' src=' + (a.src ? (a.src.length>30 ? '…'+a.src.slice(-30) : a.src) : 'none') +
' err=' + (a.error?a.error.code:'_'));
if (++i > 4) { parts.push('stream…+' + (streamAudio.size - i) + ' more'); break; }
}
logLine('', '· ' + parts.join(' '));
/* Auto-recovery for wedged WebRTC audio elements. The Firefox
* Android phone consistently gets stuck with pa=1 ct=0 even though
* the receiver's aud.recv stat shows level>0 (real audio decoding).
* Reproduced by fox 2026-06-04: "hard refresh on host fixed the
* phone" — because the host's republish triggers a fresh ontrack
* on the phone, which leases a NEW pool element with intact
* autoplay blessing. We mirror that mechanism here: when an element
* is wedged, lease a fresh pool element, rebind the same stream,
* play(), swap into remoteAudio. The old wedged element gets
* removed from the DOM. tap-anywhere was tried in 6c9d1b8 and
* reverted in 24bfc81 — fox: don't add user-visible noise. This
* is a silent automatic recovery instead. */
for (const [uuid, oldEl] of wedgedRtc){
const stream = oldEl.srcObject;
if (!stream) continue;
try { oldEl.pause(); } catch(_){}
try { oldEl.srcObject = null; } catch(_){}
try { oldEl.remove(); } catch(_){}
const fresh = leaseAudioElement();
remoteAudio.set(uuid, fresh);
applySinkTo(fresh);
fresh.addEventListener('playing', () => logLine('', 'rtc recover '+uuid.slice(0,4)+' playing'), { once: true });
fresh.srcObject = stream;
try {
const p = fresh.play();
if (p && p.catch) p.catch(e => logLine('err','rtc recover '+uuid.slice(0,4)+' play rejected: '+e.message));
} catch(e){ logLine('err','rtc recover '+uuid.slice(0,4)+' play threw: '+e.message); }
logLine('', 'rtc wedge-recovery '+uuid.slice(0,4)+' — fresh pool element bound');
}
}
function startTelemetryLoop(){
if (telemetryTimer) return;
telemetryTimer = setInterval(() => {
dumpTelemetry().catch(e => logLine('err','telemetry: '+e.message));
}, TELEMETRY_INTERVAL_MS);
/* fire one immediately so the user sees state without waiting for
* the first interval to elapse. */
dumpTelemetry().catch(()=>{});
}
function stopTelemetryLoop(){
if (!telemetryTimer) return;
clearInterval(telemetryTimer); telemetryTimer = null;
}
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 });
startAlivePings();
startTelemetryLoop();
};
ws.onclose = () => {
ws = null;
stopAlivePings();
stopTelemetryLoop();
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; }
/* Firehose every received signal event with its epoch. Lets us
* correlate phone-side regressions ('audio went silent at 16:29:07')
* against the exact server events the phone received. Skip the
* uninteresting high-rate types so the log stays readable. */
const noiseTypes = new Set(['sdp-from', 'mic-state', 'spotlight', 'state']);
if (!noiseTypes.has(m.type)){
const ep = (m.epoch != null) ? ' e=' + m.epoch : '';
const who = m.uuid ? (' u=' + String(m.uuid).slice(0,4))
: m.from ? (' from=' + String(m.from).slice(0,4))
: m.target ? (' tgt=' + String(m.target).slice(0,4))
: '';
const role = m.role ? (' role=' + m.role) : '';
const action = m.action ? (' action=' + m.action) : '';
logLine('', '« ' + m.type + ep + who + role + action);
}
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;
/* fresh listener entry — buffer hasn't filled yet, status
* should sit in "connecting — buffering" until a worklet
* reports started. */
if (myRole === 'listener' && !reentry) resetListenerBufferReady();
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);
/* dot, leave/mute button visibility, sec-room reveal, entry-row
* hide and status text are all driven by applyCallStateUI()
* from the 'joined' state. Welcome already dispatched WELCOME
* to the call FSM above (line ~3914), so the observer has
* already run by this point. Keeping this comment here as a
* pointer for the next migration. */
/* 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();
/* Ask the room to re-broadcast their current mic-state so our
* member list shows the right mute icons. Without this, mm.muted
* defaults to undefined and the UI reports every peer as
* 'open' until they happen to toggle. Fires for hiccup-takeover
* too (since the welcome path runs on every reconnect). */
try { send({ type: 'mic-state-req' }); } catch(_){}
break;
case 'state':
roomEpoch = m.epoch; applyState(m.state); flushSfuStreams(); renderRoom();
/* fresh epoch arrived — release the mod-action queue gate so the
* next mod action signs against this new value rather than the
* stale one from before our previous action's server-side bump. */
resolvePendingStateUpdate();
break;
case 'mic-state-req':
/* Another peer just (re)joined and asked the room to re-announce.
* Only speakers with a live mic respond — listeners have no
* meaningful mic-state to share. */
if (canSpeak(myRole) && micStream) sendMicState();
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); }
/* classic 'guest joined' chime — server only sends peer-joined
* to OTHER members (broadcastExcept), so we're always notifying
* the existing room about a new arrival. */
playToneJoin();
/* re-announce our mic state so the new arrival's UI reflects
* reality. Server-side mic-state-req covers welcome timing; this
* covers the standard 'a new peer joined while we were already
* here' case. */
if (canSpeak(myRole) && micStream) sendMicState();
/* 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;
setListenerStatusAware(myRole);
}
/* 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);
/* if we're in self-listener mode and a new speaker just arrived,
* pull them onto the buffered HTTP stream too so we hear them on
* the same path as everyone else */
if (selfListenerMode && canSpeak(m.role) && m.uuid !== myUUID && m.pubkey){
try {
const ph = hex(unb64(m.pubkey));
if (ph && !streamMode.has(ph)){
streamMode.add(ph);
startStream(m.uuid, ph);
const w = remoteAudio.get(m.uuid);
if (w) try { w.muted = true; } catch(_){}
}
} catch(_){}
}
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');
/* doorman chime — someone actually left (post hiccup-grace).
* server never sends peer-left for self so no self-filter. */
playToneLeave();
/* 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);
clearMeshRetryState(m.uuid);
detachListenerStream(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 mm = members.get(m.uuid);
if (!mm || !mm.pubkey) break;
if (!m.sig){ logLine('err', 'mic-state from '+m.uuid+' unsigned — ignoring'); break; }
const decrypted = await aesDecrypt(sigKey, unb64(m.data));
const sigInput = new TextEncoder().encode('mic-state|' + roomID + '|' + decrypted);
if (!(await verifyEd25519(mm.pubkey, sigInput, m.sig))){
logLine('err', 'mic-state from '+m.uuid+' bad signature — ignoring');
break;
}
const s = JSON.parse(decrypted);
/* anti-replay: drop messages older than 30s OR not newer than
* the last one we accepted from this peer */
const now = Date.now();
if (typeof s.t !== 'number' || Math.abs(now - s.t) > 30000) break;
if (mm._micT && s.t <= mm._micT) break;
mm._micT = s.t;
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'); }
/* attention bell for mods — skip if it's our own hand going up
* (server broadcasts to everyone including the raiser). */
if (m.uuid !== myUUID) playToneRaise();
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;
/* same gate-release as case 'state' / case 'peer-booted' —
* role-change carries a fresh epoch, so the next mod action
* (e.g. promote-then-kick) shouldn't have to wait the 1500ms
* timeout fallback before signing. */
resolvePendingStateUpdate();
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):''));
/* demoted to listener: reset the buffer-ready gate so
* the new 4s cushion has to refill before the UI flips
* back to "connected as listener". */
if (m.role === 'listener' && prev !== 'listener') resetListenerBufferReady();
setListenerStatusAware(myRole);
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);
}
/* If the role transition stripped them of the ability to
* publish (anything -> listener), their camera/screen/game
* tiles can't get fresh RTP anymore. The demoted page calls
* sfuUnpublish* on its own side which should propagate via
* SFU 'ended', but that path is unreliable (Pion may not
* fire OnConnectionStateChange immediately and SSE renego
* can race). Authoritative removal: drop tiles by pubkey
* here, the moment the role change broadcast lands. */
if (canSpeak(prev) && !canSpeak(m.role) && subjPub){
try {
removeScreenTile(subjPub);
removeCameraTile(subjPub);
removeVideoTile('gameshare', subjPub);
} catch(_){}
/* 'stepped off the stage' chime — they're still in the
* room but lost mic privileges. */
playToneStepDown();
}
}
renderRoom();
reorderTiles();
}
}
break;
case 'peer-force-muted':
{
const mm = members.get(m.uuid);
const by = members.get(m.by);
const victPub = pubHexFromMsg(m, 'pubkey')
|| (mm && mm.pubkey ? pubHexFromMsg({pubkey:mm.pubkey},'pubkey') : '');
const byPub = pubHexFromMsg(m, 'by_pubkey');
logLine('', idTag(m.uuid, victPub) + ' was muted by ' + (by||byPub?idTag(m.by, byPub):'a mod'));
if (m.uuid === myUUID){
/* I was the target — server already dropped my mic publisher
* at the SFU; mirror that locally so my own UI shows muted
* and the mic track stops sending into mesh PCs too. The
* user can hit unmute to resume on their own. */
showNotice('You were muted'+(by?' by '+by.handle:'')+'. Click unmute to talk again.', 'warn');
/* MuteFSM transition handles track.enabled, sessionStorage,
* sendMicState, and UI refresh via its observer. */
roomMachines.mute.send('FORCE_MUTE');
renderRoom();
} else {
/* Mark the target visually muted right away — their next
* mic-state broadcast will confirm it on the wire. */
if (mm){ mm.muted = true; renderRoom(); }
}
}
break;
case 'peer-booted':
{
/* Server bumps room epoch on every kick/ban and includes it in
* this broadcast. Pick it up + release the mod-action queue
* gate so the next kick signs against the fresh epoch — without
* this the second kick in a session hits "stale epoch". Fox
* 2026-06-04: "couldn't kick until leaving as host." */
if (typeof m.epoch === 'number') {
roomEpoch = m.epoch;
resolvePendingStateUpdate();
}
const mm = members.get(m.uuid);
const by = members.get(m.by);
const victPub = pubHexFromMsg(m, 'pubkey')
|| (mm && mm.pubkey ? pubHexFromMsg({pubkey:mm.pubkey},'pubkey') : '');
const byPub = pubHexFromMsg(m, 'by_pubkey');
/* server now annotates the action ('kick' | 'ban'); older
* servers send no action — fall back to the generic "removed". */
const verb = m.action === 'kick' ? 'was kicked'
: m.action === 'ban' ? 'was banned'
: 'was removed';
logLine('', idTag(m.uuid, victPub)+' '+verb+' by '+(by||byPub?idTag(m.by, byPub):'a mod'));
/* same departure chime as peer-left — they're gone either way. */
if (m.uuid !== myUUID) playToneLeave();
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;
/* Clear the active-call key so a hard-refresh / bfcache restore
* doesn't sneak the kicked user right back in via auto-rejoin.
* The confirm() text says "they can rejoin" — but that means
* MANUALLY (type the code, click enter), not automatically on
* any tab event. Fox 2026-06-04: "we have auto-join on the
* phone that is kicked just rejoins". */
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
const noticeVerb = m.action === 'kick' ? 'kicked from'
: m.action === 'ban' ? 'banned from'
: 'removed from';
showNotice('You were '+noticeVerb+' this space'+(by?' by '+by.handle:'')+'.', 'warn');
logLine('err','you were '+(m.action || 'removed')+' from this space');
for (const u of [...peers.keys()]) tearPeer(u);
sfuUnpublish().catch(()=>{});
sfuUnpublishScreen().catch(()=>{});
sfuUnpublishCamera().catch(()=>{});
sfuUnpublishGame().catch(()=>{});
sfuUnsubscribe().catch(()=>{});
dropMic();
/* UI (dot, leave/mute buttons, entry-row, status text) is
* now driven by the call-state observer applyCallStateUI()
* from the 'booted' state. We just need to dispatch BOOTED
* with the action so the observer can render the right
* status text (kicked/banned). */
roomMachines.call.send('BOOTED', { by: m.by, action: m.action || 'kick' });
}
/* Authoritative tile teardown by pubkey — booted users can't be
* publishing anything anymore by definition. The SFU side eviction
* (server posts /internal/block, then OnConnectionStateChange ->
* removePublisher) will eventually fire 'ended' on subscribers,
* but it can lag enough that the camera tile stays visible past
* the kick. Drop it here so the boot is visually instant. */
if (victPub){
try {
removeScreenTile(victPub);
removeCameraTile(victPub);
removeVideoTile('gameshare', victPub);
} catch(_){}
}
members.delete(m.uuid); tearPeer(m.uuid); clearMeshRetryState(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 { setListenerStatusAware(myRole); } /* 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));
}
/* No auto-enrol into DJ mode for listeners. Firefox Android refuses
* autoplay on every fresh <audio src=URL> and the page-entry gesture
* has aged out by the time the SFU lazy-inits its Ogg writer, so the
* DJ path is dead-on-arrival for phone listeners. WebRTC is the
* canonical listener playback path; the 'play' button in the
* identity panel provides the second gesture if attachSfuTrack's
* initial play() got swallowed. */
}
let inRoleTransition = false;
/* Walk every live audio + video receiver and push the new playout
* target into it. Called on role change so a listener-just-promoted
* to speaker drops from 4s lean-back to 0.5s conversational latency
* (and back the other way on demote) without leaving + rejoining.
* The worklet gets a postMessage; native receivers get jitterBufferTarget +
* playoutDelayHint reassignment.
*
* Mesh peers are left at SPEAKER_PLAYOUT_DELAY_SEC unconditionally —
* mesh is always peer-to-peer conversation. */
function retargetAllReceivers(role){
const target = playoutDelayForRole(role);
/* worklet-buffered audio receivers (every role uses these now) */
for (const [uuid, node] of listenerAudioNodes){
node.targetSeconds = target;
if (node.jbuf && node.jbuf.port){
try { node.jbuf.port.postMessage({ cmd: 'retarget', targetSeconds: target }); } catch(_){}
}
}
/* SFU sub PC receivers — audio (the native side, downstream of which
* the worklet sits) AND video (which sits directly on the receiver). */
if (sfuSubPC && typeof sfuSubPC.getReceivers === 'function'){
for (const r of sfuSubPC.getReceivers()){
try { r.playoutDelayHint = target; } catch(_){}
try { r.jitterBufferTarget = target * 1000; } catch(_){}
}
}
logLine('', 'retarget all receivers → '+target+'s (role='+role+')');
}
async function onRoleChanged(prev, next){
/* re-target every live receiver to the new role's playout delay
* BEFORE we start dropping/grabbing mics — the audio path stays
* continuous; only the buffer depth adjusts. */
retargetAllReceivers(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)){
/* Listener -> speaker (or cohost/host) is a brand-new mic grab.
* Default to MUTED so the user doesn't broadcast whatever was
* happening in their room when they accepted. The MuteFSM
* ROLE_PROMOTED event sets ctx.source='self' and the observer
* persists '1' to sessionStorage. ensureMicAndUI's
* applyMuteState then reads the FSM state via muted_get(). */
roomMachines.mute.send('ROLE_PROMOTED');
/* coming out of listener — tear down every HTTP DJ-mode tap so
* the WebRTC audio path takes over (low-latency for conversation).
* Also stop the auto-enrol retry loop — speakers don't need it. */
stopAutoEnrolRetryLoop();
autoDisableDjModeForAll();
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. */
/* self-listener flag is meaningless once we're a listener (the
* row's stream toggle disappears); flip it OFF so its streamMode
* entries get torn down cleanly with the rest of our state. */
roomMachines.selfListener.send('DEMOTED');
dropMic();
/* Demoted to listener: drop the mute-by-self state — there's
* no mic to broadcast through anymore. The next promote will
* default-mute via ROLE_PROMOTED. */
roomMachines.mute.send('RESTORE_UNMUTED');
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(); await refreshSpeakerList();
$('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();
/* Listeners have no mic — mute button stays disabled. WebRTC
* playback is autoplayed by attachSfuTrack the same way speakers
* get their inbound mesh/SFU audio; no second-gesture button. */
}
/* ==================================================================
* 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.
* ================================================================== */
/* per-peer retry tracking — repeated mesh failures (NAT/firewall the
* page can't traverse, even with TURN) used to trigger a tight 1.5s
* reconnect loop forever. Each cycle ate CPU/network and added to
* the robot-voice/lost-mic noise we keep chasing. Now we exponential-
* backoff and after PEER_MESH_MAX_RETRIES we give up the mesh entirely
* for that peer and let SFU carry the audio. peer-left clears the
* tracking, so a fresh join from the same uuid starts a new budget. */
const PEER_MESH_MAX_RETRIES = 4;
const peerMeshGiveUp = new Set(); // uuids we've stopped trying to mesh
const peerMeshRetries = new Map(); // uuid -> attempt count
const peerMeshTimers = new Map(); // uuid -> pending setTimeout id
function clearMeshRetryState(uuid){
peerMeshGiveUp.delete(uuid);
peerMeshRetries.delete(uuid);
const t = peerMeshTimers.get(uuid);
if (t){ clearTimeout(t); peerMeshTimers.delete(uuid); }
}
async function connectToPeer(uuid, weOffer){
if (peers.has(uuid)) return;
if (peerMeshGiveUp.has(uuid)){
logLine('', 'peer '+uuid+' mesh disabled (max retries) — staying on SFU');
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) => {
/* Tear down the SFU worklet path for this uuid before mesh's
* <audio> takes over. Without this we hear the same voice
* through TWO paths simultaneously: SFU mic → AudioContext
* worklet (~0.5s delay), and mesh mic → <audio> (~50ms).
* Pre-refactor, both paths used <audio> in the remoteAudio map
* so the second overwrote the first. After role-aware routing,
* SFU lives in listenerAudioNodes and mesh in remoteAudio —
* different maps, both play, echo. Fox 2026-06-04. */
detachListenerStream(uuid);
let a = remoteAudio.get(uuid);
if (!a){
a = document.createElement('audio'); a.autoplay = true;
document.body.appendChild(a); remoteAudio.set(uuid, a);
applySinkTo(a);
}
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
/* mesh path is peer-to-peer between two speakers (you'd never be
* in mesh as a pure listener). Always conversational latency
* here — fixed at SPEAKER_PLAYOUT_DELAY_SEC, no role check
* needed. */
try { ev.receiver.playoutDelayHint = SPEAKER_PLAYOUT_DELAY_SEC; } catch(_){}
try { ev.receiver.jitterBufferTarget = SPEAKER_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
stopMeter(uuid); startMeter(uuid, a.srcObject);
logLine('', 'mesh audio attached for '+uuid.slice(0,4)+' — SFU worklet path detached');
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected'){
/* successful connect — reset the retry budget so a much-later
* transient failure gets a fresh round of attempts. */
peerMeshRetries.delete(uuid);
return;
}
if (pc.connectionState !== 'failed' || peers.get(uuid) !== pc) return;
const attempts = (peerMeshRetries.get(uuid) || 0) + 1;
peerMeshRetries.set(uuid, attempts);
tearPeer(uuid);
/* Mesh PC just died; the audio element for this peer was bound to
* the dying mesh stream and won't recover on its own. Switch back
* to the cached SFU stream so the user keeps hearing them while
* mesh reconnect attempts run in the background. */
try { attachCachedSfuStreamFor(uuid); } catch(_){}
if (attempts >= PEER_MESH_MAX_RETRIES){
peerMeshGiveUp.add(uuid);
logLine('err', 'peer '+uuid+' mesh failed '+attempts+'x — giving up, audio stays on SFU');
return;
}
/* exponential backoff: 2s, 4s, 8s, 16s (capped). Keeps the room
* from melting when a peer's NAT genuinely can't mesh through. */
const delayMs = Math.min(16000, 2000 * Math.pow(2, attempts - 1));
logLine('', 'peer '+uuid+' failed — reconnecting in '+(delayMs/1000)+'s (attempt '+attempts+'/'+PEER_MESH_MAX_RETRIES+')');
const t = setTimeout(() => {
peerMeshTimers.delete(uuid);
if (members.has(uuid) && canSpeak(members.get(uuid).role) && canSpeak(myRole)){
connectToPeer(uuid, myUUID < uuid);
}
}, delayMs);
peerMeshTimers.set(uuid, t);
};
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); }
/* stream-mode <audio> for this uuid (DJ mode) — close the HTTP pull
* so the SFU stops fanning Ogg pages to a dead client */
const s = streamAudio.get(uuid);
if (s){ try { s.pause(); s.removeAttribute('src'); s.load(); s.remove(); } catch(_){} streamAudio.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
*
* Epoch race: every successful mod action server-side increments
* roomEpoch and the new value rides back on the next 'state' broadcast.
* Two kicks fired in rapid succession both signed with the same epoch
* N — the first succeeds (advances server to N+1), the second is
* rejected with "stale epoch" because the client hasn't received the
* state-update yet. Fox 2026-06-04: tried to kick two phones, only
* one was kicked, signal log showed 1 AUDIT + "signal: stale epoch"
* client-side.
*
* Fix: serialize mod-action sends with a promise that resolves on the
* next 'state' broadcast (or a 1.5s timeout fallback so a missed state
* doesn't permanently wedge the queue). signBytes() is called AFTER
* the wait, so the signature is computed against the freshest known
* roomEpoch. Single-action use is unaffected (queue is empty). */
let lastModSettled = Promise.resolve();
let pendingStateResolver = null;
function awaitStateUpdate(){
/* resolves on next case 'state' arrival, or 1500ms timeout */
return new Promise(res => {
const t = setTimeout(() => { if (pendingStateResolver === resolver) pendingStateResolver = null; res(); }, 1500);
const resolver = () => { clearTimeout(t); res(); };
pendingStateResolver = resolver;
});
}
function resolvePendingStateUpdate(){
if (!pendingStateResolver) return;
const r = pendingStateResolver; pendingStateResolver = null; r();
}
async function runModSerial(label, fn){
/* chain off lastModSettled so the second call waits for the first */
const prev = lastModSettled;
let settle;
lastModSettled = new Promise(res => { settle = res; });
try {
await prev.catch(()=>{});
logLine('', 'mod['+label+'] epoch='+roomEpoch+' (queue ready)');
await fn();
/* wait for the state update that bumps roomEpoch so the next
* action signs against the fresh epoch */
await awaitStateUpdate();
logLine('', 'mod['+label+'] settled epoch='+roomEpoch);
} finally { settle(); }
}
async function modInvite(uuid){
return runModSerial('mic-invite', async () => {
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){
return runModSerial('grant-mic', async () => {
const sig = await signBytes(sigAction(roomID, roomEpoch, 'grant-mic', uuid));
send({ type:'grant-mic', to: uuid, epoch: roomEpoch, sig });
});
}
async function modPromote(uuid){
return runModSerial('promote', async () => {
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){
return runModSerial('demote→'+to, async () => {
const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to));
send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig });
});
}
/* mute: drops the speaker's mic publisher at the SFU so their voice
* stops immediately. No role change — they keep their seat, keep
* sharing screen / camera / game, keep hearing the room — and they
* may unmute themselves on their own when they want to talk again. */
async function modMute(uuid){
if (!confirm('mute this speaker? (they can unmute themselves)')) return;
return runModSerial('mute', async () => {
const sig = await signBytes(sigAction(roomID, roomEpoch, 'mute', uuid));
send({ type:'mute', target: uuid, epoch: roomEpoch, sig });
});
}
/* kick: drop the peer from this session — evicts their SFU PCs (audio
* actually stops) but does NOT block their pubkey. They can rejoin
* freely. Use when someone's audio is leaking from a closed-tab /
* wrong-device / hung session that we just want gone right now. */
async function modKick(uuid){
if (!confirm('kick this person? (they can rejoin)')) return;
return runModSerial('kick', async () => {
const sig = await signBytes(sigAction(roomID, roomEpoch, 'kick', uuid));
send({ type:'kick', target: uuid, epoch: roomEpoch, sig });
});
}
/* ban: drop AND block their pubkey for the room's hold window — they
* cannot rejoin. Use for actual moderation removals. */
async function modBan(uuid){
if (!confirm('ban this person? (they cannot rejoin)')) return;
return runModSerial('ban', async () => {
const sig = await signBytes(sigAction(roomID, roomEpoch, 'ban', uuid));
send({ type:'ban', 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; }
/* ----- HTTP broadcast stream (DJ mode) — parallel to SFU mic subscribe.
* For a given speaker's pubHex, the user can flip into "stream" mode:
* we mute the WebRTC remote audio for that uuid and attach a separate
* <audio> element pulling /zebra-spaces-sfu/stream?room=R&pub=PUBHEX
* as Ogg/Opus. ~1.53s extra latency, but the browser's jitter buffer
* smooths out network glitches the WebRTC path can't. State is keyed
* by pubHex (not uuid) so it survives session-uuid churn on rejoin. */
const streamMode = new Set(); // pubHex strings currently streaming
const streamAudio = new Map(); // uuid -> <audio> pulling stream
/* Listener output-mute: a fresh listener tab on Firefox Android can
* silently fail to autoplay even WebRTC audio if the entry-button
* gesture has aged out by the time attachSfuTrack fires. The btn-mute
* click is the only reliable second gesture we have. listenerOutput-
* Muted starts TRUE so the visible button starts as 'play' — fox: the
* canonical 'I want sound' moment is when they tap that button. */
let listenerOutputMuted = true;
/* Self-listener mode: a speaker / cohost / host has flipped their own
* row's stream toggle to consume the room via the buffered HTTP Ogg/
* Opus path instead of the live WebRTC mesh. Auto-mutes their mic so
* they can't talk into a delayed stream (they'd be 2-4s behind the
* conversation); unmuting toggles them back to WebRTC seamlessly. */
/* selfListenerMode is now a derived getter against the SelfListenerFSM
* (roomMachines.selfListener). Single source of truth — direct writes
* to the boolean would drift from the FSM state, defeating the point.
* All transitions go through .send('TOGGLE' | 'ENABLE' | 'DISABLE' |
* 'UNMUTE' | 'DEMOTED' | 'CLEAR'); side effects ride an observer
* attached during room setup (see selfListenerObserver below). */
function selfListenerMode_get(){ return roomMachines.selfListener.state === 'on'; }
Object.defineProperty(window, 'selfListenerMode', { get: selfListenerMode_get });
/* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener
* phones — Firefox Android refuses autoplay on every fresh <audio>
* with src URL and a hard refresh starts the same loop. WebRTC stays
* the canonical listener path; DJ mode is opt-in for desktop only via
* the speaker-row stream toggle (host control). */
function activateListenerAudio(){
/* Fire .play() on every existing <audio> element inside the click
* gesture. The user just tapped — Firefox Android grants media
* permission for the duration of this synchronous handler. Also
* extends to streamAudio (DJ HTTP pulls) so a single tap recovers
* both WebRTC and HTTP paths. */
let played = 0, failed = 0;
const tryPlay = (a, label, uuid) => {
try { a.muted = false; } catch(_){}
try {
const p = a.play();
if (p && p.then){
p.then(()=>played++).catch(e => { failed++; logLine('err',label+' play '+uuid.slice(0,8)+' paused='+a.paused+' rs='+a.readyState+' err='+e.name+': '+e.message); });
} else played++;
} catch(e){ failed++; logLine('err',label+' play threw '+uuid.slice(0,8)+': '+e.message); }
};
for (const [uuid, a] of remoteAudio){ tryPlay(a, 'rtc', uuid); }
for (const [uuid, a] of streamAudio){ tryPlay(a, 'stream', uuid); }
logLine('', 'activateListenerAudio played='+played+' failed='+failed+' rtc='+remoteAudio.size+' stream='+streamAudio.size);
}
/* activateListenerAudio above remains as a manual recovery primitive
* (callable from a future UI button if needed), but the page no
* longer arms a global tap-anywhere listener — fox 2026-06-04: tap-
* anywhere from 6c9d1b8 was rejected for adding user-visible noise.
* Silent automatic recovery via the telemetry tick's wedge-detect +
* pool re-lease is the shape that ships. */
function applyAudioMute(){
/* legacy hook still called by attachSfuTrack / startStream; for the
* listener-tap-to-play flow we now do the work in activateListener-
* Audio() and the click handler. This is now a no-op for listeners. */
if (myRole !== 'listener') return;
/* For listeners that haven't tapped yet, ensure nothing audible —
* existing WebRTC autoplay may have rejected silently, no harm. */
}
function streamUrlFor(pubHex){
return SFU_BASE + '/stream?room=' + encodeURIComponent(roomID) + '&pub=' + pubHex;
}
/* Audio activation now happens in the entry-button click handler via
* primeAudioOnGesture() — see below the leave/entry section. By the
* time auto-enrolment fires we already have the page's audio
* permission granted, so dynamic <audio> elements can play() without
* further user interaction. The prior tap-anywhere-to-resume queue
* is gone: it was a regression because (a) it didn't actually fix
* playback in many cases and (b) it added user-visible noise where
* none should exist. */
async function startStream(uuid, pubHex){
const wantUrl = streamUrlFor(pubHex);
let a = streamAudio.get(uuid);
/* Idempotent: second toggle-on for the same pubHex while we're
* already loading/playing is a no-op. Resetting .src abort the
* in-flight load — Firefox surfaces this as 'fetching aborted at
* user request' which we'd mistake for autoplay block. */
if (a && a.src === wantUrl && !a.error) {
logLine('', 'stream: skip dup for '+pubHex.slice(0,12)+' (already loading/playing)');
return;
}
if (!a){
a = document.createElement('audio');
a.autoplay = true; a.controls = false;
a.preload = 'auto'; a.playsInline = true;
document.body.appendChild(a);
streamAudio.set(uuid, a);
await applySinkTo(a);
}
/* Toggle click is itself a user gesture, so unmuted play is allowed
* without the muted-autoplay workaround. Older code muted-started and
* relied on applyAudioMute() to unmute later, but that hook is a
* no-op for non-listeners now — host/speaker self-monitor (and host
* DJ-mode on remote speakers) was playing silently. Unmute on every
* call so a reused element from a prior stop also re-monitors. */
try { a.muted = false; } catch(_){}
/* Diagnostic breadcrumbs — earlier sessions clicked the toggle and
* the SFU log saw zero /stream GETs. Track loadstart (fetch began),
* canplay (data arriving), stalled (TCP stuck) so we can tell on
* which leg the path breaks next time. once:false so we see every
* recovery cycle. */
a.addEventListener('loadstart', () => logLine('', 'stream loadstart '+pubHex.slice(0,12)), { once: true });
a.addEventListener('canplay', () => logLine('', 'stream canplay '+pubHex.slice(0,12)), { once: true });
a.addEventListener('stalled', () => logLine('err','stream stalled '+pubHex.slice(0,12)));
logLine('', 'stream open: '+pubHex.slice(0,12)+' → '+wantUrl);
a.src = wantUrl;
const onPlaying = () => {
logLine('', 'stream on for '+pubHex.slice(0,12)+' — DJ mode (~2s delay, glitch-free)');
};
const onFail = (why) => {
streamMode.delete(pubHex); // allow next toggle attempt to retry
logLine('err', 'stream for '+pubHex.slice(0,12)+' '+why+' — staying on live WebRTC');
};
a.addEventListener('playing', onPlaying, { once: true });
a.addEventListener('error', (ev) => {
const err = a.error;
const code = err ? err.code : '?';
const msg = err ? (err.message || '') : '';
onFail('error code='+code+' msg='+msg);
}, { once: true });
try {
const p = a.play();
if (p && p.catch) p.catch(e => onFail('autoplay blocked: '+e.message));
} catch(e){
onFail('play threw: '+e.message);
}
}
function stopStream(uuid){
const a = streamAudio.get(uuid);
if (a){
try { a.pause(); } catch(_){}
try { a.removeAttribute('src'); a.load(); } catch(_){}
}
/* re-enable the WebRTC audio for this peer in case we'd muted it
* while DJ mode was active. */
const w = remoteAudio.get(uuid);
if (w) try { w.muted = false; } catch(_){}
}
function toggleStreamFor(uuid, pubHex){
if (streamMode.has(pubHex)){
streamMode.delete(pubHex);
stopStream(uuid);
} else {
streamMode.add(pubHex);
startStream(uuid, pubHex);
}
renderRoom();
}
/* selfListenerMode — a speaker/cohost/host who wants to consume the
* room via the buffered HTTP Ogg/Opus path instead of the live WebRTC
* mesh. Useful when WebRTC ICE / DTLS dies under cellular churn or
* the user just wants the higher-fidelity broadcast pipeline.
*
* Coupled to mute: turning it ON auto-mutes (you'd be 2-4s behind the
* conversation; talking into that delay is hopeless). Unmuting flips
* it OFF so the user is seamlessly back on the live mesh.
*
* Mechanics: populate streamMode with every audible peer's pubHex,
* fire startStream() for each so we open the HTTP Ogg/Opus pull, and
* mute the corresponding remoteAudio (WebRTC) elements so we don't
* hear both paths at once. disable* tears everything down + restores
* the WebRTC playback. */
/* Side-effect runners — invoked ONLY by the SelfListenerFSM observer
* below. No internal state mutation, no idempotency guards (the FSM
* handles re-entry by never emitting a same-state transition). */
function runSelfListenerEnable(){
/* auto-mute mic before we start playing the delayed stream — the
* MuteFSM AUTO_MUTE event sets ctx.source='self-listener' so the
* observer chain (applyMuteState + sendMicState + sessionStorage)
* all fires from one place. */
if (micStream && !muted) roomMachines.mute.send('AUTO_MUTE');
let added = 0;
/* INCLUDE self in the enrolment — listeners hear every speaker
* (including us), so the canonical "what listeners hear" experience
* pulls our own /stream too. Mic is already muted, so the loopback
* doesn't create feedback; the user gets full broadcast monitoring,
* AND a solo-host alone with listeners still has something to play. */
for (const [uuid, mm] of members){
if (!canSpeak(mm.role) || !mm.pubkey) continue;
let pubHex;
try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ continue; }
if (streamMode.has(pubHex)) continue;
streamMode.add(pubHex);
startStream(uuid, pubHex);
/* mute the matched WebRTC remote so we don't hear both paths. */
const w = remoteAudio.get(uuid);
if (w) try { w.muted = true; } catch(_){}
added++;
}
roomMachines.selfListener.context.enrolledCount = added;
renderRoom();
logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path (incl. self), mic muted');
}
function runSelfListenerDisable(){
for (const pubHex of [...streamMode]){
let foundUuid = null;
for (const [u, mm] of members){
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ foundUuid = u; break; } } catch(_){}
}
streamMode.delete(pubHex);
if (foundUuid) stopStream(foundUuid);
}
/* restore WebRTC playback for every remote */
for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
roomMachines.selfListener.context.enrolledCount = 0;
/* If the mic was auto-muted by entering self-listener mode, also
* AUTO_UNMUTE so the user is fully back to the live conversation.
* Mute states from other sources (user click, mod) are preserved. */
if (roomMachines.mute.context.source === 'self-listener'){
roomMachines.mute.send('AUTO_UNMUTE');
}
renderRoom();
logLine('', 'self-listener OFF — back to live WebRTC mesh');
}
/* Observer: side effects fire on every off↔on transition. */
roomMachines.selfListener.observe(({ state, prev }) => {
if (prev === null || state === prev) return;
if (state === 'on') runSelfListenerEnable();
else runSelfListenerDisable();
});
/* Default-on DJ mode for listeners: skip the WebRTC mic playback path
* for every audible peer and pull HTTP Ogg/Opus instead. Browser's
* <audio> element keeps a deep media buffer (~30s in Chrome) that
* absorbs glitches WebRTC can't. Speakers stay on WebRTC for low-
* latency conversational audio — only pure listeners get the
* buffered path. Idempotent: a peer already in streamMode stays as-is.
*
* Auto-enrolment runs at:
* - join time (onRoleEntered, only if landed as listener)
* - peer-joined (a new speaker arrived while we're listening)
* - role-change (another peer became a speaker; or we got demoted
* to listener). */
function autoEnableDjModeForListener(){
if (myRole !== 'listener') { logLine('', 'autoDJ skip: role='+myRole); return; }
let added = 0, seen = 0;
for (const [uuid, mm] of members){
if (uuid === myUUID) continue;
seen++;
if (!canSpeak(mm.role)) continue;
if (!mm.pubkey) continue;
let pubHex;
try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ continue; }
if (streamMode.has(pubHex)) continue;
streamMode.add(pubHex);
startStream(uuid, pubHex);
added++;
}
logLine('', 'autoDJ pass: members='+seen+' added='+added+' streamMode='+streamMode.size);
}
/* Periodic retry — covers the timing race where the host's mic publish
* hasn't been registered at the SFU yet when the listener first
* auto-enrols. onFail removes the pubHex from streamMode so the next
* sweep will re-attempt; without this poll the only retry trigger was
* peer-joined / role-change events, which never fire when a speaker
* who was already in the room simply starts publishing later. Cheap:
* idempotent on already-streaming pubHexes, no-op if not a listener. */
let autoEnrolRetryTimer = null;
function startAutoEnrolRetryLoop(){
if (autoEnrolRetryTimer) return;
autoEnrolRetryTimer = setInterval(() => {
if (myRole !== 'listener'){ stopAutoEnrolRetryLoop(); return; }
autoEnableDjModeForListener();
}, 4000);
}
function stopAutoEnrolRetryLoop(){
if (!autoEnrolRetryTimer) return;
clearInterval(autoEnrolRetryTimer);
autoEnrolRetryTimer = null;
}
/* Tear down all DJ-mode HTTP streams — used when we transition out
* of listener into speaker/cohost/host. Speakers need the low-
* latency mesh/SFU mic path because they're going to talk back; the
* 2s+ Ogg-pull buffer would make conversation painful. */
function autoDisableDjModeForAll(){
for (const pubHex of [...streamMode]){
let foundUuid = null;
for (const [u, mm] of members){
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ foundUuid = u; break; } } catch(_){}
}
streamMode.delete(pubHex);
if (foundUuid) stopStream(foundUuid);
}
}
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. Two
* sibling row-groups inside .mod-actions:
* .acts-primary — stream toggle, role transitions (non-destructive)
* .acts-removal — kick + ban (destructive, own line) */
const acts = document.createElement('span'); acts.className = 'mod-actions';
const actsPrimary = document.createElement('span'); actsPrimary.className = 'acts-primary';
const actsRemoval = document.createElement('span'); actsRemoval.className = 'acts-removal';
/* per-speaker "stream" toggle: flips THIS viewer's audio path for
* the targeted speaker from WebRTC subscribe → HTTP Ogg/Opus tap.
* Visibility rule:
* - SELF row: always (self-monitor — preview what listeners hear)
* - OTHER rows: host only (room-wide control)
* Speakers/cohosts get self-monitor only. Listeners get NO button
* at all — they're locked into DJ mode unconditionally for the
* glitch-free playback fox: 'epic, never choppy'.
*
* Lives in its own `strm` grid column on the TOP row (alongside the
* mic icon), NOT in the .acts-primary stack — putting it there made
* every speaker row grow a third sub-row of mod-actions even for
* non-mods (just-self-monitor self-rows), which fox flagged as
* causing vertical scroll. Single-char glyph keeps the column 1.4rem. */
let streamEl = null;
/* Per-row stream toggle. The ONLY row that gets one is SELF — and
* only when we can speak (host/cohost/speaker). The button flips
* THIS viewer's whole audio path from the live WebRTC mesh to the
* buffered HTTP Ogg/Opus broadcast tap (the listener-quality
* stream). selfListenerMode is global, so the ◉/○ glyph reflects
* that flag rather than streamMode of any single pubHex. */
if (canSpeak(m.role) && m.uuid === myUUID && canSpeak(myRole)){
streamEl = document.createElement('button');
streamEl.className = 'stream-toggle' + (selfListenerMode ? ' on' : '');
streamEl.textContent = selfListenerMode ? '◉' : '○';
streamEl.title = selfListenerMode
? 'listening on the buffered HTTP stream — click to rejoin the live WebRTC mesh (also unmutes is via the mic button)'
: 'switch yourself to the listener stream (buffered, ~2s behind) — auto-mutes your mic';
streamEl.onclick = () => roomMachines.selfListener.send('TOGGLE');
}
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));
actsPrimary.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));
actsPrimary.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));
actsPrimary.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));
actsPrimary.appendChild(b);
}
/* mute: drops their mic publisher at the SFU. Speakers always;
* cohosts only when I'm host (cohost can't mute another cohost). */
if (canSpeak(m.role) && m.role !== 'host' &&
!(m.role === 'cohost' && myRole !== 'host')){
const mb = document.createElement('button'); mb.className='small';
mb.textContent = 'mute';
mb.onclick = () => modMute(m.uuid).catch(e => logLine('err','mute: '+e.message));
actsPrimary.appendChild(mb);
}
/* kick + ban — destructive removal actions, kept on their own
* row so the muscle-memory misclick where 'ban' sat next to
* '→ speaker' can't fire by accident. */
if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){
const kb = document.createElement('button'); kb.className='small';
kb.textContent = 'kick';
kb.onclick = () => modKick(m.uuid).catch(e => logLine('err','kick: '+e.message));
actsRemoval.appendChild(kb);
const bb = document.createElement('button'); bb.className='small';
bb.textContent = 'ban';
bb.onclick = () => modBan(m.uuid).catch(e => logLine('err','ban: '+e.message));
actsRemoval.appendChild(bb);
}
}
acts.appendChild(actsPrimary);
acts.appendChild(actsRemoval);
row.appendChild(badge); row.appendChild(handle); row.appendChild(pub);
row.appendChild(micEl);
/* stream toggle (when rendered) goes between mic and meter so it
* lands in the `strm` grid column on the top row. If absent the
* column collapses to its 1.4rem track width (small whitespace
* gap, no row-height change). */
if (streamEl) row.appendChild(streamEl);
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', () => { logLine('', 'click: btn-screen-share'); sfuPublishScreen().catch(e => logLine('err','screen share: '+e.message)); });
$('btn-screen-stop').addEventListener('click', () => {
/* click telemetry — the 'screen share stopped' line lower in
* sfuUnpublishScreen only fires AFTER the early-return, so logging
* here lets us see whether the click reached the handler at all. */
logLine('', 'click: btn-screen-stop (sfuScreenPC='+(!!sfuScreenPC)+' sfuScreenStream='+(!!sfuScreenStream)+')');
sfuUnpublishScreen();
});
$('btn-camera-share').addEventListener('click', () => { logLine('', 'click: btn-camera-share'); sfuPublishCamera().catch(e => logLine('err','camera share: '+e.message)); });
$('btn-camera-stop').addEventListener('click', () => {
logLine('', 'click: btn-camera-stop (sfuCameraPC='+(!!sfuCameraPC)+' sfuCameraStream='+(!!sfuCameraStream)+')');
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. Show the FULL uuid (no shortHex) so
* QA can pin which session is which without scrolling logs — fox
* 2026-06-04 "don't truncate uuids in the latency page". When we
* have a handle, prepend it for human readability. */
for (const [uuid, pc] of peers){
const mm = members.get(uuid);
const handle = mm && mm.handle;
const name = handle ? ('peer ' + handle + ' ' + uuid) : ('peer ' + uuid);
rows.push({ name, 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;
/* call.observe drives the dot/buttons/entry-row/status from
* 'blocked' bootedAction — see applyCallStateUI. */
roomMachines.call.send('BOOTED', { by: source, action: 'blocked' });
/* 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');
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-leave').classList.add('hidden');
$('btn-mute').classList.add('hidden');
$('btn-enter').disabled = false;
/* re-expose the entry row so the user can read it / type a new code */
$('row-entry').classList.remove('hidden');
}
$('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).
* ================================================================== */
/* `muted` is now a getter against roomMachines.mute.state — single
* source of truth, no drift possible. Direct writes are no-ops; all
* transitions go through roomMachines.mute.send('TOGGLE' |
* 'FORCE_MUTE' | 'AUTO_MUTE' | 'AUTO_UNMUTE' | 'RESTORE_MUTED' |
* 'RESTORE_UNMUTED' | 'ROLE_PROMOTED'). Side effects (track.enabled,
* btn-mute text/class, member row, peer broadcast) ride the observer
* below. Fox 2026-06-04 directive — every system as an FSM. */
function muted_get(){ return roomMachines.mute.state === 'off'; }
Object.defineProperty(window, 'muted', { get: muted_get, configurable: true });
/* Restore from sessionStorage AFTER the FSM exists (the let / load
* at the top of this file ran before roomMachines was built — that
* preceded the FSM era; now the FSM is canonical and the restore
* fires below at the end of room init). */
try {
const saved = sessionStorage.getItem(MUTE_STATE_KEY);
if (saved === '1') roomMachines.mute.send('RESTORE_MUTED');
else if (saved === '0') roomMachines.mute.send('RESTORE_UNMUTED');
} catch(_){}
function applyMuteState(){
/* read-only against the FSM state — drives the track + DOM + room
* row. Idempotent; safe to call at any time. The mute-state
* observer below also calls applyMuteState() on every FSM
* transition so external dispatches (e.g. self-listener's
* AUTO_MUTE) get the same UI refresh. */
const m = muted_get();
if (micStream){ micStream.getAudioTracks().forEach(t=>t.enabled=!m); }
$('btn-mute').textContent = m ? 'unmute' : 'mute';
$('btn-mute').className = m ? 'invert' : '';
const mm = members.get(myUUID);
if (mm){ mm.muted = m; renderRoom(); }
}
/* Mute-FSM-driven side effects: refresh UI + broadcast mic state +
* persist to sessionStorage on every transition. */
roomMachines.mute.observe(({ state, prev, ctx, ev }) => {
if (prev === null || state === prev) return;
applyMuteState();
try { sendMicState(); } catch(_){}
try { sessionStorage.setItem(MUTE_STATE_KEY, state === 'off' ? '1' : '0'); } catch(_){}
logLine('', 'mute: '+prev+' → '+state+(ctx && ctx.source ? ' (source='+ctx.source+')' : '')+(ev && ev.type ? ' ['+ev.type+']' : ''));
});
$('btn-mute').addEventListener('click', () => {
if (!micStream) return;
const wasMuted = muted_get();
/* MuteFSM observer handles applyMuteState + sendMicState +
* sessionStorage persist. */
roomMachines.mute.send('TOGGLE');
/* Unmuting while self-listener-mode is on means "I want to talk
* again" — tear down the buffered HTTP streams and restore the
* live WebRTC mesh so the user is back in the now of the
* conversation. SelfListenerFSM's UNMUTE edge handles the
* transition (no-op if already off). */
if (wasMuted && !muted_get()) roomMachines.selfListener.send('UNMUTE');
});
$('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value;
/* Also persist the human-readable label so we can re-resolve the
* same device across Chrome's per-session deviceId rotation. */
micDeviceLabel = (e.target.selectedOptions[0] && e.target.selectedOptions[0].textContent || '').replace(/^input\s+/, '');
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
try { localStorage.setItem(MIC_LABEL_KEY, micDeviceLabel); } catch(_){}
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','input switch failed: '+err.message); await refreshMicList(); } }
});
if ($('speaker-select')){
$('speaker-select').addEventListener('change', async (e) => {
speakerDeviceId = e.target.value;
speakerDeviceLabel = (e.target.selectedOptions[0] && e.target.selectedOptions[0].textContent || '').replace(/^output\s+/, '');
try { localStorage.setItem(SPK_DEV_KEY, speakerDeviceId); } catch(_){}
try { localStorage.setItem(SPK_LABEL_KEY, speakerDeviceLabel); } catch(_){}
await applySinkToAll();
logLine('', 'output: '+(e.target.selectedOptions[0]?.textContent || 'default'));
});
}
$('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();
refreshSpeakerList();
/* 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();
refreshSpeakerList();
/* tab-close strong-leave: when the page is about to unload FOR REAL,
* send 'bye' BEFORE the WS gets torn down by the browser. Without
* this, a closed tab dies silently → hiccup grace → 8s of trailing
* audio. Fox 2026-06-03.
*
* Critical guard: pagehide ALSO fires when the page goes into the
* back-forward cache (mobile app-switch / lock screen / minimise),
* with event.persisted=true. We must NOT send bye in that case —
* the page is still alive, just paused; on pageshow it resumes with
* the same WS / SFU / mesh state. Sending bye here would force the
* server to evict the SFU PCs, and when the phone comes back the
* resumed audio path stays muted (Fox 2026-06-03: "now the phone
* leaving and coming back cannot hear the music").
*
* The JS heartbeat handles the bfcache case independently — while
* the page is bfcached, setInterval is paused, so the server's
* aliveTTL fires after 45s if the user doesn't come back. */
function sendByeIfRealClose(ev){
if (ev && ev.persisted) return; // bfcache — page is napping, not dying
try {
if (ws && ws.readyState === WebSocket.OPEN){
ws.send(JSON.stringify({ type: 'bye' }));
}
} catch(_){}
}
window.addEventListener('pagehide', sendByeIfRealClose);
window.addEventListener('beforeunload', sendByeIfRealClose);
/* 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
* ================================================================== */
/* Audio activation: mobile Firefox / Safari require a user gesture to
* allow <audio> playback. The auto-enrolment into DJ-stream happens
* many async hops after the entry click, well past the gesture
* window — so audio.play() rejects with autoplay-block.
*
* Fix: during the entry click handler, play a 1-frame silent buffer
* through a hidden <audio>. That counts as gesture-driven audio
* playback and on most mobile browsers also resumes any suspended
* AudioContext, granting the page audio permission for the session.
* Subsequent dynamic <audio> elements (the actual DJ streams) can
* then play() without further user interaction.
*
* Tiny WAV header + 1 sample of silence — the smallest valid PCM
* audio resource a browser will accept. Base64 ~80 bytes. */
const SILENCE_WAV = 'data:audio/wav;base64,UklGRiYAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQIAAAAAAA==';
/* audioPrimed gate REMOVED — every entry click should re-prime even
* after a successful first prime. Mobile browsers can suspend the
* audio session on tab background / leave; the next entry click needs
* a fresh play() to re-bless audio for the new session. */
/* Pool of audio elements pre-blessed for autoplay by the user gesture
* (entry button click). Firefox Android grants media autoplay
* permission PER ELEMENT, not per document — so attachSfuTrack creating
* a fresh <audio> later (after the gesture activation has aged out)
* gets its play() rejected with NotAllowedError. We pre-create a pool
* inside the click handler, call play() on each (succeeds inside the
* gesture), and attachSfuTrack consumes from the pool when a real
* stream arrives. The element has retained 'engagement' so setting
* srcObject and play() works without a fresh gesture. */
const AUDIO_POOL_SIZE = 16;
const audioPool = [];
function primeAudioOnGesture(){
/* legacy silent primer kept for AudioContext + chime engagement */
const a = document.createElement('audio');
a.src = SILENCE_WAV; a.muted = false; a.volume = 0;
a.style.display = 'none';
document.body.appendChild(a);
const p = a.play();
if (p && p.then) p.then(() => {
setTimeout(() => { try { a.remove(); } catch(_){} }, 500);
}).catch(()=>{ try { a.remove(); } catch(_){} });
/* Create audioCtx + actually start the render thread inside the
* gesture. Just calling resume() isn't enough on Firefox Android —
* telemetry fox 2026-06-04 caught attachListenerStreamViaAudioContext
* logging ctxState=suspended even though resume() was called at
* entry-click time. resume() outside a subsequent gesture silently
* fails on Android.
*
* Play a brief silent oscillator through audioCtx.destination
* inside this gesture — that forces the audio rendering thread to
* ACTUALLY START rather than queueing-pending. After this the
* context stays running for the session and every
* MediaStreamAudioSource attached to destination plays through. */
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume().catch(()=>{});
const osc = audioCtx.createOscillator();
const g = audioCtx.createGain();
g.gain.value = 0; /* silent — only here to wake the render thread */
osc.connect(g);
g.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.05);
osc.onended = () => { try { osc.disconnect(); g.disconnect(); } catch(_){} };
logLine('', 'audioCtx primed state='+audioCtx.state);
} catch(e){ logLine('err', 'audioCtx prime: '+e.message); }
/* pre-bless a pool of audio elements for unmuted autoplay. Each one
* gets play() called inside this click handler — the gesture grants
* each element individual engagement that survives the click's
* activation window. attachSfuTrack later assigns srcObject onto a
* pool element instead of creating a fresh one. */
for (let i = 0; i < AUDIO_POOL_SIZE; i++){
const el = document.createElement('audio');
el.autoplay = true; el.playsInline = true;
el.style.display = 'none';
document.body.appendChild(el);
/* assign a 1-frame silent source so play() has something to chew
* on. The element retains engagement after this play(); subsequent
* srcObject= assignments inherit it. */
el.src = SILENCE_WAV;
try {
const pp = el.play();
if (pp && pp.catch) pp.catch(()=>{});
} catch(_){}
audioPool.push(el);
}
logLine('', 'audio pool primed: '+audioPool.length+' elements');
}
function leaseAudioElement(){
/* hand out a pre-blessed element from the pool; if the pool is dry,
* fall back to creating a fresh one (will probably fail autoplay
* on Firefox Android listeners but at least logs the rejection). */
if (audioPool.length > 0){
const el = audioPool.shift();
try { el.removeAttribute('src'); el.load(); } catch(_){}
return el;
}
logLine('err', 'audio pool exhausted — autoplay may fail');
const fresh = document.createElement('audio');
fresh.autoplay = true; fresh.playsInline = true;
fresh.style.display = 'none';
document.body.appendChild(fresh);
return fresh;
}
$('btn-enter').addEventListener('click', () => { primeAudioOnGesture(); joinSpace(); });
$('rdv-code').addEventListener('keydown', e=>{
if(e.key==='Enter'){ e.preventDefault(); primeAudioOnGesture(); 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;
/* Auto-rejoin without a user gesture works on desktop (browsers
* grant audio engagement after first interaction with the origin)
* but breaks mobile listener audio: Firefox Android requires the
* silent-oscillator wake in primeAudioOnGesture to actually start
* the AudioContext render thread, and primeAudioOnGesture only
* runs inside the btn-enter click handler. Auto-rejoin that
* bypasses the click → audioCtx stays suspended → MediaStream-
* Source attaches silently → no audio.
*
* UA-gated: desktop preserves the zero-click rejoin convenience;
* mobile pre-fills the code + asks for one tap on enter so the
* gesture window opens and audio actually plays. Fox 2026-06-04
* chose this trade after the AudioContext-suspended telemetry. */
const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
if (isMobile){
logLine('', 'last space: '+saved+' — click enter to resume (mobile needs gesture for audio)');
setStatus('click enter to resume — '+saved, 'warn');
return;
}
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 () => {
logLine('', 'leave-button: click fired');
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; }
/* tear down ALL DJ-mode HTTP streams + their <audio> elements +
* clear streamMode so a fresh entry doesn't get blocked by
* `streamMode.has(pubHex)` from the old session. */
for (const a of [...streamAudio.values()]){
try { a.pause(); a.removeAttribute('src'); a.load(); a.remove(); } catch(_){}
}
streamAudio.clear();
streamMode.clear();
roomMachines.selfListener.send('CLEAR');
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-* visibility for invite/listener-actions/share/screen-share/
* spotlight isn't yet under the call FSM observer (room-internal
* UI, not the top-line entry/connected/booted chrome). Keep these
* imperative for now; the next FSM migration can hoist them. */
$('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;
/* call FSM: LEAVE → leaving → DONE → idle. applyCallStateUI sets
* dot warn, hides leave/mute buttons, shows entry-row, sets status
* 'left', re-enables btn-enter. */
roomMachines.call.send('LEAVE');
roomMachines.call.send('DONE');
hideNotice();
/* reset mute button label even though the FSM observer hides it —
* next join restores visibility and the residue 'unmute'/'invert'
* styling would otherwise leak. */
$('btn-mute').textContent = 'mute'; $('btn-mute').className = 'hidden';
/* Preserve the user's last mute choice across leave+rejoin —
* fox: 'when I leave and rejoin the mic state is unmuted'. Don't
* touch MUTE_STATE_KEY here; the next promotion / bless-reclaim
* reads it back via the MuteFSM RESTORE_* event. The local mute
* state still resets to whatever sessionStorage said so the
* in-page UI is clean while disconnected. */
try {
const saved = sessionStorage.getItem(MUTE_STATE_KEY);
roomMachines.mute.send(saved === '1' ? 'RESTORE_MUTED' : 'RESTORE_UNMUTED');
} 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-04</span><br>
md5 <span class="stamp-md5">ffdaafffe887e5213d2152178708bb3b</span><br>
sha256 <span class="stamp-sha">e1f9d34469407f49bc8129eef43cf990c611bd77cd6f8725bd2be8416e5eb644</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>