Fox 2026-06-04: "delay does seem to be about 4 secs but still very chappy." Buffer IS holding 4s and emitting — that part works — but every transient empty-queue tick (a single 2.67 ms drain) was setting started=false, which forced a full 4-second re-fill before emit resumed. So a 50 ms network jitter on the upstream caused a 4 s silence on the listener. That's the chop. Fix: track consecutive empty-queue blocks. Only re-arm (started=false) after rearmThresholdBlocks (100 = ~267 ms) of sustained silence. Brief drains emit silence-fill but keep started=true so playback resumes the instant new samples arrive. Listener hears at most ~267 ms of dead air on each drain — almost certainly Opus PLC will mask far shorter ones. Long outages (>267 ms with no samples) still re-buffer to 4 s — that case isn't this bug, it's a real upstream death where a fresh cushion is correct.
6120 lines
289 KiB
HTML
6120 lines
289 KiB
HTML
<!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 · webrtc · rendezvous ·
|
||
<a href="zebra-audio.html">1:1 call</a> ·
|
||
<a href="host-your-own.html">host your own</a> ·
|
||
<a href="/">unturf</a></p>
|
||
|
||
<section id="sec-identity">
|
||
<h2>identity</h2>
|
||
<div class="row">
|
||
<label class="note" for="handle" style="flex:0 0 4rem">handle</label>
|
||
<input type="text" id="handle" placeholder="what others see — under 32 chars" maxlength="32" autocomplete="off">
|
||
</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=>({'&':'&','<':'<','>':'>'}[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.06–0.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;
|
||
this.queue = [];
|
||
this.buffered = 0;
|
||
this.started = false;
|
||
this.emptyStreak = 0;
|
||
this.dropped = 0;
|
||
}
|
||
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;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
try {
|
||
const jbuf = new AudioWorkletNode(audioCtx, 'jitter-buffer', {
|
||
processorOptions: {
|
||
targetSeconds: RECV_PLAYOUT_DELAY_SEC,
|
||
maxSeconds: RECV_PLAYOUT_DELAY_SEC * 1.5,
|
||
},
|
||
outputChannelCount: [2],
|
||
});
|
||
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='+RECV_PLAYOUT_DELAY_SEC+'s');
|
||
} catch (e) {
|
||
logLine('err', 'jitter-buffer install '+uuid.slice(0,4)+': '+e.message);
|
||
}
|
||
}
|
||
|
||
function attachListenerStreamViaAudioContext(uuid, stream){
|
||
if (!audioCtx){
|
||
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
|
||
catch(e){ logLine('err','listener 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','listener 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 };
|
||
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('', 'listener audio via AudioContext '+uuid.slice(0,4)+' ctxState='+audioCtx.state);
|
||
/* 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){
|
||
/* Listener role: route audio through AudioContext (Firefox Android
|
||
* autoplay survives this path; <audio>.play() doesn't). Meter still
|
||
* uses the existing startMeter() path which separately creates its
|
||
* own analyser source from the same stream — that's fine, multiple
|
||
* MediaStreamSource nodes per stream is allowed. */
|
||
if (myRole === 'listener'){
|
||
if (attachListenerStreamViaAudioContext(uuid, stream)){
|
||
stopMeter(uuid); startMeter(uuid, stream);
|
||
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx)');
|
||
return;
|
||
}
|
||
/* AudioContext failed — fall through to <audio> path as last resort */
|
||
logLine('err','listener 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. */
|
||
const RECV_PLAYOUT_DELAY_SEC = 4.0;
|
||
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 = RECV_PLAYOUT_DELAY_SEC; } catch(_){}
|
||
try { if (ev.receiver) ev.receiver.jitterBufferTarget = RECV_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
|
||
}
|
||
/* 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. */
|
||
try { if (ev.receiver) ev.receiver.playoutDelayHint = RECV_PLAYOUT_DELAY_SEC; } catch(_){}
|
||
try { if (ev.receiver) ev.receiver.jitterBufferTarget = RECV_PLAYOUT_DELAY_SEC * 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){
|
||
/* 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
|
||
* 200–300 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) setStatus('connected as '+ctx.role, 'ok');
|
||
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;
|
||
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;
|
||
setStatus('connected as '+myRole, 'ok');
|
||
}
|
||
/* a new member may resolve a queued SFU track (e.g. host's rejoin
|
||
* race where ontrack fired before peer-joined) */
|
||
flushSfuStreams();
|
||
/* establish mesh PC if both us and them are speakers (or mods).
|
||
* present-member-offers: existing speaker offers when a new speaker
|
||
* arrives. deterministic by uuid string compare. */
|
||
if (canSpeak(myRole) && canSpeak(m.role)) connectToPeer(m.uuid, /*weOffer*/ myUUID < m.uuid);
|
||
/* 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):''));
|
||
setStatus('connected as '+myRole, 'ok');
|
||
const byMod = members.get(m.by);
|
||
const byTxt = byMod ? ' by '+byMod.handle : '';
|
||
/* visible self-notification per role transition */
|
||
if (m.role === 'listener' && prev !== 'listener') showNotice('You were moved to listener'+byTxt+'. Your mic is off.', 'warn');
|
||
else if (m.role === 'speaker' && prev === 'listener') showNotice('You are now a speaker.', 'info');
|
||
else if (m.role === 'speaker' && prev === 'cohost') showNotice('You were stepped down to speaker'+byTxt+'.', 'warn');
|
||
else if (m.role === 'cohost') showNotice('You were promoted to co-host'+byTxt+'.', 'info');
|
||
else if (m.role === 'host') showNotice('You are now the host.', 'info');
|
||
onRoleChanged(prev, m.role);
|
||
} else {
|
||
logLine('', idTag(m.uuid, subjPub)+' is now '+m.role+(m.by?' by '+idTag(m.by, byPub):''));
|
||
/* mesh adjustments — fox: 'never drop people out of the mesh
|
||
* automatically'. Promote: connect if we can speak + they
|
||
* can speak + we don't already have them. Demote: do NOT
|
||
* tear existing mesh peer connections; let them ride as a
|
||
* back-channel until one side actually leaves the room. */
|
||
if (canSpeak(myRole) && canSpeak(m.role) && !peers.has(m.uuid)){
|
||
connectToPeer(m.uuid, myUUID < m.uuid);
|
||
}
|
||
/* 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 { setStatus('connected as '+myRole, 'ok'); } /* clears the space-closing warning */
|
||
logLine('', idTag(m.new_host_uuid, pub)+' is now host'); }
|
||
flushSfuStreams();
|
||
renderRoom();
|
||
reorderTiles();
|
||
}
|
||
break;
|
||
case 'space-closing':
|
||
/* host left — 2h deadman is running. Only the host can chime to
|
||
* extend; cohosts can moderate the room during the absence but
|
||
* cannot keep the room alive. Room WILL close at grace expiry
|
||
* unless the host reclaims. */
|
||
logLine('err','space closing in '+m.grace_seconds+'s — '+m.reason);
|
||
setStatus('host left — space closing in '+m.grace_seconds+'s', 'err');
|
||
break;
|
||
case 'error':
|
||
logLine('err','signal: '+m.message);
|
||
setStatus('signal: '+m.message,'err');
|
||
/* terminal-block signal: stop the reconnect loop and tell the user */
|
||
if (/blocked/i.test(m.message)) handleBlocked('signal');
|
||
break;
|
||
}
|
||
}
|
||
|
||
function applyState(state){
|
||
members = new Map();
|
||
hostUUID = state.host_uuid || '';
|
||
for (const m of (state.members||[])) members.set(m.uuid, Object.assign({}, m));
|
||
handraise = new Set(state.handraise_queue||[]);
|
||
}
|
||
|
||
/* called once on welcome (whatever role we entered as) and on every
|
||
* role change. host/cohost/speaker need a mic; listener drops it.
|
||
*
|
||
* SFU bridge: speakers publish to the SFU (so listeners hear them);
|
||
* listeners subscribe to the SFU (so they hear the speakers). Speakers
|
||
* never subscribe — mesh gives them lower-latency audio already. */
|
||
async function onRoleEntered(){
|
||
if (canSpeak(myRole)) await ensureMicAndUI();
|
||
else updateRoleUI();
|
||
/* Everyone subscribes to the SFU. Listeners use it for mic+screen+camera.
|
||
* Speakers use it for screen+camera (and as a backup audio path); the
|
||
* SFU mic track is suppressed by the ontrack handler when we already
|
||
* have a mesh peer for that pubkey, so we don't get double audio. */
|
||
sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message));
|
||
if (canSpeak(myRole)){
|
||
for (const [uuid, mm] of members){
|
||
if (uuid === myUUID) continue;
|
||
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
|
||
}
|
||
sfuPublish().catch(e => logLine('err','sfu publish: '+e.message));
|
||
}
|
||
/* 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;
|
||
async function onRoleChanged(prev, next){
|
||
/* suppress spotlight broadcasts triggered by tile cleanup during the
|
||
* role transition — otherwise removeScreenTile / removeCameraTile
|
||
* fires pickNextSpotlight which broadcasts an empty spotlight key,
|
||
* which every other peer logs as 'X looked away'. */
|
||
inRoleTransition = true;
|
||
try {
|
||
if (!canSpeak(prev) && canSpeak(next)){
|
||
/* 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) => {
|
||
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 matches the SFU path — same RECV_PLAYOUT_DELAY_SEC.
|
||
* jitterBufferTarget enforces (not hints) the buffer depth — see
|
||
* sfu mic-receiver site for rationale. */
|
||
try { ev.receiver.playoutDelayHint = RECV_PLAYOUT_DELAY_SEC; } catch(_){}
|
||
try { ev.receiver.jitterBufferTarget = RECV_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
|
||
stopMeter(uuid); startMeter(uuid, a.srcObject);
|
||
};
|
||
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.5–3s 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> · built <span class="stamp-date">2026-06-04</span><br>
|
||
md5 <span class="stamp-md5">40b0bf67548bfa739a92c09566384731</span><br>
|
||
sha256 <span class="stamp-sha">498399317ffdf93d814ccc98e4e5f413b249e91ccfa15e7d7767a81f34e31eb1</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>
|