Desktop rule .page.controls-collapsed promotes the layout to a 2-column rail (cameras 35% + timeline rest). Inside the mobile breakpoint that same rule was winning by specificity and changing the shares column width when the panel toggled. Override inside the mobile media query so controls-collapsed on a narrow viewport still uses minmax(0, 1fr) — single stack regardless of panel visibility.
3806 lines
176 KiB
HTML
3806 lines
176 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>zebra spaces</title>
|
|
<style>
|
|
@font-face {
|
|
font-family: 'chunkfiveregular';
|
|
src: url('fonts/chunkfive-regular-webfont.woff2') format('woff2'),
|
|
url('fonts/chunkfive-regular-webfont.woff') format('woff');
|
|
font-weight: normal; font-style: normal;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: monospace; background: #fff; color: #000;
|
|
padding: 1rem 1.5rem; max-width: none; margin: 0 auto;
|
|
}
|
|
/* Dark mode = pure black canvas, dim text, low total light emission.
|
|
* Explicit overrides instead of filter:invert so the page looks
|
|
* intentional in both modes rather than mathematically flipped. */
|
|
html.theme-dark { background: #000; }
|
|
html.theme-dark body { background: #000; color: #ccc; }
|
|
html.theme-dark a { color: #9cf; }
|
|
html.theme-dark .sub, html.theme-dark .sub a, html.theme-dark .note,
|
|
html.theme-dark .status-line { color: #888; }
|
|
html.theme-dark .timeline,
|
|
html.theme-dark .cameras-col { border-right-color: #222; }
|
|
html.theme-dark .game-tile-frame { background: #000; border-color: #222; }
|
|
/* buttons + inputs */
|
|
html.theme-dark button,
|
|
html.theme-dark .game-tabs button,
|
|
html.theme-dark .theme-toggle,
|
|
html.theme-dark .controls-toggle {
|
|
background: #000; color: #ccc; border-color: #444;
|
|
}
|
|
html.theme-dark button:hover:not(:disabled),
|
|
html.theme-dark .game-tabs button:hover:not(.active),
|
|
html.theme-dark .theme-toggle:hover,
|
|
html.theme-dark .controls-toggle:hover { background: #1a1a1a; }
|
|
html.theme-dark button.invert { background: #ccc; color: #000; border-color: #ccc; }
|
|
html.theme-dark button.invert:hover:not(:disabled) { background: #999; }
|
|
html.theme-dark .game-tabs button.active { background: #ccc; color: #000; border-color: #ccc; }
|
|
html.theme-dark input[type=text],
|
|
html.theme-dark input[type=password],
|
|
html.theme-dark textarea,
|
|
html.theme-dark select {
|
|
background: #0a0a0a; color: #ccc; border-color: #333;
|
|
}
|
|
html.theme-dark .dot { background: #000; border-color: #444; }
|
|
html.theme-dark .dot.ok { background: #0a0; border-color: #0a0; }
|
|
html.theme-dark .meter { background: #000; border-color: #444; }
|
|
html.theme-dark .meter-fill { background: #ccc; }
|
|
/* dark mode: tile chrome flips back to the night palette */
|
|
html.theme-dark .screen-tile,
|
|
html.theme-dark .camera-tile { background: #000; border-color: #000; }
|
|
/* video stays transparent in both themes — the tile element's bg
|
|
* shows through any letterbox / pre-frame state, so the bars match
|
|
* the surrounding page in either palette */
|
|
html.theme-dark .screen-tile .screen-meta,
|
|
html.theme-dark .camera-tile .screen-meta { background: #111; color: #ddd; }
|
|
/* badges */
|
|
html.theme-dark .badge.host { background: #ccc; color: #000; border-color: #ccc; }
|
|
html.theme-dark .badge.cohost { background: #666; color: #000; border-color: #666; }
|
|
html.theme-dark .badge.speaker { background: #000; color: #ccc; border-color: #ccc; }
|
|
html.theme-dark .badge.listener{ background: #000; color: #888; border-color: #444; }
|
|
html.theme-dark .pub-short { color: #777; }
|
|
html.theme-dark .handle .me { color: #6c6; }
|
|
html.theme-dark .raised { color: #f66; }
|
|
/* log */
|
|
html.theme-dark .log { background: #050505; border-color: #333; }
|
|
html.theme-dark .log-line .ts { color: #666; }
|
|
html.theme-dark .log-line.err { color: #f66; }
|
|
html.theme-dark .status-line.ok { color: #6c6; }
|
|
html.theme-dark .status-line.err { color: #f66; }
|
|
/* notice banners */
|
|
html.theme-dark .notice-banner { background: #1a1a00; border-color: #553; color: #ccc; }
|
|
html.theme-dark .notice-banner.warn { background: #1a0000; border-color: #500; color: #f66; }
|
|
html.theme-dark .notice-banner.info { background: #001a00; border-color: #050; color: #6c6; }
|
|
/* latency rows */
|
|
html.theme-dark .lat-row { border-bottom-color: #1a1a1a; }
|
|
html.theme-dark .lat-row .lat-name { color: #aaa; }
|
|
html.theme-dark .lat-row .lat-loss::before,
|
|
html.theme-dark .lat-row .lat-jit::before { color: #666; }
|
|
html.theme-dark .lat-row .lat-path { color: #666; }
|
|
html.theme-dark .lat-row.lat-good .lat-rtt, html.theme-dark .lat-row.lat-good .lat-loss, html.theme-dark .lat-row.lat-good .lat-jit { color: #6c6; }
|
|
html.theme-dark .lat-row.lat-mid .lat-rtt, html.theme-dark .lat-row.lat-mid .lat-loss, html.theme-dark .lat-row.lat-mid .lat-jit { color: #ec0; }
|
|
html.theme-dark .lat-row.lat-bad .lat-rtt, html.theme-dark .lat-row.lat-bad .lat-loss, html.theme-dark .lat-row.lat-bad .lat-jit { color: #f66; }
|
|
html.theme-dark .lat-row.lat-na .lat-rtt, html.theme-dark .lat-row.lat-na .lat-loss, html.theme-dark .lat-row.lat-na .lat-jit { color: #555; }
|
|
/* vault */
|
|
html.theme-dark .vault-panel { border-color: #444; }
|
|
/* page-integrity footer */
|
|
html.theme-dark footer { color: #555 !important; }
|
|
html.theme-dark footer span { color: #555 !important; }
|
|
html.theme-dark footer a { color: #777 !important; }
|
|
|
|
.theme-toggle, .controls-toggle {
|
|
position: fixed; top: 0.6rem; z-index: 9999;
|
|
background: #fff; color: #000; border: 1px solid #000;
|
|
padding: 0.2rem 0.55rem; font-family: monospace; font-size: 0.7rem;
|
|
cursor: pointer; line-height: 1;
|
|
}
|
|
.theme-toggle { right: 0.8rem; }
|
|
.controls-toggle { right: 4.3rem; }
|
|
.theme-toggle:hover, .controls-toggle:hover { background: #f0f0f0; }
|
|
/* two-column page on desktop: left = timeline (1fr, takes remaining
|
|
* space — hosts screen shares + the running event feed), right =
|
|
* controls (kept narrow so the screen share gets maximum pixels).
|
|
* Both columns get min-width:0 so they shrink without forcing a
|
|
* horizontal scrollbar. */
|
|
/* three columns: cameras (left) | timeline/screen (middle) | controls (right).
|
|
* Each child is pinned to its column with grid-column so any one of
|
|
* them going display:none can't cause the others to slide. Controls
|
|
* column is also clamped to ≤50vw so a very wide window doesn't let
|
|
* the side panel eat the screen-share area. */
|
|
/* Page grid switches its template based on which columns are live.
|
|
* .cameras-col.hidden (no cameras) and .page.controls-collapsed
|
|
* (hide-panel) each remove a track; the timeline reassigns its
|
|
* grid-column for each combination so it always lands in the
|
|
* remaining slot. Four states:
|
|
* cams + ctrls : 220 1fr min(360,50vw) timeline col 2
|
|
* cams : 220 1fr timeline col 2
|
|
* ctrls only : 1fr min(360,50vw) timeline col 1
|
|
* alone : 1fr timeline col 1
|
|
*/
|
|
.page {
|
|
display: grid;
|
|
grid-template-columns: 220px minmax(0, 1fr) minmax(0, min(360px, 50vw));
|
|
gap: 1.25rem;
|
|
align-items: start;
|
|
}
|
|
.cameras-col { grid-column: 1; }
|
|
.timeline { grid-column: 2; }
|
|
.controls { grid-column: 3; }
|
|
.cameras-col {
|
|
min-width: 0;
|
|
border-right: 1px solid #ddd; padding-right: 1.25rem;
|
|
display: grid;
|
|
grid-auto-rows: max-content;
|
|
gap: 0.5rem;
|
|
}
|
|
/* state: no cameras (column track gone, timeline + controls take 2-col) */
|
|
.page:has(> .cameras-col.hidden) {
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, min(360px, 50vw));
|
|
}
|
|
.page:has(> .cameras-col.hidden) > .timeline { grid-column: 1; }
|
|
.page:has(> .cameras-col.hidden) > .controls { grid-column: 2; }
|
|
|
|
/* state: controls collapsed (right column gone). The cameras column
|
|
* grows from its 220px default up to ~35% of the viewport so the
|
|
* thumbnails actually use the freed horizontal space — combined with
|
|
* the auto-fill #tiles-thumbs grid below, wide columns flow into
|
|
* multiple thumb columns instead of leaving the row half-empty. */
|
|
.page.controls-collapsed > .controls { display: none; }
|
|
.page.controls-collapsed:not(:has(> .cameras-col.hidden)) {
|
|
grid-template-columns: minmax(220px, 35%) minmax(0, 1fr);
|
|
}
|
|
/* state: controls collapsed AND no cameras → timeline owns the viewport */
|
|
.page.controls-collapsed:has(> .cameras-col.hidden) {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: 0;
|
|
}
|
|
.page.controls-collapsed:has(> .cameras-col.hidden) > .timeline {
|
|
grid-column: 1; border-right: none; padding-right: 0;
|
|
}
|
|
/* pre-paint pref so a 'hidden' reload doesn't flash the panel */
|
|
html.controls-collapsed-pref .page > .controls { display: none; }
|
|
html.controls-collapsed-pref .page:not(:has(> .cameras-col.hidden)) {
|
|
grid-template-columns: minmax(220px, 35%) minmax(0, 1fr);
|
|
}
|
|
html.controls-collapsed-pref .page:has(> .cameras-col.hidden) {
|
|
grid-template-columns: minmax(0, 1fr); gap: 0;
|
|
}
|
|
html.controls-collapsed-pref .page:has(> .cameras-col.hidden) > .timeline {
|
|
grid-column: 1; border-right: none; padding-right: 0;
|
|
}
|
|
.timeline {
|
|
border-right: 1px solid #ddd; padding-right: 1.25rem; min-height: 60vh;
|
|
min-width: 0; display: grid; grid-auto-rows: max-content;
|
|
position: relative;
|
|
}
|
|
.controls { min-width: 0; }
|
|
/* games live in the LEFT column under cameras + screens-thumbs as
|
|
* proper tile-thumbs. Click one to spotlight it in the middle (an
|
|
* iframe loads at full size); the click also broadcasts the spotlight
|
|
* choice through the same channel cameras/screens use, so the log
|
|
* shows 'alice now viewing unmario' and the thumb-order popularity
|
|
* sort knows people are watching. */
|
|
.games-h2 { margin-top: 0.5rem; }
|
|
#games-thumbs { display: grid; grid-template-columns: 1fr; gap: 0.45rem; }
|
|
/* game thumb is a STATIC tile — never loads the iframe; it's just a
|
|
* card with title + 'play' cue. The iframe only mounts when the tile
|
|
* is clicked into the spotlight slot. Click → spotlight + broadcast. */
|
|
.game-thumb {
|
|
border: 1px solid #ddd; background: #fff; padding: 0;
|
|
display: grid; grid-template-rows: 1fr auto; gap: 0;
|
|
cursor: pointer; position: relative;
|
|
aspect-ratio: 16 / 9;
|
|
}
|
|
.game-thumb:hover { outline: 1px solid #888; }
|
|
.game-thumb .game-poster {
|
|
display: grid; place-items: center; text-align: center;
|
|
font-family: 'chunkfiveregular', serif; font-size: 1.05rem;
|
|
padding: 0.5rem; background: #f0f0f0; color: #333;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.game-thumb .game-meta {
|
|
background: #111; color: #ddd; font-size: 0.65rem; padding: 0.2rem 0.4rem;
|
|
display: grid; grid-template-columns: 1fr auto; gap: 0.4rem; align-items: center;
|
|
}
|
|
.game-thumb .game-meta .play-cue { color: #6c6; letter-spacing: 0.04em; }
|
|
/* spotlit game shows the iframe at full size in the middle column */
|
|
#spotlight > .game-tile {
|
|
border: 1px solid #ddd; background: #fff;
|
|
display: grid; grid-template-rows: 1fr auto;
|
|
}
|
|
#spotlight > .game-tile iframe {
|
|
width: 100%; height: 80vh;
|
|
border: none; background: #fff; display: block;
|
|
}
|
|
#spotlight > .game-tile .screen-meta {
|
|
background: #f0f0f0; color: #333; font-size: 0.7rem; padding: 0.3rem 0.5rem;
|
|
display: grid; grid-template-columns: 1fr auto; align-items: center;
|
|
}
|
|
html.theme-dark .game-thumb { background: #000; border-color: #000; }
|
|
html.theme-dark .game-thumb .game-poster { background: #111; color: #ddd; }
|
|
html.theme-dark #spotlight > .game-tile { background: #000; border-color: #000; }
|
|
html.theme-dark #spotlight > .game-tile iframe { background: #000; }
|
|
html.theme-dark #spotlight > .game-tile .screen-meta { background: #111; color: #ddd; }
|
|
@media (max-width: 800px) {
|
|
body { padding: 1.25rem; }
|
|
/* mobile: single column, all three sections stack to grid-column:1 */
|
|
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
|
|
/* hide-panel on mobile must NOT swap the layout into the wide-
|
|
* viewport two-column rail (cameras-col + timeline). Keep the
|
|
* single-column stack regardless of the controls-collapsed state.
|
|
* Wins over the desktop .controls-collapsed selector by being
|
|
* inside this media query (later in cascade, equal specificity
|
|
* after the matching selector) — using the same class so the
|
|
* specificity matches and the order decides. */
|
|
.page.controls-collapsed,
|
|
html.controls-collapsed-pref .page { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
|
|
.controls, .timeline, .cameras-col { grid-column: 1; }
|
|
.controls { grid-row: 1; }
|
|
.timeline { grid-row: 2;
|
|
border-right: none; border-top: 1px solid #ddd;
|
|
padding-right: 0; padding-top: 1rem; min-height: 0; }
|
|
.cameras-col { grid-row: 3;
|
|
border-right: none; border-top: 1px solid #ddd;
|
|
padding-right: 0; padding-top: 1rem; }
|
|
/* on mobile each thumb takes a full row — a phone viewport is
|
|
* narrow enough that splitting into 2 columns of ~200px ends up
|
|
* smaller than just stacking. */
|
|
#tiles-thumbs { grid-template-columns: minmax(0, 1fr); }
|
|
}
|
|
/* never let dynamically-appended <audio> sinks (one per remote
|
|
* speaker) render their default ~300px control strip — they're just
|
|
* sinks for the WebRTC track, no UI required */
|
|
audio { display: none; }
|
|
|
|
/* spotlight: the active tile (camera OR screen) renders full-width
|
|
* in the middle column. All other tiles render as thumbnails inside
|
|
* the cameras-col. Clicking a thumbnail promotes it to the spotlight
|
|
* and demotes the current one back to the column. */
|
|
#spotlight { display: grid; grid-template-columns: 1fr; gap: 0; }
|
|
/* unified popularity-sorted thumb list — screens + cameras + game-shares
|
|
* all flow through here, ordered by tileScore(). auto-fill with a
|
|
* 180px floor means: narrow columns get 1 thumb per row, wide
|
|
* columns (e.g. panel hidden) flow into 2 / 3 / N columns naturally. */
|
|
#tiles-thumbs {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
gap: 0.45rem;
|
|
}
|
|
#tiles-h2 { margin-top: 0.5rem; }
|
|
#tiles-h2.hidden { display: none; }
|
|
/* a thumbnail tile is the same DOM as a spotlight tile but with a
|
|
* thumb modifier class; clicking it triggers a spotlight swap */
|
|
.tile-thumb { cursor: pointer; position: relative; }
|
|
.tile-thumb:hover { outline: 1px solid #888; }
|
|
/* viewing state: the same tile is currently in the spotlight slot.
|
|
* Don't hide the thumb — gray it out and overlay 'viewing' so the
|
|
* user can see which thumb maps to the spotlight tile. */
|
|
.tile-thumb .viewing-badge {
|
|
position: absolute; inset: 0;
|
|
display: grid; place-items: center;
|
|
background: rgba(0,0,0,0.22); color: #fff;
|
|
font-family: monospace; font-size: 0.75rem; letter-spacing: 0.05em;
|
|
text-transform: uppercase; pointer-events: none;
|
|
text-shadow: 0 1px 2px rgba(0,0,0,0.75);
|
|
opacity: 0; transition: opacity 0.12s;
|
|
z-index: 5;
|
|
}
|
|
.tile-thumb.viewing { cursor: default; }
|
|
.tile-thumb.viewing:hover { outline: none; }
|
|
/* keep the underlying stream legible — a soft dim is enough to
|
|
* read as 'this is what's in the spotlight slot', no need to
|
|
* blacken it. */
|
|
.tile-thumb.viewing > video { opacity: 0.75; }
|
|
.tile-thumb.viewing .viewing-badge { opacity: 1; }
|
|
/* spotlight sizing — a camera in the spotlight uses contain so the
|
|
* whole frame is visible (not cropped like the thumb cover) */
|
|
#spotlight > .camera-tile video {
|
|
aspect-ratio: 16 / 9;
|
|
object-fit: contain;
|
|
max-height: 92vh;
|
|
}
|
|
/* thumb sizing for either kind: small, cropped 16:9 */
|
|
.tile-thumb > video {
|
|
width: 100%; height: auto;
|
|
aspect-ratio: 16 / 9; object-fit: cover;
|
|
max-height: 22vh;
|
|
}
|
|
.tile-thumb > .screen-meta {
|
|
font-size: 0.6rem; padding: 0.15rem 0.3rem;
|
|
}
|
|
/* tile chrome (border + bg + meta) defaults to light-theme palette so
|
|
* the screen-share blends with a white page. Dark-theme overrides
|
|
* further down restore the black tile chrome for the night palette. */
|
|
.screen-tile {
|
|
border: 1px solid #ddd; background: #fff; padding: 0;
|
|
display: grid; grid-auto-rows: max-content; position: relative;
|
|
}
|
|
.screen-tile video {
|
|
width: 100%; height: auto; max-height: 92vh; display: block;
|
|
background: transparent; /* let the tile bg show through letterbox */
|
|
}
|
|
.screen-tile .screen-meta {
|
|
background: #f0f0f0; color: #333; font-size: 0.7rem; padding: 0.3rem 0.5rem;
|
|
display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center;
|
|
}
|
|
/* Firefox Android rejects MediaStream <video> autoplay even when muted.
|
|
* When play() rejects we surface a tap-to-play overlay over the video
|
|
* area only (the meta bar stays clickable); the click counts as the
|
|
* gesture so the retry play() succeeds. */
|
|
.screen-tile .tap-play {
|
|
position: absolute; left: 0; right: 0; top: 0; bottom: 1.6rem;
|
|
display: none; place-items: center;
|
|
background: rgba(0,0,0,0.65); color: #fff; cursor: pointer;
|
|
font-family: monospace; font-size: 1rem; user-select: none;
|
|
}
|
|
.screen-tile.needs-tap .tap-play { display: grid; }
|
|
/* spotlight active → hide the game iframe so the spotlight tile owns the column */
|
|
/* (games live in sec-cameras now — no timeline-side hide rule needed) */
|
|
|
|
/* camera tiles share the unified #tiles-thumbs container with screens
|
|
* and game-shares (popularity-sorted). 16:9 letterbox keeps faces
|
|
* proportional regardless of incoming stream. */
|
|
.camera-tile {
|
|
border: 1px solid #ddd; background: #fff; padding: 0;
|
|
display: grid; grid-auto-rows: max-content; position: relative;
|
|
}
|
|
.camera-tile video {
|
|
width: 100%; height: auto; display: block;
|
|
background: #000;
|
|
/* contain (not cover) so a portrait stream from a mobile camera
|
|
* letterboxes inside the 16:9 box instead of being middle-cropped
|
|
* into a square. We still reserve a 16:9 slot for layout stability
|
|
* across mixed stream orientations. */
|
|
aspect-ratio: 16 / 9; object-fit: contain;
|
|
}
|
|
.camera-tile .screen-meta {
|
|
background: #f0f0f0; color: #333; font-size: 0.7rem; padding: 0.2rem 0.4rem;
|
|
display: grid; grid-template-columns: 1fr auto; gap: 0.4rem; align-items: center;
|
|
}
|
|
/* tap-to-play overlay applies to camera tiles too */
|
|
.camera-tile .tap-play {
|
|
position: absolute; left: 0; right: 0; top: 0; bottom: 1.2rem;
|
|
display: none; place-items: center;
|
|
background: rgba(0,0,0,0.65); color: #fff; cursor: pointer;
|
|
font-family: monospace; font-size: 0.9rem; user-select: none;
|
|
}
|
|
.camera-tile.needs-tap .tap-play { display: grid; }
|
|
|
|
/* latency panel: one row per live PC, fixed-width columns so the numbers
|
|
* line up vertically as values bounce. lat-good < 50ms, mid < 150ms,
|
|
* bad ≥ 150ms — colour-tagged so the user can scan at a glance. */
|
|
.latency-rows { display: grid; gap: 0.18rem; font-size: 0.78rem; margin-bottom: 0.4rem; }
|
|
.lat-row {
|
|
display: grid; grid-template-columns: 1fr 3.6rem 2.6rem 2.6rem 2.6rem; gap: 0.35rem;
|
|
align-items: baseline; padding: 0.12rem 0;
|
|
border-bottom: 1px dotted #eee; font-family: monospace;
|
|
}
|
|
.lat-row .lat-name { color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.lat-row .lat-rtt, .lat-row .lat-loss, .lat-row .lat-jit { text-align: right; font-weight: bold; }
|
|
.lat-row .lat-loss::before { content: 'loss '; color: #888; font-weight: normal; font-size: 0.65rem; }
|
|
.lat-row .lat-jit::before { content: 'jit '; color: #888; font-weight: normal; font-size: 0.65rem; }
|
|
.lat-row .lat-path { text-align: right; color: #888; font-size: 0.7rem; text-transform: uppercase; }
|
|
.lat-row.lat-good .lat-rtt, .lat-row.lat-good .lat-loss, .lat-row.lat-good .lat-jit { color: #060; }
|
|
.lat-row.lat-mid .lat-rtt, .lat-row.lat-mid .lat-loss, .lat-row.lat-mid .lat-jit { color: #b80; }
|
|
.lat-row.lat-bad .lat-rtt, .lat-row.lat-bad .lat-loss, .lat-row.lat-bad .lat-jit { color: #b00; }
|
|
.lat-row.lat-na .lat-rtt, .lat-row.lat-na .lat-loss, .lat-row.lat-na .lat-jit { color: #999; }
|
|
|
|
h1 {
|
|
font-family: 'chunkfiveregular', serif;
|
|
font-size: 3rem; font-weight: normal; letter-spacing: 0.02em;
|
|
line-height: 1; margin-bottom: 0.2rem;
|
|
}
|
|
.sub {
|
|
font-size: 0.75rem; color: #555; margin-bottom: 2rem;
|
|
letter-spacing: 0.05em; text-transform: uppercase;
|
|
}
|
|
.sub a { color: #555; }
|
|
h2 {
|
|
font-family: 'chunkfiveregular', serif; font-size: 1.1rem; font-weight: normal;
|
|
border-bottom: 1px solid #000; padding-bottom: 0.25rem; margin-bottom: 0.8rem;
|
|
}
|
|
section { margin-bottom: 1.6rem; }
|
|
button {
|
|
background: #fff; color: #000; border: 1px solid #000;
|
|
padding: 0.4rem 0.9rem; font-family: monospace; font-size: 0.85rem; cursor: pointer;
|
|
}
|
|
button:hover:not(:disabled) { background: #f0f0f0; }
|
|
button:disabled { opacity: 0.3; cursor: default; }
|
|
button.invert { background: #000; color: #fff; }
|
|
button.invert:hover:not(:disabled) { background: #333; }
|
|
button.small { padding: 0.2rem 0.55rem; font-size: 0.75rem; }
|
|
/* form-row style guide — every form row is a grid.
|
|
* default children pack at max-content width, no stretch
|
|
* <something> <input/select> ... → template "auto 1fr ..." so the
|
|
* input cell grows
|
|
* <input/select> ... → template "1fr ..." so the field fills,
|
|
* packed siblings follow
|
|
* any <span.note> child → moved to its own row inside the grid so
|
|
* long text never competes with the
|
|
* buttons it explains
|
|
* Items beyond the templated cells flow via grid-auto-columns:max-content. */
|
|
.row {
|
|
display: grid;
|
|
grid-auto-flow: column;
|
|
/* minmax(0,max-content) so a long single-child label (e.g. the
|
|
* 'music mode — …' description) can shrink and wrap inside the
|
|
* controls column instead of overflowing horizontally */
|
|
grid-auto-columns: minmax(0, max-content);
|
|
align-items: center; gap: 0.6rem; margin-bottom: 0.6rem;
|
|
min-width: 0;
|
|
}
|
|
.row:has(> :first-child + input[type=text]),
|
|
.row:has(> :first-child + input[type=password]),
|
|
.row:has(> :first-child + select) {
|
|
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
|
}
|
|
.row:has(> input[type=text]:first-child),
|
|
.row:has(> input[type=password]:first-child),
|
|
.row:has(> select:first-child) {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
/* notes inside a row drop to their own line under the buttons/inputs
|
|
* instead of fighting them for horizontal space */
|
|
.row > .note {
|
|
grid-column: 1 / -1;
|
|
margin-top: 0.05rem; line-height: 1.4;
|
|
}
|
|
/* labels wrapping a checkbox + long text break naturally */
|
|
.row > label { white-space: normal; }
|
|
input[type=text], input[type=password] {
|
|
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
|
|
padding: 0.4rem; background: #fff; color: #000; min-width: 0; width: 100%;
|
|
}
|
|
textarea {
|
|
font-family: monospace; font-size: 0.75rem; border: 1px solid #000;
|
|
padding: 0.4rem; background: #fff; color: #000; width: 100%; min-height: 4rem;
|
|
word-break: break-all;
|
|
}
|
|
select {
|
|
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
|
|
padding: 0.4rem; background: #fff; color: #000; min-width: 0; width: 100%; cursor: pointer;
|
|
}
|
|
.dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #000; background: #fff; }
|
|
.dot.ok { background: #060; border-color: #060; }
|
|
.dot.warn { background: #888; }
|
|
.status-line { font-size: 0.8rem; color: #555; }
|
|
.status-line.ok { color: #060; }
|
|
.status-line.err { color: #b00; }
|
|
.note { font-size: 0.75rem; color: #555; line-height: 1.5; }
|
|
.meter { height: 8px; border: 1px solid #000; background: #fff; position: relative; overflow: hidden; }
|
|
.meter-fill { height: 100%; background: #000; width: 0%; transition: width 0.06s linear; }
|
|
/* member rows: two-row grid per member — handles stay on one line,
|
|
* full pubkey gets its own row (wraps across as many lines as it
|
|
* needs). Mod buttons take a third row when present. Never truncate
|
|
* the pubkey, never wrap a handle mid-word. */
|
|
.member {
|
|
display: grid;
|
|
grid-template-columns: 5.5rem minmax(0, 1fr) 1.4rem 5rem;
|
|
grid-template-areas:
|
|
"badge handle mic meter"
|
|
"badge pub pub pub"
|
|
"badge acts acts acts";
|
|
align-items: center; gap: 0.35rem 0.55rem; padding: 0.35rem 0;
|
|
border-bottom: 1px dotted #ccc; font-size: 0.85rem;
|
|
}
|
|
.member > .badge { grid-area: badge; align-self: start; }
|
|
.member > .handle { grid-area: handle; }
|
|
.member > .pub-short { grid-area: pub; }
|
|
.member > .mic { grid-area: mic; }
|
|
.member > .meter { grid-area: meter; }
|
|
.member > .mod-actions { grid-area: acts; }
|
|
.member:last-child { border-bottom: none; }
|
|
.badge {
|
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|
padding: 0.1rem 0.4rem; border: 1px solid #000; text-align: center;
|
|
}
|
|
.badge.host { background: #000; color: #fff; }
|
|
.badge.cohost { background: #444; color: #fff; }
|
|
.badge.speaker { background: #fff; color: #000; }
|
|
.badge.listener{ background: #fff; color: #888; border-color: #888; }
|
|
/* handle is never truncated. Long handles wrap on word boundaries
|
|
* by default; overflow-wrap:break-word allows a single absurdly long
|
|
* word to wrap rather than push the column wider. */
|
|
.handle { font-weight: bold; overflow-wrap: break-word; min-width: 0; }
|
|
.handle .me { color: #060; font-weight: normal; font-size: 0.7rem; margin-left: 0.3rem; }
|
|
/* full pubkey, monospace, wraps onto as many lines as it needs */
|
|
.pub-short {
|
|
font-size: 0.7rem; color: #888;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
word-break: break-all; line-height: 1.3;
|
|
}
|
|
.raised { color: #b00; font-weight: bold; }
|
|
.mic { width: 18px; height: 18px; }
|
|
.mic svg { width: 18px; height: 18px; display: block; }
|
|
.mod-actions {
|
|
display: grid; grid-auto-flow: column; grid-auto-columns: max-content;
|
|
gap: 0.3rem;
|
|
justify-content: start;
|
|
margin-top: 0.2rem;
|
|
}
|
|
.mod-actions:empty { display: none; } /* no row gap when nothing to do */
|
|
/* log lives in its own full-width section beneath the .page grid.
|
|
* 240px default, vertical resize handle so the user can drag it
|
|
* taller when the chatter outpaces the viewport. */
|
|
#sec-log { margin-top: 1.25rem; }
|
|
.log {
|
|
border: 1px solid #000;
|
|
height: 240px;
|
|
min-height: 120px;
|
|
resize: vertical;
|
|
overflow-y: auto;
|
|
padding: 0.5rem;
|
|
font-size: 0.75rem; line-height: 1.5;
|
|
background: #fafafa;
|
|
width: 100%;
|
|
}
|
|
.log-line { margin-bottom: 0.2rem; word-wrap: break-word; }
|
|
.log-line .ts { color: #888; }
|
|
.log-line.err { color: #b00; }
|
|
.invite-banner {
|
|
border: 2px solid #000; padding: 0.8rem; margin-bottom: 1rem; background: #ffd;
|
|
display: grid; grid-auto-rows: max-content; gap: 0.6rem; justify-items: start;
|
|
}
|
|
.invite-banner .invite-actions {
|
|
display: grid; grid-auto-flow: column; grid-auto-columns: max-content; gap: 0.5rem;
|
|
}
|
|
.notice-banner {
|
|
padding: 0.7rem 0.8rem; margin-bottom: 1rem;
|
|
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
|
|
gap: 0.6rem; font-size: 0.85rem;
|
|
}
|
|
.notice-banner.warn { border: 2px solid #b00; background: #fee; color: #b00; }
|
|
.notice-banner.info { border: 2px solid #060; background: #efe; color: #060; }
|
|
.notice-banner button { margin-left: auto; }
|
|
.vault-panel { border: 1px dashed #000; padding: 0.7rem; margin-top: 0.5rem; }
|
|
.hidden { display: none !important; }
|
|
@media (max-width: 500px) {
|
|
/* same template-areas layout, tighter columns. pubkey still
|
|
* present on its own row (fox: don't truncate keys). meter row
|
|
* trims to the right edge of the visible width. */
|
|
.member {
|
|
grid-template-columns: 4.5rem minmax(0, 1fr) 1.4rem 3rem;
|
|
}
|
|
}
|
|
</style>
|
|
<script>
|
|
/* apply theme before first paint to avoid a flash of the wrong theme.
|
|
* Default IS dark — only the explicit 'light' choice opts out, so a
|
|
* first-time visitor lands in dark without a flash. One unified key
|
|
* across every zebra page so the preference follows the user. */
|
|
try {
|
|
var pref = localStorage.getItem('zebra-theme-v1');
|
|
if (!pref) pref = localStorage.getItem('zebra-spaces-theme-v1'); // legacy migration
|
|
if (pref !== 'light'){
|
|
document.documentElement.classList.add('theme-dark');
|
|
}
|
|
} catch(_){ document.documentElement.classList.add('theme-dark'); }
|
|
/* sidebar-collapsed pre-paint: apply to <html> so the .page child can
|
|
* read it via :has() or via a body-level class added on DOMContentLoaded */
|
|
try {
|
|
if (localStorage.getItem('zebra-spaces-controls-v1') === 'hidden'){
|
|
document.documentElement.classList.add('controls-collapsed-pref');
|
|
}
|
|
} catch(_){}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">dark</button>
|
|
<button id="btn-controls-toggle" class="controls-toggle" type="button" aria-label="hide side panel" title="hide right side panel">hide panel</button>
|
|
|
|
<div class="page">
|
|
|
|
<!-- left column: cameras at top, then screen-thumbs, then games at the
|
|
bottom. Games are persistent (always visible) so the column is
|
|
always on; cameras + screens push the games further down as people
|
|
join + share. People can play during the meeting without the games
|
|
ever competing with a live screen-share for the middle slot. -->
|
|
<aside id="sec-cameras" class="cameras-col">
|
|
<!-- single popularity-sorted list. Cameras, screen-shares and
|
|
game-shares all live here, ordered by tileScore() (host boost
|
|
on the owner + viewer count). Games stay below as a fixed
|
|
persistent group — they're not popularity-ranked. -->
|
|
<h2 id="tiles-h2" class="hidden">shares</h2>
|
|
<div id="tiles-thumbs"></div>
|
|
<h2 class="games-h2">games</h2>
|
|
<div id="games-thumbs"></div>
|
|
</aside>
|
|
|
|
<main class="timeline">
|
|
<!-- spotlight: the active camera or screen tile occupies this slot at
|
|
full size. All other tiles render as thumbnails in the cameras
|
|
column. Click a thumbnail to swap it into the spotlight. -->
|
|
<section id="sec-spotlight" class="hidden">
|
|
<div id="spotlight"></div>
|
|
</section>
|
|
</main>
|
|
|
|
<aside class="controls">
|
|
|
|
<h1>zebra spaces</h1>
|
|
<p class="sub">encrypted voice rooms · 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">
|
|
<span id="pub-short" class="pub-short" title="your persistent ed25519 pubkey"></span>
|
|
</div>
|
|
<div class="row">
|
|
<button id="btn-vault" class="small">backup / restore</button>
|
|
<button id="btn-logout" class="small">log out</button>
|
|
<span class="note">key lives in this browser only.</span>
|
|
</div>
|
|
<div id="vault-panel" class="vault-panel hidden">
|
|
<p class="note" style="margin-bottom:0.5rem"><strong>backup</strong> — pick a password; you'll get a text blob to save.</p>
|
|
<div class="row">
|
|
<input type="password" id="vault-pass" placeholder="password" autocomplete="new-password">
|
|
<button id="btn-vault-backup" class="small">export</button>
|
|
</div>
|
|
<p class="note" style="margin:0.6rem 0 0.4rem"><strong>restore</strong> — paste a backup blob + its password. <em>overwrites your current identity.</em></p>
|
|
<textarea id="vault-blob" placeholder="zspc-id-v1|…"></textarea>
|
|
<div class="row" style="margin-top:0.4rem">
|
|
<input type="password" id="vault-pass-restore" placeholder="password" autocomplete="current-password">
|
|
<button id="btn-vault-restore" class="small">import</button>
|
|
</div>
|
|
<div class="row" style="margin-top:0.4rem">
|
|
<span class="dot" id="vault-dot"></span>
|
|
<span id="vault-status" class="status-line">idle</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="sec-call">
|
|
<h2>space</h2>
|
|
<div class="row">
|
|
<input type="text" id="rdv-code" placeholder="rendezvous code — same string for everyone in the space">
|
|
<button id="btn-enter" class="invert">enter</button>
|
|
</div>
|
|
<div class="row">
|
|
<span class="dot warn" id="dot-call"></span>
|
|
<span id="call-status" class="status-line">idle</span>
|
|
<button id="btn-mute" disabled>mute</button>
|
|
<button id="btn-leave" disabled>leave</button>
|
|
</div>
|
|
<div class="row" id="row-mic-controls">
|
|
<select id="mic-select" title="audio input device — applies once you have the mic"><option value="">input default</option></select>
|
|
</div>
|
|
<div class="row" id="row-music-mode">
|
|
<label class="note"><input type="checkbox" id="music-mode"> music mode — raw mic, no echo/noise cancellation (for playing audio through it)</label>
|
|
</div>
|
|
<p class="note">join as listener; host promotes to mic. end-to-end encrypted.</p>
|
|
</section>
|
|
|
|
<section id="sec-invite" class="hidden">
|
|
<div class="invite-banner">
|
|
<div>
|
|
<strong>you're invited to the mic.</strong>
|
|
<span id="invite-from" class="note"></span>
|
|
</div>
|
|
<div class="invite-actions">
|
|
<button id="btn-accept-mic" class="invert small">accept</button>
|
|
<button id="btn-decline-mic" class="small">decline</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="sec-notice" class="hidden">
|
|
<div id="notice-banner" class="notice-banner warn">
|
|
<span id="notice-text"></span>
|
|
<button id="btn-notice-close" class="small">dismiss</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="sec-room" class="hidden">
|
|
<h2>room</h2>
|
|
<div id="members"></div>
|
|
</section>
|
|
|
|
<section id="sec-screen-share" class="hidden">
|
|
<h2>share</h2>
|
|
<div class="row">
|
|
<button id="btn-screen-share" class="invert">share screen</button>
|
|
<button id="btn-screen-stop" class="hidden">stop sharing</button>
|
|
</div>
|
|
<div class="row">
|
|
<button id="btn-camera-share" class="invert">share camera</button>
|
|
<button id="btn-camera-stop" class="hidden">stop camera</button>
|
|
<select id="camera-select" title="camera device — applies on next start"><option value="">default camera</option></select>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="sec-listener-actions" class="hidden">
|
|
<h2>your hand</h2>
|
|
<div class="row">
|
|
<button id="btn-raise" class="invert">raise hand</button>
|
|
<button id="btn-lower" class="hidden">lower hand</button>
|
|
<span id="hand-status" class="note">tap "raise hand" to ask a mod for the mic.</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="sec-latency" class="hidden">
|
|
<h2>latency</h2>
|
|
<div id="latency-rows" class="latency-rows"></div>
|
|
<span class="note">RTT from <code>candidate-pair.currentRoundTripTime</code> + path kind (host = LAN, srflx = direct WAN, relay = through TURN). Updates every 2s.</span>
|
|
</section>
|
|
|
|
<section id="sec-share" class="hidden">
|
|
<h2>link</h2>
|
|
<p class="note" style="margin-bottom:0.5rem">share only with people you trust to hear the room.</p>
|
|
<div class="row">
|
|
<input type="text" id="share-url" readonly>
|
|
<button id="btn-copy-share" class="small">copy</button>
|
|
</div>
|
|
<div id="qr-host" class="row" style="display:none">
|
|
<canvas id="share-qr" width="220" height="220" aria-label="QR code for this space"></canvas>
|
|
</div>
|
|
</section>
|
|
|
|
</aside>
|
|
</div>
|
|
|
|
<!-- log moved out of the right column so it can use the full viewport
|
|
width. Sits between the .page grid and the page-integrity footer,
|
|
starts at 240px tall, vertical-resize handle for growing it. -->
|
|
<section id="sec-log">
|
|
<h2>log</h2>
|
|
<div class="log" id="log"></div>
|
|
</section>
|
|
|
|
<script>
|
|
(async () => {
|
|
const $ = (id) => document.getElementById(id);
|
|
function b64(buf){ const u8=buf instanceof Uint8Array?buf:new Uint8Array(buf); let s=''; for(const b of u8) s+=String.fromCharCode(b); return btoa(s); }
|
|
function unb64(s){ return Uint8Array.from(atob(s), c=>c.charCodeAt(0)); }
|
|
function hex(buf){ const u8=buf instanceof Uint8Array?buf:new Uint8Array(buf); return Array.from(u8).map(b=>b.toString(16).padStart(2,'0')).join(''); }
|
|
function shortHex(s){ return s.slice(0,4)+'…'+s.slice(-4); }
|
|
|
|
const logEl = $('log');
|
|
function logLine(kind, msg){
|
|
const d=document.createElement('div'); d.className='log-line '+(kind||'');
|
|
d.innerHTML='<span class="ts">'+new Date().toLocaleTimeString()+'</span> '+msg.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
|
|
logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight;
|
|
}
|
|
|
|
/* pubHex straight from a server message — prefers the explicit hex field,
|
|
* falls back to decoding the b64 form. Used so role/spot/boot/etc. log lines
|
|
* carry the actor's authoritative key (the server already verified the
|
|
* signature), independent of the local uuid→handle map. A spoof attempt
|
|
* is then visible in the log retroactively: same handle, different pubkey. */
|
|
function pubHexFromMsg(m, b64key, hexkey){
|
|
if (m && hexkey && m[hexkey]) return m[hexkey];
|
|
if (m && b64key && m[b64key]){
|
|
try {
|
|
const bin = atob(m[b64key].replace(/-/g,'+').replace(/_/g,'/'));
|
|
let h=''; for (let i=0;i<bin.length;i++) h += bin.charCodeAt(i).toString(16).padStart(2,'0');
|
|
return h;
|
|
} catch(_){}
|
|
}
|
|
return '';
|
|
}
|
|
/* "handle pubhex" or just "pubhex" if we don't have the member yet. Full
|
|
* hex — never truncated. */
|
|
function idTag(uuidOrHandle, pubHex){
|
|
const mm = (uuidOrHandle && members.get) ? members.get(uuidOrHandle) : null;
|
|
const handle = mm ? mm.handle : (uuidOrHandle || '');
|
|
if (!pubHex) return handle;
|
|
return handle ? (handle+' '+pubHex) : pubHex;
|
|
}
|
|
|
|
/* ==================================================================
|
|
* state-machine framework — pure transitions, testable in isolation.
|
|
*
|
|
* spec = {
|
|
* initial: 'off',
|
|
* context: { ... }, // shared mutable state
|
|
* states: {
|
|
* off: { entry?, exit?, on: { START: 'acquiring' } },
|
|
* acquiring: { on: { ACQUIRED: { target: 'live', action(ctx, ev) } } },
|
|
* ...
|
|
* }
|
|
* }
|
|
*
|
|
* Transition table values are either a target state string OR an object
|
|
* with { target, action }. Actions and entry/exit hooks are SYNCHRONOUS
|
|
* and may NOT call send() during their own entry (queue would race).
|
|
* Async work belongs in observers — observers fire after each transition
|
|
* and may call send() to advance the machine.
|
|
*
|
|
* No external deps: runs in browser AND Node so unit tests can drive
|
|
* any FSM with synthetic events and assert transition tables. */
|
|
function createFSM(spec){
|
|
let state = spec.initial;
|
|
const ctx = Object.assign({}, spec.context || {});
|
|
const observers = new Set();
|
|
let started = false;
|
|
function notify(prev, ev){
|
|
for (const fn of observers) try { fn({ state, prev, ev, ctx }); } catch(_){}
|
|
}
|
|
function runEntry(ev){
|
|
const def = spec.states[state];
|
|
if (def && def.entry) try { def.entry(ctx, ev); } catch(_){}
|
|
}
|
|
function runExit(ev){
|
|
const def = spec.states[state];
|
|
if (def && def.exit) try { def.exit(ctx, ev); } catch(_){}
|
|
}
|
|
function send(type, payload){
|
|
if (!started) start();
|
|
const def = spec.states[state];
|
|
if (!def || !def.on) return false;
|
|
const t = def.on[type];
|
|
if (!t) return false;
|
|
const target = (typeof t === 'string') ? t : t.target;
|
|
if (!target || !spec.states[target]) return false;
|
|
const action = (typeof t === 'string') ? null : t.action;
|
|
const prev = state;
|
|
const ev = { type, payload };
|
|
runExit(ev);
|
|
state = target;
|
|
if (action) try { action(ctx, ev); } catch(_){}
|
|
runEntry(ev);
|
|
notify(prev, ev);
|
|
return true;
|
|
}
|
|
function start(){
|
|
if (started) return api;
|
|
started = true;
|
|
runEntry(null);
|
|
notify(null, null);
|
|
return api;
|
|
}
|
|
const api = {
|
|
get state(){ return state; },
|
|
get context(){ return ctx; },
|
|
send,
|
|
observe(fn){ observers.add(fn); return () => observers.delete(fn); },
|
|
start,
|
|
};
|
|
return api;
|
|
}
|
|
|
|
/* ==================================================================
|
|
* PublishFSM — one instance per kind (mic / screen / camera).
|
|
*
|
|
* off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
|
|
* ▲ │ │ │
|
|
* │ FAILED FAILED STOP / LOST
|
|
* │ ▼ ▼ ▼
|
|
* └──────────── (off) (off) stopping
|
|
* │
|
|
* DONE
|
|
* ▼
|
|
* off
|
|
*
|
|
* Pure transition spec. The runtime wires up actual getUserMedia /
|
|
* getDisplayMedia / fetch / PeerConnection via observers and feeds
|
|
* results back as events. */
|
|
const publishSpec = {
|
|
initial: 'off',
|
|
context: { kind: '', stream: null, pc: null, peerID: null, lastError: null },
|
|
states: {
|
|
off: {
|
|
entry: (ctx) => { ctx.stream = null; ctx.pc = null; ctx.peerID = null; },
|
|
on: {
|
|
START: 'acquiring',
|
|
},
|
|
},
|
|
acquiring: {
|
|
on: {
|
|
ACQUIRED: {
|
|
target: 'negotiating',
|
|
action: (ctx, ev) => { ctx.stream = ev.payload && ev.payload.stream; },
|
|
},
|
|
FAILED: {
|
|
target: 'off',
|
|
action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; },
|
|
},
|
|
STOP: 'off', /* cancelled before media acquired */
|
|
},
|
|
},
|
|
negotiating: {
|
|
on: {
|
|
NEGOTIATED: {
|
|
target: 'live',
|
|
action: (ctx, ev) => {
|
|
if (ev.payload){ ctx.pc = ev.payload.pc || ctx.pc; ctx.peerID = ev.payload.peerID || ctx.peerID; }
|
|
},
|
|
},
|
|
FAILED: {
|
|
target: 'stopping',
|
|
action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; },
|
|
},
|
|
STOP: 'stopping',
|
|
},
|
|
},
|
|
live: {
|
|
on: {
|
|
STOP: 'stopping',
|
|
LOST: 'stopping', /* track ended via device disappearance */
|
|
},
|
|
},
|
|
stopping: {
|
|
on: {
|
|
DONE: 'off',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
/* ==================================================================
|
|
* SubscribeFSM — one instance for the SFU subscribe leg.
|
|
*
|
|
* off ──START──▶ connecting ──CONNECTED──▶ subscribed
|
|
* ▲ │ FAILED │ RENEG (SSE offer arrives)
|
|
* │ ▼ ▼
|
|
* │ off renegotiating
|
|
* │ │ RENEG_DONE / RENEG_FAILED
|
|
* │ ▼
|
|
* │ subscribed
|
|
* │ │ LOST (SSE drop)
|
|
* │ ▼
|
|
* │ reconnecting ──CONNECTED──▶ subscribed
|
|
* │ │
|
|
* │ STOP
|
|
* │ ▼
|
|
* └────────────── DONE ──────────────── stopping
|
|
*
|
|
* Renegotiation lives in its own state so RENEG offers queued during an
|
|
* in-flight one can't race setRemoteDescription (the bug we hit in
|
|
* commit ae9721e). When RENEG fires while already renegotiating, the
|
|
* spec parks the SDP on ctx.pendingOffers; the runtime drains the queue
|
|
* on RENEG_DONE via an observer, so the FSM itself stays pure. */
|
|
const subscribeSpec = {
|
|
initial: 'off',
|
|
context: { pc: null, peerID: null, events: null, lastError: null, pendingOffers: [] },
|
|
states: {
|
|
off: {
|
|
entry: (ctx) => { ctx.pc = null; ctx.peerID = null; ctx.events = null; ctx.pendingOffers = []; },
|
|
on: { START: 'connecting' },
|
|
},
|
|
connecting: {
|
|
on: {
|
|
CONNECTED: {
|
|
target: 'subscribed',
|
|
action: (ctx, ev) => {
|
|
if (ev.payload){
|
|
ctx.pc = ev.payload.pc || ctx.pc;
|
|
ctx.peerID = ev.payload.peerID || ctx.peerID;
|
|
ctx.events = ev.payload.events || ctx.events;
|
|
}
|
|
},
|
|
},
|
|
FAILED: { target: 'off', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
|
|
STOP: 'off',
|
|
},
|
|
},
|
|
subscribed: {
|
|
on: {
|
|
RENEG: {
|
|
target: 'renegotiating',
|
|
action: (ctx, ev) => { const sdp = ev.payload && ev.payload.sdp; if (sdp) ctx.pendingOffers.push(sdp); },
|
|
},
|
|
LOST: 'reconnecting',
|
|
STOP: 'stopping',
|
|
},
|
|
},
|
|
renegotiating: {
|
|
on: {
|
|
/* extra offers queue up; we re-enter renegotiating so observers
|
|
* can see the transition but the existing run continues */
|
|
RENEG: {
|
|
target: 'renegotiating',
|
|
action: (ctx, ev) => { const sdp = ev.payload && ev.payload.sdp; if (sdp) ctx.pendingOffers.push(sdp); },
|
|
},
|
|
RENEG_DONE: 'subscribed',
|
|
RENEG_FAILED: { target: 'subscribed', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
|
|
LOST: 'reconnecting',
|
|
STOP: 'stopping',
|
|
},
|
|
},
|
|
reconnecting: {
|
|
on: {
|
|
CONNECTED: 'subscribed',
|
|
FAILED: { target: 'off', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
|
|
STOP: 'stopping',
|
|
},
|
|
},
|
|
stopping: {
|
|
on: { DONE: 'off' },
|
|
},
|
|
},
|
|
};
|
|
|
|
/* ==================================================================
|
|
* RemoteTileFSM — one instance per incoming screen / camera track from
|
|
* a remote publisher. Formalises the lifecycle that f71e9e7 patched
|
|
* imperatively (frozen-thumb after unshare):
|
|
*
|
|
* inactive ──TRACK_ARRIVED──▶ receiving ──MUTED──▶ muted
|
|
* ▲ │
|
|
* │ UNMUTED │ PRUNE / ENDED
|
|
* └────────────────────┤
|
|
* ▼
|
|
* removed
|
|
*
|
|
* {receiving, muted} + ENDED → removed
|
|
* * + LEFT → removed (peer-left wipes the tile from every state)
|
|
*
|
|
* MUTED is a debounce gate, not a deletion: if UNMUTED arrives within
|
|
* the runtime's debounce window (~1.5s) we stay receiving — the mute
|
|
* was a transient network blip. If PRUNE fires (debounce expired and
|
|
* still muted) the publisher really unshared and the tile dies.
|
|
* ENDED skips the debounce. removed is terminal — a new tile gets
|
|
* a fresh FSM. */
|
|
const remoteTileSpec = {
|
|
initial: 'inactive',
|
|
context: { kind: '', pubHex: '', stream: null, lastError: null },
|
|
states: {
|
|
inactive: {
|
|
on: {
|
|
TRACK_ARRIVED: {
|
|
target: 'receiving',
|
|
action: (ctx, ev) => { if (ev.payload) ctx.stream = ev.payload.stream || ctx.stream; },
|
|
},
|
|
LEFT: 'removed',
|
|
},
|
|
},
|
|
receiving: {
|
|
on: {
|
|
MUTED: 'muted',
|
|
ENDED: 'removed',
|
|
LEFT: 'removed',
|
|
/* a fresh ontrack for the same pubHex+kind — publisher re-shared
|
|
* before our prune fired; keep the new stream and stay receiving */
|
|
TRACK_ARRIVED: {
|
|
target: 'receiving',
|
|
action: (ctx, ev) => { if (ev.payload && ev.payload.stream) ctx.stream = ev.payload.stream; },
|
|
},
|
|
},
|
|
},
|
|
muted: {
|
|
on: {
|
|
UNMUTED: 'receiving',
|
|
PRUNE: 'removed',
|
|
ENDED: 'removed',
|
|
LEFT: 'removed',
|
|
TRACK_ARRIVED: {
|
|
target: 'receiving',
|
|
action: (ctx, ev) => { if (ev.payload && ev.payload.stream) ctx.stream = ev.payload.stream; },
|
|
},
|
|
},
|
|
},
|
|
removed: {
|
|
entry: (ctx) => { ctx.stream = null; },
|
|
/* terminal — no outbound transitions */
|
|
},
|
|
},
|
|
};
|
|
|
|
/* ==================================================================
|
|
* CallFSM — the user's overall lifecycle in a space. Orchestrates
|
|
* (but doesn't replace) the per-leg FSMs above: a Joined call has
|
|
* exactly one SubscribeFSM and zero-to-three PublishFSMs running
|
|
* underneath; the runtime starts/stops them on entry/exit of joined.
|
|
*
|
|
* idle ──ENTER──▶ connecting ──WELCOME──▶ joined ──LEAVE──▶ leaving ──DONE──▶ idle
|
|
* ▲ │ FAILED │ ▲
|
|
* │ ▼ │ WS_DROPPED │
|
|
* │ idle ▼ │
|
|
* │ reconnecting ──WELCOME──▶ joined │
|
|
* │ │ LEAVE / FAILED │
|
|
* │ ▼ │
|
|
* │ leaving ─────────────────────────────┘
|
|
* │ ▲
|
|
* │ │ ACK
|
|
* └─────────────────────────────────── booted ◀── BOOTED ── (any live state)
|
|
*
|
|
* Role lives in ctx (host / cohost / speaker / listener). ROLE_CHANGE
|
|
* fires re-entry of joined so observers can swap mesh/sub/publish
|
|
* topology without inventing a new state per role permutation. */
|
|
const callSpec = {
|
|
initial: 'idle',
|
|
context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, lastError: null },
|
|
states: {
|
|
idle: {
|
|
entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; },
|
|
on: {
|
|
ENTER: {
|
|
target: 'connecting',
|
|
action: (ctx, ev) => { if (ev.payload){ ctx.code = ev.payload.code || ''; ctx.handle = ev.payload.handle || ''; } },
|
|
},
|
|
},
|
|
},
|
|
connecting: {
|
|
on: {
|
|
WELCOME: {
|
|
target: 'joined',
|
|
action: (ctx, ev) => { if (ev.payload){ ctx.uuid = ev.payload.uuid || ''; ctx.role = ev.payload.role || ''; } },
|
|
},
|
|
FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
|
|
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
|
|
LEAVE: 'idle',
|
|
},
|
|
},
|
|
joined: {
|
|
on: {
|
|
/* ROLE_CHANGE re-enters joined so observers get an event to act
|
|
* on (swap mesh peers, start/stop publish, etc.) */
|
|
ROLE_CHANGE: {
|
|
target: 'joined',
|
|
action: (ctx, ev) => { if (ev.payload && ev.payload.role) ctx.role = ev.payload.role; },
|
|
},
|
|
WS_DROPPED: 'reconnecting',
|
|
LEAVE: 'leaving',
|
|
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
|
|
},
|
|
},
|
|
reconnecting: {
|
|
/* signal-server WebSocket dropped; the SubscribeFSM + publish
|
|
* legs stay alive because WebRTC PCs are independent of the WS.
|
|
* On WELCOME (a re-issued one after the WS comes back) we land
|
|
* back in joined and ROLE_CHANGE may follow. */
|
|
on: {
|
|
WELCOME: {
|
|
target: 'joined',
|
|
action: (ctx, ev) => { if (ev.payload && ev.payload.role) ctx.role = ev.payload.role; },
|
|
},
|
|
FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } },
|
|
LEAVE: 'leaving',
|
|
BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } },
|
|
},
|
|
},
|
|
leaving: {
|
|
on: { DONE: 'idle' },
|
|
},
|
|
booted: {
|
|
/* user has been removed from the room. ACK transitions back to
|
|
* idle so the entry screen comes back; the room's auto-rejoin
|
|
* sessionStorage key is cleared by the runtime when entering booted. */
|
|
on: { ACK: 'idle' },
|
|
},
|
|
},
|
|
};
|
|
|
|
/* ==================================================================
|
|
* wireZebraMachines — orchestrator. Composes one CallFSM, one
|
|
* SubscribeFSM, three PublishFSMs (mic/screen/camera), and a Map of
|
|
* RemoteTileFSMs into a coherent room. Observers wire transitions
|
|
* between machines; no side effects in this layer — the page's
|
|
* runtime attaches its OWN observers on top to drive actual WebRTC
|
|
* and DOM work. That separation keeps this function fully testable
|
|
* in Node with synthetic events.
|
|
*
|
|
* Returns { call, sub, pubs, remoteTiles, tileFor, tileLeft }. */
|
|
function wireZebraMachines(){
|
|
const call = createFSM(callSpec);
|
|
const sub = createFSM(subscribeSpec);
|
|
const pubs = {
|
|
mic: createFSM(publishSpec),
|
|
screen: createFSM(publishSpec),
|
|
camera: createFSM(publishSpec),
|
|
};
|
|
pubs.mic.context.kind = 'mic';
|
|
pubs.screen.context.kind = 'screen';
|
|
pubs.camera.context.kind = 'camera';
|
|
|
|
/* RemoteTileFSMs keyed by `${kind}:${pubHex}` — lazily created. */
|
|
const remoteTiles = new Map();
|
|
function tileFor(kind, pubHex){
|
|
const key = kind + ':' + pubHex;
|
|
let t = remoteTiles.get(key);
|
|
if (!t){
|
|
t = createFSM(remoteTileSpec);
|
|
t.context.kind = kind;
|
|
t.context.pubHex = pubHex;
|
|
remoteTiles.set(key, t);
|
|
}
|
|
return t;
|
|
}
|
|
/* peer-left: any tile keyed by this pubHex (any kind) goes LEFT */
|
|
function tileLeft(pubHex){
|
|
for (const [key, t] of remoteTiles){
|
|
if (key.endsWith(':' + pubHex)) t.send('LEFT');
|
|
}
|
|
}
|
|
|
|
/* CallFSM → SubscribeFSM: subscribe whenever joined, stop when not.
|
|
* Coming back from reconnecting → joined doesn't re-START because the
|
|
* SubscribeFSM stayed alive through the signal-WS drop. */
|
|
call.observe(({ state, prev }) => {
|
|
if (state === prev) return;
|
|
if (state === 'joined' && prev !== 'reconnecting'){
|
|
sub.send('START');
|
|
}
|
|
if (state === 'leaving' || state === 'booted'){
|
|
sub.send('STOP');
|
|
}
|
|
});
|
|
|
|
/* CallFSM → PublishFSMs: tearing down the call stops every live publish */
|
|
call.observe(({ state, prev }) => {
|
|
if (state === prev) return;
|
|
if (state === 'leaving' || state === 'booted'){
|
|
for (const k of Object.keys(pubs)){
|
|
const ps = pubs[k].state;
|
|
if (ps === 'acquiring' || ps === 'negotiating' || ps === 'live'){
|
|
pubs[k].send('STOP');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return { call, sub, pubs, remoteTiles, tileFor, tileLeft };
|
|
}
|
|
|
|
/* ==================================================================
|
|
* identity — ed25519 keypair, persisted in localStorage as JWK.
|
|
*
|
|
* Why JWK and not raw bytes: WebCrypto's Ed25519 importKey accepts
|
|
* 'jwk' and 'raw'/'pkcs8' but JWK round-trips losslessly with both
|
|
* private and pubkey halves in one object. The pubkey we wire to the
|
|
* server is the raw 32-byte form, base64-encoded, matching what the
|
|
* Go side does with ed25519.PublicKey.
|
|
* ================================================================== */
|
|
const ID_KEY = 'zebra-spaces-id-v1';
|
|
const HANDLE_KEY = 'zebra-spaces-handle-v1';
|
|
const MUSIC_MODE_KEY = 'zebra-spaces-music-mode-v1';
|
|
const MIC_DEV_KEY = 'zebra-spaces-mic-device-v1';
|
|
const CAM_DEV_KEY = 'zebra-spaces-cam-device-v1';
|
|
const THEME_KEY = 'zebra-theme-v1';
|
|
/* sessionStorage (per-tab) — tracks which call this tab is in so a
|
|
* hard refresh auto-rejoins. Different tabs can sit in different
|
|
* spaces because sessionStorage doesn't bleed across tabs. */
|
|
const ACTIVE_CALL_KEY = 'zebra-spaces-active-call-v1';
|
|
/* camera resume only — screen-share recovery isn't possible silently
|
|
* (getDisplayMedia needs a fresh gesture) and offering a separate
|
|
* 'resume screen' button is the same friction as clicking 'share
|
|
* screen' again, so we just don't track screen state across reload. */
|
|
const ACTIVE_CAM_KEY = 'zebra-spaces-active-cam-v1';
|
|
/* mute state for the current tab — '1' means muted. Survives a hard
|
|
* refresh via sessionStorage so the page reconnects in the same state
|
|
* the user last set. Cleared on explicit leave. NOT cleared on role
|
|
* demotion (mic dropped), because the next promotion should respect
|
|
* the user's previously-chosen mute state instead of silently
|
|
* un-muting them. */
|
|
const MUTE_STATE_KEY = 'zebra-spaces-mute-v1';
|
|
|
|
/* theme toggle. The class is already applied pre-paint by the head
|
|
* script, so this just wires the click + keeps the button label in
|
|
* sync with the current state. */
|
|
function applyThemeLabel(){
|
|
const dark = document.documentElement.classList.contains('theme-dark');
|
|
const btn = $('btn-theme'); if (btn) btn.textContent = dark ? 'light' : 'dark';
|
|
}
|
|
applyThemeLabel();
|
|
$('btn-theme').addEventListener('click', () => {
|
|
const root = document.documentElement;
|
|
const goDark = !root.classList.contains('theme-dark');
|
|
root.classList.toggle('theme-dark', goDark);
|
|
try { localStorage.setItem(THEME_KEY, goDark ? 'dark' : 'light'); } catch(_){}
|
|
applyThemeLabel();
|
|
});
|
|
|
|
/* controls-panel collapse — promotes the pre-paint <html> pref onto the
|
|
* .page element, then keeps both in sync on click. Pre-paint class on
|
|
* <html> stays so a fresh load with a saved 'hidden' pref still shows
|
|
* the layout collapsed instantly. */
|
|
const CONTROLS_KEY = 'zebra-spaces-controls-v1';
|
|
function applyControlsLabel(){
|
|
const hidden = document.querySelector('.page').classList.contains('controls-collapsed');
|
|
const btn = $('btn-controls-toggle'); if (btn) btn.textContent = hidden ? 'show panel' : 'hide panel';
|
|
}
|
|
if (document.documentElement.classList.contains('controls-collapsed-pref')){
|
|
document.querySelector('.page').classList.add('controls-collapsed');
|
|
}
|
|
applyControlsLabel();
|
|
$('btn-controls-toggle').addEventListener('click', () => {
|
|
const page = document.querySelector('.page');
|
|
const goHidden = !page.classList.contains('controls-collapsed');
|
|
page.classList.toggle('controls-collapsed', goHidden);
|
|
document.documentElement.classList.toggle('controls-collapsed-pref', goHidden);
|
|
try { localStorage.setItem(CONTROLS_KEY, goHidden ? 'hidden' : 'shown'); } catch(_){}
|
|
applyControlsLabel();
|
|
});
|
|
const VAULT_PREFIX = 'zspc-id-v1|';
|
|
const PBKDF2_ITER = 600000;
|
|
|
|
let myKeys = null; /* { privateKey, publicKey, pubB64, pubHex } */
|
|
let myHandle = '';
|
|
|
|
async function generateIdentity(){
|
|
const kp = await crypto.subtle.generateKey({ name:'Ed25519' }, true, ['sign','verify']);
|
|
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
|
|
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
|
localStorage.setItem(ID_KEY, JSON.stringify(jwk));
|
|
return packKeys(kp.privateKey, kp.publicKey, raw);
|
|
}
|
|
function packKeys(priv, pub, rawPub){
|
|
const pubB64 = b64(rawPub), pubHex = hex(rawPub);
|
|
return { privateKey: priv, publicKey: pub, pubB64, pubHex };
|
|
}
|
|
async function loadOrCreateIdentity(){
|
|
const stored = localStorage.getItem(ID_KEY);
|
|
if (!stored){ myKeys = await generateIdentity(); logLine('','new identity created'); return; }
|
|
try {
|
|
const jwk = JSON.parse(stored);
|
|
const priv = await crypto.subtle.importKey('jwk', jwk, { name:'Ed25519' }, true, ['sign']);
|
|
/* derive pubkey JWK from the priv JWK so we can importKey for raw export */
|
|
const pubJwk = { kty:jwk.kty, crv:jwk.crv, x:jwk.x };
|
|
const pub = await crypto.subtle.importKey('jwk', pubJwk, { name:'Ed25519' }, true, ['verify']);
|
|
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pub));
|
|
myKeys = packKeys(priv, pub, raw);
|
|
} catch(e){
|
|
logLine('err','stored identity unreadable, generating new one: '+e.message);
|
|
myKeys = await generateIdentity();
|
|
}
|
|
}
|
|
async function signBytes(bytes){
|
|
const sig = await crypto.subtle.sign('Ed25519', myKeys.privateKey, bytes);
|
|
return b64(sig);
|
|
}
|
|
|
|
/* canonical sig inputs — must match the Go side byte-for-byte */
|
|
function sigJoin(roomID, nonce, pubB64, handle){
|
|
return new TextEncoder().encode('zebra-spaces|v1|join|'+roomID+'|'+nonce+'|'+pubB64+'|'+handle);
|
|
}
|
|
function sigAction(roomID, epoch, action, ...args){
|
|
return new TextEncoder().encode(['zebra-spaces','v1',roomID,String(epoch),action,...args].join('|'));
|
|
}
|
|
|
|
/* ==================================================================
|
|
* vault — password backup/restore of the identity JWK.
|
|
*
|
|
* Format: 'zspc-id-v1|' + base64(salt[16] | iv[12] | aes-gcm-ct).
|
|
* key = PBKDF2-SHA256(password, salt, 600000) -> AES-GCM-256
|
|
* ct = AES-GCM(iv, key, utf8(JSON(jwk)))
|
|
* Self-contained: anyone with the blob + password can restore.
|
|
* ================================================================== */
|
|
async function deriveVaultKey(password, salt, usage){
|
|
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password),
|
|
'PBKDF2', false, ['deriveKey']);
|
|
return crypto.subtle.deriveKey({ name:'PBKDF2', salt, iterations:PBKDF2_ITER, hash:'SHA-256' },
|
|
base, { name:'AES-GCM', length:256 }, false, usage);
|
|
}
|
|
async function vaultExport(password){
|
|
const jwk = JSON.parse(localStorage.getItem(ID_KEY));
|
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const key = await deriveVaultKey(password, salt, ['encrypt']);
|
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key,
|
|
new TextEncoder().encode(JSON.stringify(jwk))));
|
|
const blob = new Uint8Array(16+12+ct.length); blob.set(salt); blob.set(iv,16); blob.set(ct,28);
|
|
return VAULT_PREFIX + b64(blob);
|
|
}
|
|
async function vaultImport(blobStr, password){
|
|
if (!blobStr.startsWith(VAULT_PREFIX)) throw new Error('not a zebra-spaces vault blob');
|
|
const bytes = unb64(blobStr.slice(VAULT_PREFIX.length).trim());
|
|
if (bytes.length < 16+12+1) throw new Error('vault blob too short');
|
|
const salt = bytes.slice(0,16), iv = bytes.slice(16,28), ct = bytes.slice(28);
|
|
const key = await deriveVaultKey(password, salt, ['decrypt']);
|
|
const pt = new Uint8Array(await crypto.subtle.decrypt({ name:'AES-GCM', iv }, key, ct));
|
|
const jwk = JSON.parse(new TextDecoder().decode(pt));
|
|
/* round-trip through WebCrypto to validate it's a real Ed25519 key */
|
|
const priv = await crypto.subtle.importKey('jwk', jwk, { name:'Ed25519' }, true, ['sign']);
|
|
const pubJwk = { kty:jwk.kty, crv:jwk.crv, x:jwk.x };
|
|
const pub = await crypto.subtle.importKey('jwk', pubJwk, { name:'Ed25519' }, true, ['verify']);
|
|
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pub));
|
|
localStorage.setItem(ID_KEY, JSON.stringify(jwk));
|
|
myKeys = packKeys(priv, pub, raw);
|
|
renderIdentity();
|
|
}
|
|
|
|
function setVaultStatus(msg, cls){
|
|
const e=$('vault-status'); e.textContent=msg; e.className='status-line'+(cls?' '+cls:'');
|
|
$('vault-dot').className='dot'+(cls==='ok'?' ok':cls==='err'?' warn':'');
|
|
}
|
|
function renderIdentity(){ $('pub-short').textContent = myKeys ? shortHex(myKeys.pubHex) : ''; }
|
|
|
|
$('btn-vault').addEventListener('click', () => $('vault-panel').classList.toggle('hidden'));
|
|
|
|
/* log out — destructive: wipes Ed25519 + handle from localStorage and
|
|
* generates a fresh identity. The booted/blocked window keys off the
|
|
* pubkey, so logging out is also the escape hatch from a room block.
|
|
* The user keeps the same browser so we generate a new key right away;
|
|
* otherwise the page would refuse to enter any space (no identity). */
|
|
$('btn-logout').addEventListener('click', async () => {
|
|
if (ws){ setStatus('leave the space first','err'); return; }
|
|
/* destructive + permanent: the pubkey IS the user — it's how host
|
|
* claims, room reservations, cohost grants, blocks, and meeting-life
|
|
* blocklists key off you. Without a vault backup there's no way to
|
|
* be 'you' again from any device. Spell that out in plain language
|
|
* with the hex so the user can copy it if they panic mid-prompt. */
|
|
const msg =
|
|
'Log out will permanently destroy this identity in this browser.\n\n' +
|
|
'pubkey ' + (myKeys ? myKeys.pubHex : '(none)') + '\n\n' +
|
|
'After this:\n' +
|
|
' • You cannot reclaim host on any space you opened with this key\n' +
|
|
' • Cohost / speaker grants tied to this key are gone\n' +
|
|
' • The only way back is "backup / restore" — if you have not made a\n' +
|
|
' backup, this is one-way.\n\n' +
|
|
'Continue?';
|
|
if (!confirm(msg)) return;
|
|
try { localStorage.removeItem(ID_KEY); localStorage.removeItem(HANDLE_KEY); } catch(_){}
|
|
myHandle = ''; $('handle').value = '';
|
|
myKeys = await generateIdentity();
|
|
renderIdentity();
|
|
logLine('', 'logged out — fresh identity '+myKeys.pubHex);
|
|
});
|
|
$('btn-vault-backup').addEventListener('click', async () => {
|
|
const pw = $('vault-pass').value;
|
|
if (!pw){ setVaultStatus('enter a password first','err'); return; }
|
|
if (pw.length < 8){ setVaultStatus('password too short (min 8 chars)','err'); return; }
|
|
try {
|
|
setVaultStatus('encrypting…');
|
|
const blob = await vaultExport(pw);
|
|
/* offer download — local file, no network */
|
|
const fname = 'zebra-id-'+shortHex(myKeys.pubHex).replace('…','-')+'.txt';
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(new Blob([blob], {type:'text/plain'}));
|
|
a.download = fname; a.click();
|
|
setVaultStatus('exported as '+fname,'ok');
|
|
$('vault-pass').value = '';
|
|
} catch(e){ setVaultStatus('export failed: '+e.message,'err'); }
|
|
});
|
|
$('btn-vault-restore').addEventListener('click', async () => {
|
|
const blob = $('vault-blob').value.trim();
|
|
const pw = $('vault-pass-restore').value;
|
|
if (!blob || !pw){ setVaultStatus('paste blob + password','err'); return; }
|
|
try {
|
|
setVaultStatus('decrypting…');
|
|
await vaultImport(blob, pw);
|
|
setVaultStatus('identity restored — pubkey '+shortHex(myKeys.pubHex),'ok');
|
|
$('vault-blob').value=''; $('vault-pass-restore').value='';
|
|
} catch(e){ setVaultStatus('restore failed: '+e.message,'err'); }
|
|
});
|
|
|
|
/* handle is per-browser, also in localStorage so it survives reload */
|
|
$('handle').addEventListener('input', (e) => {
|
|
myHandle = e.target.value.trim().slice(0, 32);
|
|
localStorage.setItem(HANDLE_KEY, myHandle);
|
|
});
|
|
|
|
await loadOrCreateIdentity();
|
|
myHandle = localStorage.getItem(HANDLE_KEY) || '';
|
|
$('handle').value = myHandle;
|
|
/* preferences that should outlive a reload — music-mode toggle and the
|
|
* last-picked mic + camera deviceIds. Declared up here so the restore
|
|
* runs before their downstream `let` would put them in the temporal
|
|
* dead zone; downstream code now reads from these existing bindings. */
|
|
let musicMode = false, micDeviceId = '', cameraDeviceId = '';
|
|
try { musicMode = localStorage.getItem(MUSIC_MODE_KEY) === '1'; } catch(_){}
|
|
try { micDeviceId = localStorage.getItem(MIC_DEV_KEY) || ''; } catch(_){}
|
|
try { cameraDeviceId = localStorage.getItem(CAM_DEV_KEY) || ''; } catch(_){}
|
|
if ($('music-mode')) $('music-mode').checked = musicMode;
|
|
renderIdentity();
|
|
logLine('', 'pubkey '+myKeys.pubHex);
|
|
|
|
/* ==================================================================
|
|
* rendezvous signaling
|
|
* ================================================================== */
|
|
const SIGNAL_URL = new URLSearchParams(location.search).get('signal')
|
|
|| 'wss://cors-proxy.uncloseai.com/zebra-spaces-signal';
|
|
const SIGNAL_SALT = new TextEncoder().encode('zebra-spaces-v1');
|
|
async function deriveSignalRoom(code){
|
|
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('zebra-spaces-room|'+code));
|
|
return hex(h);
|
|
}
|
|
async function deriveSignalKey(code){
|
|
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(code), 'PBKDF2', false, ['deriveKey']);
|
|
return crypto.subtle.deriveKey({ name:'PBKDF2', salt:SIGNAL_SALT, iterations:PBKDF2_ITER, hash:'SHA-256' },
|
|
base, { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']);
|
|
}
|
|
async function aesEncrypt(key, str){
|
|
const iv=crypto.getRandomValues(new Uint8Array(12));
|
|
const ct=new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv}, key, new TextEncoder().encode(str)));
|
|
const out=new Uint8Array(12+ct.length); out.set(iv); out.set(ct,12); return out;
|
|
}
|
|
async function aesDecrypt(key, bytes){
|
|
const iv=bytes.slice(0,12), ct=bytes.slice(12);
|
|
return new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv}, key, ct));
|
|
}
|
|
|
|
/* ==================================================================
|
|
* SFU bridge — listeners subscribe to receive every speaker's audio;
|
|
* speakers publish their mic. Mesh handles speaker↔speaker low-latency;
|
|
* SFU handles broadcast fan-out to listeners. Speakers never subscribe
|
|
* (they'd hear their mesh peers a second time, delayed).
|
|
* ================================================================== */
|
|
const SFU_BASE = (new URLSearchParams(location.search).get('sfu')
|
|
|| 'https://cors-proxy.uncloseai.com/zebra-spaces-sfu').replace(/\/$/, '');
|
|
|
|
let sfuPubPC = null, sfuPubPeerID = null;
|
|
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
|
|
let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
|
|
let sfuCameraPC = null, sfuCameraPeerID = null, sfuCameraStream = null;
|
|
/* game-share rides its own SFU publisher kind so it can coexist with
|
|
* a regular screen share. Region Capture (Chromium) crops the captured
|
|
* video to the iframe only so audience members see just the game. */
|
|
let sfuGamePC = null, sfuGamePeerID = null, sfuGameStream = null;
|
|
/* cameraDeviceId is declared earlier so localStorage restore can write to it
|
|
* before the device-list refresh runs */
|
|
/* incoming screen/camera streams keyed by publisher pubkey hex (== streamID).
|
|
* Cleared when the corresponding publisher leaves (peer-left or boot). */
|
|
const screenStreams = new Map(); // pubHex -> MediaStream
|
|
const screenVideos = new Map(); // pubHex -> { tile, video }
|
|
const cameraStreams = new Map(); // pubHex -> MediaStream
|
|
const cameraVideos = new Map(); // pubHex -> { tile, video }
|
|
const gameStreams = new Map(); // pubHex -> MediaStream (kind=game-share)
|
|
const gameVideos = new Map(); // pubHex -> { tile, video }
|
|
/* SFU MediaStream cache keyed by PUBLISHER pubkey hex (= streamID set by the
|
|
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
|
|
* we tear their audio element, but Pion often REUSES the transceiver on
|
|
* their rejoin so ontrack doesn't fire a second time — the same MediaStream
|
|
* object keeps receiving new RTP under the hood. By caching by pubkey we
|
|
* can re-attach that same stream to a fresh audio element on peer-joined
|
|
* even when no fresh ontrack event arrives. */
|
|
const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream
|
|
|
|
function attachSfuTrack(uuid, stream){
|
|
let a = remoteAudio.get(uuid);
|
|
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
|
|
a.srcObject = stream;
|
|
/* programmatic play in case autoplay policy needs the nudge after a track
|
|
* swap — caller already had a user gesture (entered the space) */
|
|
try { const p = a.play(); if (p && p.catch) p.catch(()=>{}); } catch(_){}
|
|
stopMeter(uuid); startMeter(uuid, stream);
|
|
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid));
|
|
}
|
|
/* If we already have a cached SFU stream for this member's pubkey (from a
|
|
* prior subscribe-side ontrack), attach it. Used on peer-joined / host
|
|
* promotions / room state updates so a rejoined speaker's audio reattaches
|
|
* without needing the SFU to emit a fresh ontrack. */
|
|
function attachCachedSfuStreamFor(uuid){
|
|
const mm = members.get(uuid);
|
|
if (!mm || !mm.pubkey) return;
|
|
let pubHex;
|
|
try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ return; }
|
|
const stream = sfuStreamsByPubHex.get(pubHex);
|
|
if (stream) attachSfuTrack(uuid, stream);
|
|
}
|
|
function flushSfuStreams(){
|
|
for (const [pubHex, stream] of sfuStreamsByPubHex){
|
|
for (const [uuid, mm] of members){
|
|
try {
|
|
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
|
|
if (!remoteAudio.has(uuid)) attachSfuTrack(uuid, stream);
|
|
break;
|
|
}
|
|
} catch(_){}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* When a publisher unshares, the SFU stops its transceiver on every
|
|
* subscriber's pc and renegotiates. Browsers don't reliably fire
|
|
* 'ended' on remote tracks under this path (Chromium half-fires,
|
|
* Firefox stays silent). They DO fire 'mute' when RTP stops arriving.
|
|
* Watch both: a sustained mute for >1.5s = the publisher is gone and
|
|
* we should remove the tile. If 'unmute' fires within the window
|
|
* (transient network blip) we cancel the removal. */
|
|
function watchVideoTrackForRemoval(track, removeFn){
|
|
if (!track) return;
|
|
let timer = null, removed = false, 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; }
|
|
};
|
|
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) remove();
|
|
}, 3000);
|
|
};
|
|
track.addEventListener('ended', remove);
|
|
track.addEventListener('mute', onMute);
|
|
track.addEventListener('unmute', onUnmute);
|
|
}
|
|
|
|
/* Spotlight model — every tile keeps its thumbnail permanently in the
|
|
* cameras column (cameras above, screens-thumbs below). When a tile is
|
|
* spotlit, a SECOND larger tile renders in the middle slot pointing at
|
|
* the same MediaStream; the thumbnail gets a 'viewing' overlay + gray
|
|
* tint so the user can see which tile is up big. Clicking another
|
|
* thumbnail swaps the spotlight.
|
|
*
|
|
* Containers:
|
|
* #spotlight middle column (zero or one larger 'spotlight tile')
|
|
* #cameras left column: camera thumbnails (always rendered)
|
|
* #screens-thumbs left column: screen thumbnails (always rendered) */
|
|
const TILE_KINDS = {
|
|
screen: { thumbContainer:'tiles-thumbs', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
|
|
camera: { thumbContainer:'tiles-thumbs', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
|
|
gameshare: { thumbContainer:'tiles-thumbs', tileClass:'screen-tile', labelPrefix:'gameplay', store: gameVideos, streams: gameStreams },
|
|
};
|
|
/* the active spotlight, or null when nothing is spotlit */
|
|
let spotlight = null; // { kind, pubHex, tile (DOM), video (DOM) }
|
|
/* who in the room is viewing what big tile. Keyed by uuid → key
|
|
* ("kind:pubHex" or "" for none). Drives popularity sort + log lines. */
|
|
const spotlights = new Map();
|
|
function spotlightKey(){ return spotlight ? spotlight.kind+':'+spotlight.pubHex : ''; }
|
|
function getEntry(kind, pubHex){ const k = TILE_KINDS[kind]; return k ? k.store.get(pubHex) : null; }
|
|
function memberOf(pubHex){
|
|
for (const [uuid, mm] of members){
|
|
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex) return { uuid, mm }; } catch(_){}
|
|
}
|
|
return null;
|
|
}
|
|
/* games are tiles too. The 'pubHex' for a game is its short id below;
|
|
* spotlight broadcasts use 'game:<id>' as the key, so the room sees
|
|
* 'alice now viewing unmario' in the log just like 'alice now viewing
|
|
* bob's screen'. */
|
|
const GAMES = {
|
|
unmario: { label: 'unmario', src: 'https://unmario.com/' },
|
|
cake: { label: 'cake murder adventure', src: 'https://cuppcb.com/games/cake-murder-adventure/' },
|
|
};
|
|
function ownerLabel(kind, pubHex){
|
|
if (kind === 'game'){
|
|
const g = GAMES[pubHex];
|
|
return g ? g.label : pubHex;
|
|
}
|
|
const m = memberOf(pubHex);
|
|
const handle = m ? (m.mm.handle || shortHex(pubHex)) : shortHex(pubHex);
|
|
return handle + "'s " + (TILE_KINDS[kind] ? TILE_KINDS[kind].labelPrefix : kind);
|
|
}
|
|
/* game thumbs are STATIC cards. No iframe → no game JS, no network, no
|
|
* audio. The game only loads (in an iframe in the spotlight slot) when
|
|
* the tile is clicked. Click also broadcasts the spotlight choice so
|
|
* the room sees 'alice now viewing unmario'. */
|
|
function buildGameThumb(id){
|
|
const g = GAMES[id]; if (!g) return null;
|
|
const tile = document.createElement('div');
|
|
tile.className = 'game-thumb';
|
|
tile.dataset.pubHex = id;
|
|
tile.dataset.kind = 'game';
|
|
const poster = document.createElement('div');
|
|
poster.className = 'game-poster';
|
|
poster.textContent = g.label;
|
|
tile.appendChild(poster);
|
|
const meta = document.createElement('div'); meta.className = 'game-meta';
|
|
const who = document.createElement('span'); who.textContent = 'game';
|
|
const cue = document.createElement('span'); cue.className = 'play-cue'; cue.textContent = '▶ play';
|
|
meta.appendChild(who); meta.appendChild(cue);
|
|
tile.appendChild(meta);
|
|
const view = document.createElement('div'); view.className = 'viewing-badge'; view.textContent = 'viewing';
|
|
tile.appendChild(view);
|
|
tile.addEventListener('click', () => {
|
|
if (spotlight && spotlight.kind === 'game' && spotlight.pubHex === id) return;
|
|
setSpotlight('game', id);
|
|
});
|
|
return tile;
|
|
}
|
|
function renderGamesColumn(){
|
|
const c = $('games-thumbs'); if (!c) return;
|
|
c.innerHTML = '';
|
|
for (const id of Object.keys(GAMES)){
|
|
const t = buildGameThumb(id);
|
|
if (t) c.appendChild(t);
|
|
}
|
|
}
|
|
/* tile score = role-based boost on the OWNER + count of current viewers.
|
|
* Host always at top, co-hosts second, then speakers sorted by viewers.
|
|
* Listeners can't publish so they never appear. */
|
|
function tileScore(kind, pubHex){
|
|
let boost = 0;
|
|
const m = memberOf(pubHex);
|
|
if (m){
|
|
if (m.mm.role === 'host') boost = 10000;
|
|
else if (m.mm.role === 'cohost') boost = 5000;
|
|
else if (m.mm.role === 'speaker') boost = 0;
|
|
}
|
|
const key = kind + ':' + pubHex;
|
|
let viewers = 0;
|
|
for (const [, k] of spotlights) if (k === key) viewers++;
|
|
return boost + viewers;
|
|
}
|
|
/* one popularity-sorted list across screens + cameras + game-shares.
|
|
* Each thumb stamps its kind on tile.dataset.kind so tileScore can read
|
|
* the owner role and viewer count via the right TILE_KINDS entry. */
|
|
function reorderTiles(){
|
|
const c = $('tiles-thumbs'); if (!c) return;
|
|
const els = Array.from(c.children);
|
|
els.sort((a, b) => tileScore(b.dataset.kind, b.dataset.pubHex) - tileScore(a.dataset.kind, a.dataset.pubHex));
|
|
for (const el of els) c.appendChild(el);
|
|
}
|
|
function broadcastSpotlight(){
|
|
/* fire-and-forget to the signal server; no-op if not connected yet.
|
|
* Suppressed during role transitions — otherwise removeScreenTile +
|
|
* removeCameraTile triggered by sfuUnpublishScreen/Camera during a
|
|
* demotion fire pickNextSpotlight → broadcasts an empty spotlight,
|
|
* which every other peer logs as 'X looked away'. False signal. */
|
|
if (typeof inRoleTransition !== 'undefined' && inRoleTransition) return;
|
|
try { send({ type: 'spotlight', key: spotlightKey() }); } catch(_){}
|
|
}
|
|
function logSpotlightChange(uuid, key, viewerPubHex){
|
|
/* viewerPubHex is the authoritative pubkey from the server's spotlight
|
|
* broadcast; for self-spotlight calls we fall back to my own key. */
|
|
const mm = members.get(uuid);
|
|
const pub = viewerPubHex || (uuid === myUUID && myKeys ? myKeys.pubHex : (mm && mm.pubkey ? pubHexFromMsg({pubkey:mm.pubkey},'pubkey') : ''));
|
|
const who = idTag(uuid, pub);
|
|
if (!key){ logLine('', who + ' looked away'); return; }
|
|
const [kind, pubHex] = key.split(':');
|
|
logLine('', who + ' now viewing ' + ownerLabel(kind, pubHex) + (pubHex?' ['+pubHex+']':''));
|
|
}
|
|
|
|
function updateContainerVisibility(){
|
|
$('sec-spotlight').classList.toggle('hidden', !spotlight);
|
|
/* sec-cameras is always visible (games are persistent residents).
|
|
* 'shares' header only appears when at least one camera / screen /
|
|
* game-share thumb is live. */
|
|
const anyThumb = cameraVideos.size + screenVideos.size + gameVideos.size > 0;
|
|
$('tiles-h2').classList.toggle('hidden', !anyThumb);
|
|
}
|
|
|
|
/* build a tile DOM. opts.thumb = true for thumb-style (no fullscreen
|
|
* button, gets .tile-thumb class). Returns { tile, video, fsBtn }. */
|
|
function buildTile(kind, pubHex, label, opts){
|
|
const k = TILE_KINDS[kind];
|
|
const isThumb = !!(opts && opts.thumb);
|
|
const tile = document.createElement('div');
|
|
tile.className = k.tileClass + (isThumb ? ' tile-thumb' : '');
|
|
tile.dataset.pubHex = pubHex;
|
|
tile.dataset.kind = kind;
|
|
const video = document.createElement('video');
|
|
video.setAttribute('autoplay', '');
|
|
video.setAttribute('playsinline', '');
|
|
video.setAttribute('muted', '');
|
|
video.autoplay = true; video.playsInline = true; video.muted = true;
|
|
const tap = document.createElement('div'); tap.className = 'tap-play';
|
|
tap.textContent = 'tap to play';
|
|
tap.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
try { video.play().then(() => { tile.classList.remove('needs-tap'); })
|
|
.catch(e => logLine('err','play after tap: '+e.message)); } catch(_){}
|
|
};
|
|
const meta = document.createElement('div'); meta.className = 'screen-meta';
|
|
const who = document.createElement('span'); who.textContent = k.labelPrefix+': '+label;
|
|
const ctl = document.createElement('span');
|
|
let fsBtn = null;
|
|
if (!isThumb){
|
|
fsBtn = document.createElement('button'); fsBtn.className = 'small';
|
|
fsBtn.textContent = 'fullscreen';
|
|
fsBtn.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
if (video.requestFullscreen) video.requestFullscreen().catch(()=>{});
|
|
};
|
|
ctl.appendChild(fsBtn);
|
|
}
|
|
meta.appendChild(who); meta.appendChild(ctl);
|
|
/* "viewing" overlay only ever shown on thumbs whose tile is currently spotlit */
|
|
if (isThumb){
|
|
const view = document.createElement('div'); view.className = 'viewing-badge';
|
|
view.textContent = 'viewing';
|
|
tile.appendChild(view);
|
|
}
|
|
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
|
|
return { tile, video, fsBtn };
|
|
}
|
|
|
|
/* find a thumb element by kind+pubHex regardless of whether it's a
|
|
* camera/screen tile or a game card. Returned element gets the
|
|
* 'viewing' class toggle so the thumb→spotlight mapping is obvious. */
|
|
function thumbElementFor(kind, pubHex){
|
|
if (kind === 'game'){
|
|
return document.querySelector('.game-thumb[data-pub-hex="'+pubHex+'"]');
|
|
}
|
|
const e = getEntry(kind, pubHex);
|
|
return e ? e.tile : null;
|
|
}
|
|
|
|
function setSpotlight(kind, pubHex){
|
|
/* unmark previous spotlight's thumb + tear its big tile */
|
|
if (spotlight){
|
|
const prevThumb = thumbElementFor(spotlight.kind, spotlight.pubHex);
|
|
if (prevThumb) prevThumb.classList.remove('viewing');
|
|
clearSpotlightDOM();
|
|
spotlight = null;
|
|
}
|
|
if (kind === 'game'){
|
|
const g = GAMES[pubHex]; if (!g){ updateContainerVisibility(); return; }
|
|
const big = document.createElement('div'); big.className = 'game-tile';
|
|
const iframe = document.createElement('iframe');
|
|
iframe.src = g.src;
|
|
iframe.title = g.label;
|
|
iframe.setAttribute('referrerpolicy', 'no-referrer');
|
|
iframe.loading = 'eager';
|
|
const meta = document.createElement('div'); meta.className = 'screen-meta';
|
|
const who = document.createElement('span'); who.textContent = 'game: ' + g.label;
|
|
const ctl = document.createElement('span');
|
|
/* 'share gameplay' publishes the iframe (via Region-Capture cropping
|
|
* on Chromium, or whole-tab fallback elsewhere) to a SEPARATE SFU
|
|
* publisher kind (kind=game) so it coexists with a regular screen
|
|
* share. Listeners and other speakers see a gameplay tile in their
|
|
* screens-thumbs column. Only speakers can publish. */
|
|
if (canSpeak(myRole)){
|
|
const shareBtn = document.createElement('button');
|
|
shareBtn.className = 'small';
|
|
const refreshLabel = () => { shareBtn.textContent = sfuGamePC ? 'stop sharing' : 'share gameplay'; };
|
|
refreshLabel();
|
|
shareBtn.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
if (sfuGamePC){
|
|
sfuUnpublishGame().then(refreshLabel);
|
|
} else {
|
|
logLine('', 'share gameplay: pick this tab in the picker');
|
|
sfuPublishGame(iframe).then(refreshLabel).catch(()=>refreshLabel());
|
|
}
|
|
};
|
|
ctl.appendChild(shareBtn);
|
|
}
|
|
meta.appendChild(who); meta.appendChild(ctl);
|
|
big.appendChild(iframe); big.appendChild(meta);
|
|
$('spotlight').appendChild(big);
|
|
const thumb = thumbElementFor('game', pubHex);
|
|
if (thumb) thumb.classList.add('viewing');
|
|
spotlight = { kind, pubHex, tile: big, video: null };
|
|
} else {
|
|
const entry = getEntry(kind, pubHex);
|
|
if (!entry){ updateContainerVisibility(); return; }
|
|
const big = buildTile(kind, pubHex, entry.label, { thumb: false });
|
|
big.video.srcObject = entry.video.srcObject;
|
|
try { const p = big.video.play(); if (p && p.catch) p.catch(()=>{ big.tile.classList.add('needs-tap'); }); } catch(_){}
|
|
$('spotlight').appendChild(big.tile);
|
|
entry.tile.classList.add('viewing');
|
|
spotlight = { kind, pubHex, tile: big.tile, video: big.video };
|
|
}
|
|
updateContainerVisibility();
|
|
if (myUUID){
|
|
spotlights.set(myUUID, spotlightKey());
|
|
logSpotlightChange(myUUID, spotlightKey());
|
|
broadcastSpotlight();
|
|
}
|
|
reorderTiles();
|
|
}
|
|
function clearSpotlightDOM(){
|
|
const sp = $('spotlight');
|
|
while (sp.firstChild){
|
|
const v = sp.firstChild.querySelector && sp.firstChild.querySelector('video');
|
|
if (v) try { v.srcObject = null; } catch(_){}
|
|
sp.removeChild(sp.firstChild);
|
|
}
|
|
}
|
|
|
|
function pickNextSpotlight(){
|
|
/* called when the current spotlight tile is removed — prefer a screen */
|
|
for (const [pubHex] of screenVideos){ setSpotlight('screen', pubHex); return; }
|
|
for (const [pubHex] of cameraVideos){ setSpotlight('camera', pubHex); return; }
|
|
if (spotlight) clearSpotlightDOM();
|
|
spotlight = null;
|
|
updateContainerVisibility();
|
|
if (myUUID){
|
|
spotlights.set(myUUID, '');
|
|
logSpotlightChange(myUUID, '');
|
|
broadcastSpotlight();
|
|
}
|
|
}
|
|
|
|
function renderVideoTile(kind, pubHex, stream, opts){
|
|
const k = TILE_KINDS[kind]; if (!k) return;
|
|
const local = !!(opts && opts.local);
|
|
let label = shortHex(pubHex);
|
|
if (local){
|
|
label = (myHandle || label) + ' (you)';
|
|
} else {
|
|
for (const [, mm] of members){
|
|
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ label = mm.handle || label; break; } } catch(_){}
|
|
}
|
|
}
|
|
let entry = k.store.get(pubHex);
|
|
let isNew = false;
|
|
if (!entry){
|
|
isNew = true;
|
|
/* thumbnail lives in the cameras column permanently */
|
|
const built = buildTile(kind, pubHex, label, { thumb: true });
|
|
built.tile.addEventListener('click', () => {
|
|
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex) return;
|
|
setSpotlight(kind, pubHex);
|
|
});
|
|
$(k.thumbContainer).appendChild(built.tile);
|
|
entry = { tile: built.tile, video: built.video, label };
|
|
k.store.set(pubHex, entry);
|
|
}
|
|
entry.video.srcObject = stream;
|
|
try {
|
|
const p = entry.video.play();
|
|
if (p && p.then){
|
|
p.then(() => { entry.tile.classList.remove('needs-tap'); })
|
|
.catch(e => {
|
|
logLine('err','autoplay blocked: '+e.message+' — tap the tile to play');
|
|
entry.tile.classList.add('needs-tap');
|
|
});
|
|
}
|
|
} catch(e){
|
|
logLine('err','play threw: '+e.message);
|
|
entry.tile.classList.add('needs-tap');
|
|
}
|
|
/* if the same tile is currently spotlit, mirror the stream into the big tile */
|
|
if (spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex && spotlight.video){
|
|
spotlight.video.srcObject = stream;
|
|
}
|
|
/* spotlight promotion rules:
|
|
* - no spotlight yet → first tile auto-promotes (anyone)
|
|
* - listener role + new SCREEN arrives → auto-promote so the
|
|
* audience always sees the most recent share without clicking;
|
|
* speakers/hosts get to keep their current focus
|
|
* - listener role + new CAMERA → don't override an active screen
|
|
* spotlight (cameras are less load-bearing than a screen-share)
|
|
*/
|
|
if (isNew){
|
|
const isListener = !canSpeak(myRole);
|
|
if (!spotlight){
|
|
setSpotlight(kind, pubHex);
|
|
} else if (isListener && !local && kind === 'screen' && spotlight.kind !== 'screen'){
|
|
/* listener: a screen just arrived and we're spotlighting a camera
|
|
* or game — switch to the screen */
|
|
setSpotlight(kind, pubHex);
|
|
} else if (isListener && !local && kind === 'screen' && spotlight.kind === 'screen'){
|
|
/* listener: prefer the newer screen over the older one */
|
|
setSpotlight(kind, pubHex);
|
|
}
|
|
}
|
|
updateContainerVisibility();
|
|
if (isNew) logLine('', k.labelPrefix+' share: '+(local?'local preview ':'receiving ')+label);
|
|
}
|
|
function removeVideoTile(kind, pubHex){
|
|
const k = TILE_KINDS[kind]; if (!k) return;
|
|
const entry = k.store.get(pubHex);
|
|
if (!entry) return;
|
|
const wasSpotlight = spotlight && spotlight.kind === kind && spotlight.pubHex === pubHex;
|
|
try { entry.video.srcObject = null; entry.tile.remove(); } catch(_){}
|
|
k.store.delete(pubHex);
|
|
k.streams.delete(pubHex);
|
|
if (wasSpotlight){
|
|
clearSpotlightDOM();
|
|
spotlight = null;
|
|
pickNextSpotlight();
|
|
} else {
|
|
updateContainerVisibility();
|
|
}
|
|
}
|
|
/* back-compat shims — keep the old names callable so the rest of the
|
|
* file doesn't have to be rewritten in one shot */
|
|
function renderScreenTile(pubHex, stream, opts){ return renderVideoTile('screen', pubHex, stream, opts); }
|
|
function removeScreenTile(pubHex){ return removeVideoTile('screen', pubHex); }
|
|
function renderCameraTile(pubHex, stream, opts){ return renderVideoTile('camera', pubHex, stream, opts); }
|
|
function removeCameraTile(pubHex){ return removeVideoTile('camera', pubHex); }
|
|
|
|
async function sfuPublish(){
|
|
if (sfuPubPC) return; /* idempotent — re-entry from signal reconnect is fine */
|
|
if (!micStream || !myKeys || !roomID){
|
|
logLine('err','sfu publish skipped: mic='+(!!micStream)+' keys='+(!!myKeys)+' room='+(!!roomID));
|
|
return;
|
|
}
|
|
logLine('', 'sfu publish: starting (base='+SFU_BASE+')');
|
|
const pc = new RTCPeerConnection(rtcConfig);
|
|
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
|
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
|
const offer = await pc.createOffer();
|
|
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
|
|
await pc.setLocalDescription(offer);
|
|
await waitForIceGathering(pc);
|
|
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
|
});
|
|
if (res.status === 403){ pc.close(); handleBlocked('publish'); return; }
|
|
if (!res.ok){ pc.close(); throw new Error('sfu publish http '+res.status); }
|
|
const ans = await res.json();
|
|
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
|
sfuPubPC = pc; sfuPubPeerID = ans.peer_id;
|
|
watchPublishPC(pc, 'mic', sfuPublish, () => sfuPubPC === pc, () => { sfuPubPC = null; sfuPubPeerID = null; });
|
|
logLine('', 'sfu: publishing as '+sfuPubPeerID);
|
|
}
|
|
|
|
/* Publish-side ICE failure recovery. The SFU reaps a failed publisher
|
|
* (OnConnectionStateChange in the Go side calls h.removePublisher), so
|
|
* any listener that arrives during the failure window gets an SDP with
|
|
* zero m-lines and never sees audio/video until the host re-publishes.
|
|
* Bug observed: fxhp's mic/screen/camera all failed ICE; a listener
|
|
* who joined while they were down got nothing until fxhp left + rejoined.
|
|
*
|
|
* The page now auto-rebuilds: on 'failed' on any of the publish PCs we
|
|
* tear down the dead PC and call the publish entrypoint again. wasOurs
|
|
* guards against the racey case where the user explicitly stopped
|
|
* sharing between the 'failed' fire and our reaction (e.g. closing a
|
|
* screen-share). cleanup runs synchronously before the rebuild so the
|
|
* idempotent guard at the top of sfuPublishX (`if (sfuXPC) return`)
|
|
* doesn't bail us out of the recovery. */
|
|
function watchPublishPC(pc, label, rebuildFn, wasOurs, cleanup){
|
|
pc.onconnectionstatechange = () => {
|
|
if (pc.connectionState !== 'failed') return;
|
|
if (!wasOurs()) return;
|
|
logLine('err', 'sfu '+label+' publish PC failed — rebuilding');
|
|
try { pc.close(); } catch(_){}
|
|
cleanup();
|
|
rebuildFn().catch(e => logLine('err','sfu '+label+' re-publish: '+e.message));
|
|
};
|
|
}
|
|
|
|
/* ----- screen share ----- */
|
|
async function sfuPublishScreen(){
|
|
if (sfuScreenPC || !myKeys || !roomID) return;
|
|
let stream;
|
|
try {
|
|
/* broadcast-quality capture: 1080p30 video, raw stereo 48kHz audio.
|
|
* Browsers treat these as 'ideal' — if a window is smaller it downscales
|
|
* gracefully; nothing is rejected. The constraint matters for the audio
|
|
* side: without channelCount:2 + sampleRate:48000, getDisplayMedia on
|
|
* Chrome can hand back mono 16kHz, which kills music quality. */
|
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} },
|
|
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
|
|
channelCount:2, sampleRate:48000 }
|
|
});
|
|
} catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
|
|
sfuScreenStream = stream;
|
|
const vTracks = stream.getVideoTracks(), aTracks = stream.getAudioTracks();
|
|
logLine('', 'screen capture: '+vTracks.length+' video + '+aTracks.length+' audio track(s)');
|
|
if (aTracks.length === 0){
|
|
/* Firefox getDisplayMedia never captures tab/window audio — only the
|
|
* 'entire screen' source carries system audio, and only on some
|
|
* platforms. Chrome captures tab audio when the user ticks 'share
|
|
* audio' in the picker. Make the limitation visible instead of
|
|
* silently broadcasting video-only. */
|
|
const ua = navigator.userAgent;
|
|
const ff = /Firefox/.test(ua);
|
|
logLine('err','no audio captured — '+(ff
|
|
? 'Firefox getDisplayMedia ignores tab/window audio. Share "entire screen" with system audio, or stream the audio via mic music mode (toggle music mode + route the tab through your mic).'
|
|
: 'tick the "share tab/system audio" checkbox in the picker, or use music-mode mic.'));
|
|
}
|
|
const pc = new RTCPeerConnection(rtcConfig);
|
|
for (const tr of stream.getTracks()){
|
|
if (tr.kind === 'video') tr.contentHint = 'detail'; /* favour pixel fidelity over framerate */
|
|
if (tr.kind === 'audio') tr.contentHint = 'music';
|
|
pc.addTrack(tr, stream);
|
|
}
|
|
/* the user can stop the share from the browser's native "stop sharing"
|
|
* bar — propagate that into a clean unpublish */
|
|
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishScreen(); });
|
|
const offer = await pc.createOffer();
|
|
/* screen-share audio is always music-grade — system audio capture is
|
|
* what users actually broadcast, not voice */
|
|
offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true });
|
|
await pc.setLocalDescription(offer);
|
|
await waitForIceGathering(pc);
|
|
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
|
|
+ '&pub=' + myKeys.pubHex + '&kind=screen';
|
|
let res;
|
|
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
|
|
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw e; }
|
|
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; handleBlocked('publish-screen'); return; }
|
|
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw new Error('sfu publish-screen http '+res.status); }
|
|
const ans = await res.json();
|
|
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
|
sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
|
|
/* raise the RTP-level caps: 6 Mbps for video (high-detail 1080p screen),
|
|
* 256 kbps for audio (transparent stereo Opus). The codec-level cap was
|
|
* already raised via preferStereoOpus(). */
|
|
for (const s of pc.getSenders()){
|
|
if (!s.track) continue;
|
|
if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000);
|
|
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
|
|
}
|
|
watchPublishPC(pc, 'screen', sfuPublishScreen,
|
|
() => sfuScreenPC === pc,
|
|
() => { sfuScreenPC = null; sfuScreenPeerID = null; sfuScreenStream = null; });
|
|
logLine('', 'sfu: sharing screen as '+sfuScreenPeerID);
|
|
/* render a muted local preview so the publisher sees what they're
|
|
* sharing — SFU does not echo the publisher's own stream back */
|
|
renderScreenTile(myKeys.pubHex, stream, { local: true });
|
|
$('btn-screen-share').classList.add('hidden');
|
|
$('btn-screen-stop').classList.remove('hidden');
|
|
}
|
|
async function sfuUnpublishScreen(){
|
|
if (!sfuScreenPC && !sfuScreenStream) return;
|
|
const pid = sfuScreenPeerID;
|
|
if (myKeys) removeScreenTile(myKeys.pubHex);
|
|
if (sfuScreenStream){ sfuScreenStream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; }
|
|
if (sfuScreenPC){ try { sfuScreenPC.close(); } catch(_){} sfuScreenPC = null; sfuScreenPeerID = null; }
|
|
if (pid && roomID){
|
|
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
|
}
|
|
$('btn-screen-share').classList.remove('hidden');
|
|
$('btn-screen-stop').classList.add('hidden');
|
|
logLine('', 'screen share stopped');
|
|
}
|
|
|
|
/* ----- game-share publish (kind=game) — separate slot from regular
|
|
* screen-share so the two can coexist. Region Capture (Chromium) crops
|
|
* the tab capture to just the iframe element so audience members see
|
|
* the gameplay without the surrounding UI. Firefox lacks Region
|
|
* Capture so it falls back to whole-tab; the user controls what to
|
|
* share via the picker. */
|
|
async function sfuPublishGame(iframe){
|
|
if (sfuGamePC || !myKeys || !roomID) return;
|
|
let stream;
|
|
try {
|
|
const constraints = {
|
|
video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} },
|
|
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
|
|
channelCount:2, sampleRate:48000 },
|
|
};
|
|
constraints.preferCurrentTab = true; /* Chromium hint */
|
|
stream = await navigator.mediaDevices.getDisplayMedia(constraints);
|
|
} catch(e){ logLine('err','game-share cancelled: '+e.message); return; }
|
|
sfuGameStream = stream;
|
|
/* Region Capture: crop the captured video to the iframe element. Only
|
|
* works if user picked the current tab + browser supports CropTarget. */
|
|
try {
|
|
if (iframe && window.CropTarget && typeof CropTarget.fromElement === 'function'){
|
|
const cropTarget = await CropTarget.fromElement(iframe);
|
|
const videoTrack = stream.getVideoTracks()[0];
|
|
if (videoTrack && typeof videoTrack.cropTo === 'function'){
|
|
await videoTrack.cropTo(cropTarget);
|
|
logLine('', 'game-share: cropped to iframe');
|
|
}
|
|
}
|
|
} catch(e){ logLine('', 'game-share: crop failed ('+e.message+'), sharing full tab'); }
|
|
const pc = new RTCPeerConnection(rtcConfig);
|
|
for (const tr of stream.getTracks()){
|
|
if (tr.kind === 'video') tr.contentHint = 'motion';
|
|
if (tr.kind === 'audio') tr.contentHint = 'music';
|
|
pc.addTrack(tr, stream);
|
|
}
|
|
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishGame(); });
|
|
const offer = await pc.createOffer();
|
|
offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true });
|
|
await pc.setLocalDescription(offer);
|
|
await waitForIceGathering(pc);
|
|
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
|
|
+ '&pub=' + myKeys.pubHex + '&kind=game';
|
|
let res;
|
|
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
|
|
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; throw e; }
|
|
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; handleBlocked('publish-game'); return; }
|
|
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; throw new Error('sfu publish-game http '+res.status); }
|
|
const ans = await res.json();
|
|
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
|
sfuGamePC = pc; sfuGamePeerID = ans.peer_id;
|
|
for (const s of pc.getSenders()){
|
|
if (!s.track) continue;
|
|
if (s.track.kind === 'video') setSenderMaxBitrate(s, 4000000);
|
|
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
|
|
}
|
|
/* game-share doesn't auto-rebuild on 'failed' — its capture source is
|
|
* a user-selected iframe via Region Capture; the user would have to
|
|
* pick it again anyway. Just surface the failure clearly and tear
|
|
* down the dead PC so the share button comes back. */
|
|
pc.onconnectionstatechange = () => {
|
|
if (pc.connectionState !== 'failed' || sfuGamePC !== pc) return;
|
|
logLine('err', 'sfu game-share PC failed — stop and re-share to recover');
|
|
sfuUnpublishGame().catch(()=>{});
|
|
};
|
|
logLine('', 'sfu: sharing gameplay as '+sfuGamePeerID);
|
|
}
|
|
async function sfuUnpublishGame(){
|
|
if (!sfuGamePC && !sfuGameStream) return;
|
|
const pid = sfuGamePeerID;
|
|
if (sfuGameStream){ sfuGameStream.getTracks().forEach(t=>t.stop()); sfuGameStream = null; }
|
|
if (sfuGamePC){ try { sfuGamePC.close(); } catch(_){} sfuGamePC = null; sfuGamePeerID = null; }
|
|
if (pid && roomID){
|
|
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
|
}
|
|
logLine('', 'game-share stopped');
|
|
}
|
|
|
|
/* ----- camera publish (kind=camera) ----- */
|
|
async function sfuPublishCamera(){
|
|
if (sfuCameraPC || !myKeys || !roomID) return;
|
|
let stream;
|
|
/* aspectRatio:{ideal: 16/9} tells Android Chrome / Firefox to capture
|
|
* in landscape regardless of the device's current screen orientation.
|
|
* Without it the back camera in portrait delivers a portrait stream
|
|
* that the receivers then cover-crop into a square. With it the
|
|
* sensor reads out landscape and rotation no longer matters. */
|
|
const videoConstraints = {
|
|
width: { ideal: 1280 }, height: { ideal: 720 },
|
|
aspectRatio: { ideal: 16/9 },
|
|
frameRate: { ideal: 30 },
|
|
};
|
|
if (cameraDeviceId) videoConstraints.deviceId = { exact: cameraDeviceId };
|
|
try {
|
|
/* camera only — mic comes through the separate sfuPublish path so a
|
|
* speaker can choose camera-on while still using a different audio
|
|
* input (monitor source, music mode, etc) */
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
|
|
} catch(e){ logLine('err','camera open cancelled: '+e.message); return; }
|
|
sfuCameraStream = stream;
|
|
const pc = new RTCPeerConnection(rtcConfig);
|
|
for (const tr of stream.getTracks()){
|
|
if (tr.kind === 'video') tr.contentHint = 'motion'; /* face/scene cam = motion over detail */
|
|
pc.addTrack(tr, stream);
|
|
}
|
|
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishCamera(); });
|
|
await pc.setLocalDescription(await pc.createOffer());
|
|
await waitForIceGathering(pc);
|
|
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
|
|
+ '&pub=' + myKeys.pubHex + '&kind=camera';
|
|
let res;
|
|
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
|
|
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw e; }
|
|
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; handleBlocked('publish-camera'); return; }
|
|
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw new Error('sfu publish-camera http '+res.status); }
|
|
const ans = await res.json();
|
|
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
|
sfuCameraPC = pc; sfuCameraPeerID = ans.peer_id;
|
|
/* 1.5 Mbps is plenty for 720p30 face cam — keeps the screen-share
|
|
* headroom intact when both are live */
|
|
for (const s of pc.getSenders()){
|
|
if (s.track && s.track.kind === 'video') setSenderMaxBitrate(s, 1500000);
|
|
}
|
|
watchPublishPC(pc, 'camera', sfuPublishCamera,
|
|
() => sfuCameraPC === pc,
|
|
() => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; });
|
|
logLine('', 'sfu: camera on as '+sfuCameraPeerID);
|
|
renderCameraTile(myKeys.pubHex, stream, { local: true });
|
|
$('btn-camera-share').classList.add('hidden');
|
|
$('btn-camera-stop').classList.remove('hidden');
|
|
try { sessionStorage.setItem(ACTIVE_CAM_KEY, '1'); } catch(_){}
|
|
}
|
|
async function sfuUnpublishCamera(){
|
|
if (!sfuCameraPC && !sfuCameraStream) return;
|
|
const pid = sfuCameraPeerID;
|
|
if (myKeys) removeCameraTile(myKeys.pubHex);
|
|
if (sfuCameraStream){ sfuCameraStream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; }
|
|
if (sfuCameraPC){ try { sfuCameraPC.close(); } catch(_){} sfuCameraPC = null; sfuCameraPeerID = null; }
|
|
if (pid && roomID){
|
|
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
|
}
|
|
$('btn-camera-share').classList.remove('hidden');
|
|
$('btn-camera-stop').classList.add('hidden');
|
|
logLine('', 'camera off');
|
|
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
|
|
}
|
|
|
|
/* On mobile the camera sensor's natural read-out orientation is locked
|
|
* at getUserMedia() time. Rotating the phone does NOT update the encoded
|
|
* frames' orientation — listeners see whatever was captured initially,
|
|
* regardless of how the publisher is holding the device now.
|
|
*
|
|
* Fix: when the device orientation changes while we're publishing, re-
|
|
* acquire the camera so the sensor reads out in the new orientation.
|
|
* We swap the track in the existing publisher's RTCRtpSender via
|
|
* replaceTrack — no SDP renegotiation, no SFU-side supplant, no
|
|
* subscriber renegotiation. Subscribers just start receiving frames
|
|
* in the new orientation a few hundred ms later.
|
|
*
|
|
* Debounced 350ms so a fast orientation flick doesn't trigger two
|
|
* back-to-back captures. */
|
|
let cameraOrientationDebounce = null;
|
|
async function reacquireCameraForOrientation(){
|
|
if (!sfuCameraPC || !sfuCameraStream || !myKeys) return;
|
|
const constraints = {
|
|
width: { ideal: 1280 }, height: { ideal: 720 },
|
|
aspectRatio: { ideal: 16/9 },
|
|
frameRate: { ideal: 30 },
|
|
};
|
|
if (cameraDeviceId) constraints.deviceId = { exact: cameraDeviceId };
|
|
let fresh;
|
|
try { fresh = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false }); }
|
|
catch(e){ logLine('err','camera re-acquire failed: '+e.message); return; }
|
|
const newVideo = fresh.getVideoTracks()[0];
|
|
if (!newVideo){ fresh.getTracks().forEach(t=>t.stop()); return; }
|
|
newVideo.contentHint = 'motion';
|
|
newVideo.addEventListener('ended', () => { sfuUnpublishCamera(); });
|
|
/* swap the sender's track */
|
|
let swapped = false;
|
|
for (const s of sfuCameraPC.getSenders()){
|
|
if (s.track && s.track.kind === 'video'){
|
|
try { await s.replaceTrack(newVideo); swapped = true; break; }
|
|
catch(e){ logLine('err','replaceTrack: '+e.message); }
|
|
}
|
|
}
|
|
if (!swapped){ fresh.getTracks().forEach(t=>t.stop()); return; }
|
|
/* stop the old stream's tracks AFTER swap so the encoder has the
|
|
* new source ready before the old one ends */
|
|
const old = sfuCameraStream;
|
|
sfuCameraStream = fresh;
|
|
if (old) old.getTracks().forEach(t=>t.stop());
|
|
renderCameraTile(myKeys.pubHex, fresh, { local: true });
|
|
const w = newVideo.getSettings ? (newVideo.getSettings().width||'?') : '?';
|
|
const h = newVideo.getSettings ? (newVideo.getSettings().height||'?') : '?';
|
|
logLine('', 'camera re-captured for new orientation ('+w+'x'+h+')');
|
|
}
|
|
function onCameraOrientationChange(){
|
|
if (cameraOrientationDebounce) clearTimeout(cameraOrientationDebounce);
|
|
cameraOrientationDebounce = setTimeout(() => {
|
|
cameraOrientationDebounce = null;
|
|
reacquireCameraForOrientation();
|
|
}, 350);
|
|
}
|
|
if (window.screen && window.screen.orientation && window.screen.orientation.addEventListener){
|
|
window.screen.orientation.addEventListener('change', onCameraOrientationChange);
|
|
} else {
|
|
window.addEventListener('orientationchange', onCameraOrientationChange);
|
|
}
|
|
|
|
async function sfuUnpublish(){
|
|
if (!sfuPubPC) return;
|
|
const pid = sfuPubPeerID;
|
|
try { sfuPubPC.close(); } catch(_){}
|
|
sfuPubPC = null; sfuPubPeerID = null;
|
|
if (pid && roomID){
|
|
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
|
}
|
|
}
|
|
|
|
async function sfuSubscribe(){
|
|
if (sfuSubPC || !roomID) return;
|
|
const pc = new RTCPeerConnection(rtcConfig);
|
|
pc.ontrack = (ev) => {
|
|
const sid = ev.streams[0] ? ev.streams[0].id : '';
|
|
if (!sid) return;
|
|
/* streamID format (RFC 7941 compliant — Firefox enforces 1*64 token-
|
|
* chars and rejects ':'): SHORT16HEX (mic) | SHORT16HEX-screen |
|
|
* SHORT16HEX-camera. Resolve the 16-char prefix back to a member's
|
|
* full pubkey via lookup so the rest of the code keeps using full
|
|
* pubhex as identity. */
|
|
const dash = sid.indexOf('-');
|
|
let pubHex16, kind;
|
|
if (dash > 0){
|
|
pubHex16 = sid.slice(0, dash);
|
|
kind = sid.slice(dash + 1);
|
|
} else {
|
|
pubHex16 = sid;
|
|
kind = 'mic';
|
|
}
|
|
/* skip echo of our own publish — match by prefix */
|
|
if (myKeys && myKeys.pubHex.startsWith(pubHex16)) return;
|
|
/* resolve short prefix → full pubhex via member roster */
|
|
let pubHex = pubHex16;
|
|
for (const [, mm] of members){
|
|
try {
|
|
if (mm.pubkey){
|
|
const fh = hex(unb64(mm.pubkey));
|
|
if (fh.startsWith(pubHex16)){ pubHex = fh; break; }
|
|
}
|
|
} catch(_){}
|
|
}
|
|
if (kind === 'screen' || kind === 'camera' || kind === 'game'){
|
|
logLine('', 'sfu ontrack: kind=' + kind + ' pub=' + pubHex +
|
|
' track=' + ev.track.kind + ' mute=' + ev.track.muted + ' state=' + ev.track.readyState);
|
|
}
|
|
/* Stream-identity guard on removeFn: when a publisher supplants
|
|
* themselves (typical: hard refresh — new SFU peer_id same pubkey +
|
|
* kind), 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 = ev.streams[0];
|
|
screenStreams.set(pubHex, s);
|
|
renderScreenTile(pubHex, s);
|
|
watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); });
|
|
return;
|
|
}
|
|
if (kind === 'camera'){
|
|
const s = ev.streams[0];
|
|
cameraStreams.set(pubHex, s);
|
|
renderCameraTile(pubHex, s);
|
|
watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(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 = ev.streams[0];
|
|
gameStreams.set(pubHex, s);
|
|
renderVideoTile('gameshare', pubHex, s);
|
|
watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); });
|
|
return;
|
|
}
|
|
if (kind !== 'mic'){
|
|
logLine('', 'sfu: unknown kind '+kind+' from '+pubHex);
|
|
return;
|
|
}
|
|
/* mic audio — 400ms jitter-buffer target absorbs Wi-Fi peak jitter */
|
|
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
|
|
/* cache by full pubkey (already resolved above) so it survives the
|
|
* member's session uuid changing across leave/rejoin */
|
|
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
|
|
for (const [uuid, mm] of members){
|
|
try {
|
|
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
|
|
/* speakers get their peers' audio via mesh (lower latency) —
|
|
* skip the duplicate SFU mic. screens + cameras still came
|
|
* through above. */
|
|
if (canSpeak(myRole) && peers.has(uuid)) return;
|
|
attachSfuTrack(uuid, ev.streams[0]);
|
|
return;
|
|
}
|
|
} catch(_){}
|
|
}
|
|
/* no matching member yet — flushSfuStreams will attach on peer-joined */
|
|
};
|
|
/* server-initiated offer: POST /subscribe (empty body) — SFU answers with
|
|
* an SDP offer containing one m-line per current publisher. We answer it
|
|
* and POST the answer back, which completes the initial handshake. */
|
|
/* pass our pubkey as `sub` so the SFU can evict us if we're booted —
|
|
* server-side boot also calls SFU /internal/block which kicks any
|
|
* subscriber whose sub pubkey matches */
|
|
const subUrl = SFU_BASE + '/subscribe?room=' + encodeURIComponent(roomID) + '&sub=' + myKeys.pubHex;
|
|
const offerRes = await fetch(subUrl, {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'
|
|
});
|
|
if (offerRes.status === 403){ pc.close(); handleBlocked('subscribe'); return; }
|
|
if (!offerRes.ok){ pc.close(); throw new Error('sfu subscribe http '+offerRes.status); }
|
|
const offer = await offerRes.json();
|
|
await pc.setRemoteDescription({ type:'offer', sdp: offer.sdp });
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
await waitForIceGathering(pc);
|
|
const ackRes = await fetch(SFU_BASE + '/subscribe-answer?room=' + encodeURIComponent(roomID) + '&peer=' + offer.peer_id, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
|
});
|
|
if (!ackRes.ok){ pc.close(); throw new Error('sfu subscribe-answer http '+ackRes.status); }
|
|
sfuSubPC = pc; sfuSubPeerID = offer.peer_id;
|
|
/* survive laptop-lid-close / network suspend. When the OS suspends the
|
|
* browser the SFU sub PC goes 'disconnected' first (transient) then
|
|
* 'failed'. On 'failed' the existing PC is dead — RTP can never
|
|
* recover even if we resume — so we tear it down and rebuild via
|
|
* sfuUnsubscribe + sfuSubscribe. The auto-rejoin code that follows
|
|
* a hard refresh handles the WS side; this handles the SFU side. */
|
|
pc.onconnectionstatechange = () => {
|
|
/* 'failed' is terminal ICE failure (we should rebuild).
|
|
* 'closed' is OUR OWN sfuUnsubscribe() — never rebuild on that
|
|
* (Chrome fires the state change synchronously before sfuSubPC is
|
|
* null'd, which used to trigger a subscribe→close→subscribe loop
|
|
* every time anyone deliberately tore down the sub).
|
|
* 'disconnected' is transient — let WebRTC try to recover before
|
|
* we yank the rug. */
|
|
if (pc.connectionState !== 'failed') return;
|
|
if (sfuSubPC !== pc) return;
|
|
logLine('err', 'sfu sub PC failed — rebuilding');
|
|
sfuUnsubscribe().then(() => {
|
|
if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message));
|
|
});
|
|
};
|
|
/* SSE: server pushes renegotiation offers when publisher set changes.
|
|
* We answer each via POST /answer. ping events are keepalive only.
|
|
*
|
|
* Renegotiation must be SERIALISED. Each offer transitions the PC
|
|
* through have-remote-offer → stable, and if we kick off the next
|
|
* setRemoteDescription before the previous has applied its answer,
|
|
* the second one throws ('failed to set remote offer sdp: Called in
|
|
* wrong state'). When a speaker publishes mic + screen + camera in
|
|
* quick succession the SFU fires three offers in a row — without
|
|
* serialisation we drop the later ones, browser-side track set goes
|
|
* out of sync with the SFU, and existing screen/camera tiles can
|
|
* stop receiving RTP. Chain through a single promise queue. */
|
|
let renegQueue = Promise.resolve();
|
|
sfuSubEvents = new EventSource(SFU_BASE + '/events?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID);
|
|
sfuSubEvents.onmessage = (ev) => {
|
|
let m; try { m = JSON.parse(ev.data); } catch(_){ return; }
|
|
if (m.type !== 'offer' || !sfuSubPC) return;
|
|
renegQueue = renegQueue.then(async () => {
|
|
if (!sfuSubPC) return;
|
|
try {
|
|
await sfuSubPC.setRemoteDescription({ type:'offer', sdp: m.sdp });
|
|
const ans = await sfuSubPC.createAnswer();
|
|
await sfuSubPC.setLocalDescription(ans);
|
|
await waitForIceGathering(sfuSubPC);
|
|
await fetch(SFU_BASE + '/answer?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ sdp: sfuSubPC.localDescription.sdp })
|
|
});
|
|
} catch(e){ logLine('err','sfu renegotiate: '+e.message); }
|
|
});
|
|
};
|
|
sfuSubEvents.onerror = () => { /* EventSource auto-reconnects */ };
|
|
logLine('', 'sfu: subscribed as '+sfuSubPeerID);
|
|
}
|
|
|
|
async function sfuUnsubscribe(){
|
|
if (sfuSubEvents){ try { sfuSubEvents.close(); } catch(_){} sfuSubEvents = null; }
|
|
/* null out sfuSubPC FIRST, then close — Chrome fires the
|
|
* connectionstatechange handler SYNCHRONOUSLY during pc.close(), and
|
|
* the handler's `if (sfuSubPC === pc)` guard depends on the global
|
|
* already being null. Otherwise the handler thinks the close was a
|
|
* 'failed' rebuild trigger and we get a subscribe/close loop. */
|
|
const oldPC = sfuSubPC;
|
|
sfuSubPC = null;
|
|
sfuSubPeerID = null;
|
|
if (oldPC){ try { oldPC.close(); } catch(_){} }
|
|
sfuStreamsByPubHex.clear();
|
|
}
|
|
|
|
/* ==================================================================
|
|
* ephemeral TURN credentials (reused from zebra-audio model)
|
|
* ================================================================== */
|
|
const TURN_CRED_URL = new URLSearchParams(location.search).get('turncred')
|
|
|| 'https://cors-proxy.uncloseai.com/turn-cred';
|
|
let rtcConfig = { iceServers: [{ urls: ['stun:proxy.uncloseai.com:3478','stun:stun.l.google.com:19302'] }] };
|
|
async function refreshTurnCred(){
|
|
try {
|
|
const c = await (await fetch(TURN_CRED_URL, {cache:'no-store'})).json();
|
|
if (c && c.credential && c.uris) {
|
|
rtcConfig = { iceServers: [
|
|
{ urls: c.stun || ['stun:proxy.uncloseai.com:3478'] },
|
|
{ urls: c.uris, username: c.username, credential: c.credential }
|
|
] };
|
|
logLine('', 'TURN credentials fetched');
|
|
}
|
|
} catch (e) { logLine('', 'no TURN creds — direct/STUN only ('+e.message+')'); }
|
|
}
|
|
|
|
/* ==================================================================
|
|
* audio — mic acquisition + per-PC remote audio elements
|
|
*
|
|
* One mic stream local; one <audio> per remote speaker (so all speakers
|
|
* are heard concurrently). Music-mode + mid-call device switching from
|
|
* zebra-audio carries over directly: re-acquire + replaceTrack on every
|
|
* live sender (now multiple senders, one per peer).
|
|
* ================================================================== */
|
|
let micStream = null, audioCtx = null;
|
|
/* musicMode + micDeviceId declared earlier so localStorage restore can
|
|
* populate them before getMic() reads micConstraints() */
|
|
function micConstraints(){
|
|
/* music mode = high-fidelity broadcast: stereo, raw, 48kHz so we can
|
|
* push 256kbps Opus and let Opus's stereo modes carry music properly.
|
|
* voice mode stays mono + the three cleanups so speech is intelligible. */
|
|
const base = musicMode
|
|
? { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
|
|
channelCount:2, sampleRate:48000, sampleSize:16 }
|
|
: { echoCancellation:true, noiseSuppression:true, autoGainControl:true };
|
|
if (micDeviceId) base.deviceId = { exact: micDeviceId };
|
|
return base;
|
|
}
|
|
async function refreshMicList(){
|
|
try {
|
|
const devs = await navigator.mediaDevices.enumerateDevices();
|
|
const mics = devs.filter(d=>d.kind==='audioinput');
|
|
const sel = $('mic-select'); if (!sel) return;
|
|
sel.innerHTML = '';
|
|
if (!mics.length){ sel.innerHTML = '<option value="">input default</option>'; return; }
|
|
mics.forEach((m,i)=>{
|
|
const o = document.createElement('option');
|
|
/* prefix every option with 'input' so the dropdown reads as
|
|
* 'input WH-1000XM5' / 'input Built-in Mic'. We dropped the
|
|
* separate 'input' label that used to live alongside the row;
|
|
* the prefix keeps the meaning while freeing the column. */
|
|
o.value = m.deviceId; o.textContent = 'input ' + (m.label || ('microphone '+(i+1)));
|
|
sel.appendChild(o);
|
|
});
|
|
if (micDeviceId && mics.some(m=>m.deviceId===micDeviceId)) sel.value = micDeviceId;
|
|
else micDeviceId = sel.value;
|
|
} catch(e){ logLine('err','could not list inputs: '+e.message); }
|
|
}
|
|
function tagTrack(t){ if (t) t.contentHint = musicMode ? 'music' : 'speech'; }
|
|
/* Firefox sometimes ignores the EC/NS/AGC constraints at getUserMedia time
|
|
* for non-mic sources (e.g. PulseAudio monitor) and applies its default
|
|
* processing pipeline anyway. applyConstraints() after the fact tends to
|
|
* stick. Log the actual settings so we can see what the UA ended up with —
|
|
* silent disagreement between requested and effective constraints is what
|
|
* makes music-mode-on-a-monitor-source sound 'cleaned up'. */
|
|
async function enforceMicConstraints(track){
|
|
if (!track) return;
|
|
try { await track.applyConstraints(micConstraints()); } catch(e){ logLine('', 'applyConstraints rejected: '+e.message); }
|
|
try {
|
|
const s = track.getSettings();
|
|
logLine('', 'mic track settings: '+JSON.stringify({
|
|
ec: s.echoCancellation, ns: s.noiseSuppression, agc: s.autoGainControl,
|
|
ch: s.channelCount, hz: s.sampleRate, dev: (s.deviceId||'').slice(0,8)
|
|
}));
|
|
} catch(_){}
|
|
}
|
|
async function getMic(){
|
|
if (micStream) return micStream;
|
|
micStream = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
|
|
const t = micStream.getAudioTracks()[0];
|
|
tagTrack(t); await enforceMicConstraints(t);
|
|
watchMicTrack(t);
|
|
return micStream;
|
|
}
|
|
/* If the underlying mic device disappears (BT disconnect, USB unplug, OS
|
|
* audio swap), the track fires 'ended' or stops emitting samples but the
|
|
* MediaStream object stays alive — so existing RTP senders keep pointing
|
|
* at a dead track and the host goes silent until they leave + rejoin.
|
|
* Watch the track and re-acquire on the fly. */
|
|
let micReacquireInFlight = false;
|
|
function watchMicTrack(track){
|
|
if (!track) return;
|
|
track.addEventListener('ended', () => {
|
|
logLine('err', 'mic track ended — re-acquiring');
|
|
reacquireMic().catch(e => logLine('err','mic re-acquire failed: '+e.message));
|
|
});
|
|
}
|
|
async function reacquireMic(){
|
|
if (micReacquireInFlight) return;
|
|
micReacquireInFlight = true;
|
|
try {
|
|
const ns = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
|
|
const nt = ns.getAudioTracks()[0];
|
|
tagTrack(nt); nt.enabled = !muted;
|
|
await enforceMicConstraints(nt);
|
|
watchMicTrack(nt);
|
|
/* hot-swap onto every existing sender — no renegotiation needed
|
|
* since the codec / SDP didn't change, just the track behind it */
|
|
async function swap(pc){
|
|
const sender = pc.getSenders().find(s=>s.track && s.track.kind==='audio') || pc.getSenders()[0];
|
|
if (sender){ try { await sender.replaceTrack(nt); } catch(_){} }
|
|
}
|
|
for (const [, pc] of peers) await swap(pc);
|
|
if (sfuPubPC) await swap(sfuPubPC);
|
|
if (micStream) micStream.getTracks().forEach(t=>t.stop());
|
|
micStream = ns;
|
|
if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); }
|
|
logLine('', 'mic re-acquired');
|
|
} finally {
|
|
micReacquireInFlight = false;
|
|
}
|
|
}
|
|
async function setSenderBitrate(sender){
|
|
if (!sender) return;
|
|
try {
|
|
const p = sender.getParameters();
|
|
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
|
|
/* 256 kbps stereo Opus is roughly transparent for music; 40 kbps mono is
|
|
* plenty for speech */
|
|
p.encodings[0].maxBitrate = musicMode ? 256000 : 40000;
|
|
await sender.setParameters(p);
|
|
} catch(_){}
|
|
}
|
|
/* explicit bitrate setter for non-mic senders (screen video, screen audio).
|
|
* setSenderBitrate above is locked to the mic's musicMode value. */
|
|
async function setSenderMaxBitrate(sender, bps){
|
|
if (!sender) return;
|
|
try {
|
|
const p = sender.getParameters();
|
|
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
|
|
p.encodings[0].maxBitrate = bps;
|
|
await sender.setParameters(p);
|
|
} catch(_){}
|
|
}
|
|
/* munge the offer SDP so Opus negotiates stereo + a high maxaveragebitrate.
|
|
* Browsers omit stereo=1 unless they're sure the track is stereo, and the
|
|
* codec-level maxaveragebitrate cap (separate from RTP-level maxBitrate)
|
|
* has to be raised explicitly for music to actually use the headroom. */
|
|
function preferStereoOpus(sdp, maxAvgBps, opts){
|
|
/* DTX is great for voice (silence is silence) but its comfort-noise
|
|
* transitions audibly pop on continuous music signals — keep it OFF
|
|
* in music mode and ON for voice. */
|
|
const dtx = !(opts && opts.music) ? '1' : '0';
|
|
return sdp.replace(/a=fmtp:(\d+) ([^\r\n]*minptime=10[^\r\n]*)/g, (m, pt, fmtp) => {
|
|
/* useinbandfec=1: forward error correction so a single dropped
|
|
* packet doesn't audibly chop — Opus reconstructs from FEC. */
|
|
const want = {
|
|
'stereo': '1', 'sprop-stereo': '1',
|
|
'maxaveragebitrate': String(maxAvgBps),
|
|
'useinbandfec': '1', 'usedtx': dtx,
|
|
};
|
|
const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean);
|
|
const seen = new Set();
|
|
for (let i = 0; i < parts.length; i++){
|
|
const k = parts[i].split('=')[0];
|
|
seen.add(k);
|
|
if (want[k] !== undefined) parts[i] = k + '=' + want[k];
|
|
}
|
|
for (const k of Object.keys(want)) if (!seen.has(k)) parts.push(k + '=' + want[k]);
|
|
return 'a=fmtp:' + pt + ' ' + parts.join(';');
|
|
});
|
|
}
|
|
async function applyMicMode(){
|
|
/* re-acquire mic with new constraints, hot-swap onto every live sender
|
|
* (mesh peers + the SFU publish PC) */
|
|
const ns = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
|
|
const nt = ns.getAudioTracks()[0];
|
|
tagTrack(nt); nt.enabled = !muted;
|
|
await enforceMicConstraints(nt);
|
|
watchMicTrack(nt);
|
|
async function swap(pc){
|
|
const sender = pc.getSenders().find(s=>s.track && s.track.kind==='audio') || pc.getSenders()[0];
|
|
if (sender){ try { await sender.replaceTrack(nt); } catch(_){} setSenderBitrate(sender); }
|
|
}
|
|
for (const [_, pc] of peers) await swap(pc);
|
|
/* SFU PC needs a full renegotiation — replaceTrack alone doesn't change
|
|
* the negotiated Opus fmtp (stereo/maxaveragebitrate), so a mono publish
|
|
* keeps emitting mono even after we swap in a stereo track. Tear down
|
|
* and re-publish so the new SDP carries the music-mode codec params. */
|
|
if (sfuPubPC){
|
|
await sfuUnpublish();
|
|
}
|
|
if (micStream) micStream.getTracks().forEach(t=>t.stop());
|
|
micStream = ns;
|
|
if (myRole && canSpeak(myRole)){
|
|
sfuPublish().catch(e => logLine('err','sfu re-publish: '+e.message));
|
|
}
|
|
/* old analyser is now dead — rewire local meter against the fresh stream */
|
|
if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); }
|
|
}
|
|
/* per-peer meter: one analyser node + one rAF loop, keyed by uuid. The tick
|
|
* reads members.get(uuid)._meterEl fresh each frame so renderRoom can replace
|
|
* the DOM element without killing the meter. Cleanup happens when the uuid
|
|
* leaves the room (members loses the key) or stopMeter is called. */
|
|
const meterCtl = new Map(); /* uuid -> {an, buf} */
|
|
function startMeter(uuid, stream){
|
|
if (!stream || meterCtl.has(uuid)) return;
|
|
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
|
|
let src;
|
|
try { src = audioCtx.createMediaStreamSource(stream); }
|
|
catch(e){ logLine('err','meter for '+uuid+': '+e.message); return; }
|
|
const an = audioCtx.createAnalyser(); an.fftSize = 512;
|
|
src.connect(an);
|
|
const buf = new Uint8Array(an.fftSize);
|
|
meterCtl.set(uuid, { an, buf });
|
|
(function tick(){
|
|
const c = meterCtl.get(uuid);
|
|
if (!c) return;
|
|
if (!members.has(uuid)){ meterCtl.delete(uuid); return; }
|
|
c.an.getByteTimeDomainData(c.buf);
|
|
let peak=0;
|
|
for (let i=0;i<c.buf.length;i++){ const v=Math.abs(c.buf[i]-128)/128; if(v>peak)peak=v; }
|
|
const m = members.get(uuid);
|
|
if (m && m._meterEl) m._meterEl.style.width = Math.min(100, Math.round(peak*180))+'%';
|
|
requestAnimationFrame(tick);
|
|
})();
|
|
}
|
|
function stopMeter(uuid){ meterCtl.delete(uuid); }
|
|
|
|
/* ==================================================================
|
|
* room state mirror (server is source of truth, we mirror locally for
|
|
* rendering). updated by welcome / peer-joined / peer-left / state /
|
|
* role-change / peer-booted / host-promoted.
|
|
* ================================================================== */
|
|
let myUUID = '';
|
|
let myRole = ''; /* host | cohost | speaker | listener */
|
|
let roomEpoch = 0;
|
|
let roomID = '';
|
|
let members = new Map(); /* uuid -> {uuid,pubkey,handle,role,joined_at} */
|
|
let hostUUID = '';
|
|
let handraise = new Set(); /* uuids of listeners with hand raised */
|
|
let outstandingInvite = null; /* {from, epoch} when we're invited */
|
|
|
|
const peers = new Map(); /* uuid -> RTCPeerConnection (mesh among speakers) */
|
|
const remoteAudio = new Map(); /* uuid -> <audio> element */
|
|
|
|
function isMod(role){ return role==='host' || role==='cohost'; }
|
|
function canSpeak(role){ return role==='host' || role==='cohost' || role==='speaker'; }
|
|
|
|
/* ==================================================================
|
|
* signaling websocket — JSON text frames, see protocol header in
|
|
* cmd/zebra-spaces-signal/main.go
|
|
* ================================================================== */
|
|
/* ROOM MACHINES — shadow instance. Runs alongside the imperative
|
|
* logic during the FSM rollout: the existing handlers stay
|
|
* authoritative for side effects, but we ALSO send synthetic events
|
|
* here so the state machines track reality. fox can inspect via
|
|
* `roomMachines.call.state` / `roomMachines.sub.state` in DevTools
|
|
* during QA. As each imperative path is rewritten to be driven by
|
|
* the FSM, the shadowing comes out. */
|
|
const roomMachines = wireZebraMachines();
|
|
if (typeof window !== 'undefined'){ window.roomMachines = roomMachines; }
|
|
/* visible trace of every CallFSM transition so QA can correlate UI
|
|
* symptoms with state changes. Will tighten the log noise once the
|
|
* runtime takes over from the imperative handlers. */
|
|
roomMachines.call.observe(({ state, prev, ev }) => {
|
|
if (prev === null || state === prev) return;
|
|
logLine('', 'call: ' + prev + ' → ' + state + (ev && ev.type ? ' [' + ev.type + ']' : ''));
|
|
});
|
|
|
|
let ws = null, wantConnected = false, sigKey = null, sigReconnect = null;
|
|
|
|
function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); }
|
|
async function sendEncSDP(toUUID, kind, desc){
|
|
send({ type:'sdp-to', to:toUUID, kind, data: b64(await aesEncrypt(sigKey, JSON.stringify(desc))) });
|
|
}
|
|
async function sendMicState(){
|
|
if (!sigKey) return;
|
|
try { send({ type:'mic-state', data: b64(await aesEncrypt(sigKey, JSON.stringify({muted}))) }); } catch(_){}
|
|
}
|
|
|
|
function setStatus(msg, cls){ const e=$('call-status'); e.textContent=msg; e.className='status-line'+(cls?' '+cls:''); }
|
|
|
|
async function joinSpace(){
|
|
const code = $('rdv-code').value.trim();
|
|
if (!code){ setStatus('enter a rendezvous code','err'); return; }
|
|
if (!myHandle){ setStatus('pick a handle first (under "identity")','err'); $('handle').focus(); return; }
|
|
if (ws){ setStatus('already in a space — leave first',null); return; }
|
|
/* fresh attempt — clear any previous terminal-block state from an
|
|
* earlier session in a different room */
|
|
blocked = false;
|
|
hideNotice();
|
|
await refreshTurnCred();
|
|
/* mic isn't acquired here — listeners don't broadcast. We grab it
|
|
* lazily when our role becomes speaker (or we entered as host). */
|
|
roomID = await deriveSignalRoom(code);
|
|
sigKey = await deriveSignalKey(code);
|
|
wantConnected = true;
|
|
$('btn-enter').disabled = true;
|
|
setStatus('connecting…');
|
|
roomMachines.call.send('ENTER', { code, handle: myHandle });
|
|
openSignal();
|
|
}
|
|
|
|
function openSignal(){
|
|
ws = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(roomID));
|
|
ws.onopen = async () => {
|
|
/* first message: signed join. Server assigns role: host if room is
|
|
* empty/we're the recently-departed host coming back, else listener. */
|
|
const nonce = hex(crypto.getRandomValues(new Uint8Array(16)));
|
|
const sig = await signBytes(sigJoin(roomID, nonce, myKeys.pubB64, myHandle));
|
|
send({ type:'join', pubkey: myKeys.pubB64, handle: myHandle, nonce, sig });
|
|
};
|
|
ws.onclose = () => {
|
|
ws = null;
|
|
if (wantConnected){
|
|
setStatus('rendezvous dropped — reconnecting…');
|
|
if (sigReconnect) clearTimeout(sigReconnect);
|
|
sigReconnect = setTimeout(()=>{ if (wantConnected) openSignal(); }, 1500);
|
|
} else {
|
|
setStatus('left',null);
|
|
$('btn-enter').disabled = false;
|
|
}
|
|
};
|
|
ws.onerror = () => setStatus('signal error','err');
|
|
ws.onmessage = (ev) => { handleSignal(ev.data).catch(e=>logLine('err','signal: '+e.message)); };
|
|
}
|
|
|
|
async function handleSignal(raw){
|
|
let m; try { m = JSON.parse(raw); } catch(_){ return; }
|
|
switch (m.type){
|
|
case 'welcome':
|
|
{
|
|
/* a second welcome arrives whenever the signal-WS reconnects.
|
|
* If our uuid hasn't changed, the SFU + mesh are still alive —
|
|
* just refresh state + role, don't re-run the whole setup
|
|
* (that's what caused 'sfu publish: already publishing' +
|
|
* other duplicate-effect noise during reconnect cycles). */
|
|
const reentry = !!myUUID && myUUID === m.your_uuid;
|
|
myUUID = m.your_uuid; myRole = m.role; roomEpoch = m.epoch;
|
|
applyState(m.state);
|
|
roomMachines.call.send('WELCOME', { uuid: myUUID, role: myRole });
|
|
if (reentry){
|
|
logLine('', 'signal re-welcomed — role still '+myRole);
|
|
flushSfuStreams();
|
|
renderRoom();
|
|
break;
|
|
}
|
|
}
|
|
/* any tile auto-spotlit before welcome (during fast-path subscribe)
|
|
* needs to be broadcast now so the room sees our viewing state */
|
|
spotlights.set(myUUID, spotlightKey());
|
|
broadcastSpotlight();
|
|
reorderTiles();
|
|
logLine('', 'joined as '+myRole+' — uuid '+myUUID);
|
|
setStatus('connected as '+myRole, 'ok');
|
|
$('dot-call').className='dot ok';
|
|
$('btn-leave').disabled = false;
|
|
$('sec-room').classList.remove('hidden');
|
|
/* remember which space this tab is in so a hard refresh auto-rejoins.
|
|
* sessionStorage is per-tab so tab A in space X + tab B in space Y
|
|
* stay independent and clear cleanly on tab close. */
|
|
try { sessionStorage.setItem(ACTIVE_CALL_KEY, $('rdv-code').value.trim()); } catch(_){}
|
|
/* publish-resume: only meaningful for roles that can speak. If our
|
|
* role is listener (was speaker before but room demoted us on
|
|
* rejoin), drop the flags so we don't endlessly prompt. */
|
|
if (canSpeak(myRole)){
|
|
/* camera silent attempt — getUserMedia normally remembers
|
|
* per-origin permission, so the browser won't prompt. If it
|
|
* does prompt + the user denies, the flag self-clears. */
|
|
let wantCam = false;
|
|
try { wantCam = sessionStorage.getItem(ACTIVE_CAM_KEY) === '1'; } catch(_){}
|
|
if (wantCam){
|
|
setTimeout(() => {
|
|
sfuPublishCamera().catch(e => {
|
|
logLine('err', 'camera resume failed: ' + e.message);
|
|
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
|
|
});
|
|
}, 250);
|
|
}
|
|
} else {
|
|
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
|
|
}
|
|
renderShareUrl();
|
|
onRoleEntered();
|
|
renderRoom();
|
|
break;
|
|
case 'state':
|
|
roomEpoch = m.epoch; applyState(m.state); flushSfuStreams(); renderRoom(); break;
|
|
case 'peer-joined':
|
|
members.set(m.uuid, { uuid:m.uuid, pubkey:m.pubkey, handle:m.handle, role:m.role, joined_at: Date.now()/1000 });
|
|
{ const pub = pubHexFromMsg(m, 'pubkey'); logLine('', idTag(m.uuid, pub)+' joined as '+m.role); }
|
|
/* if the original host rejoined during their grace window, the room
|
|
* is rescued — drop the "space closing" status from our top bar */
|
|
if (m.role === 'host'){
|
|
hostUUID = m.uuid;
|
|
setStatus('connected as '+myRole, 'ok');
|
|
}
|
|
/* a new member may resolve a queued SFU track (e.g. host's rejoin
|
|
* race where ontrack fired before peer-joined) */
|
|
flushSfuStreams();
|
|
/* establish mesh PC if both us and them are speakers (or mods).
|
|
* present-member-offers: existing speaker offers when a new speaker
|
|
* arrives. deterministic by uuid string compare. */
|
|
if (canSpeak(myRole) && canSpeak(m.role)) connectToPeer(m.uuid, /*weOffer*/ myUUID < m.uuid);
|
|
renderRoom();
|
|
break;
|
|
case 'peer-left':
|
|
{
|
|
const left = members.get(m.uuid);
|
|
const pub = pubHexFromMsg(m, 'pubkey') || (left && left.pubkey ? pubHexFromMsg({pubkey:left.pubkey}, 'pubkey') : '');
|
|
if (left || pub) logLine('', idTag(m.uuid, pub)+' left');
|
|
/* if they were sharing screen or camera, drop the tiles */
|
|
if (left && left.pubkey){
|
|
try { const h = hex(unb64(left.pubkey)); removeScreenTile(h); removeCameraTile(h); } catch(_){}
|
|
}
|
|
members.delete(m.uuid);
|
|
handraise.delete(m.uuid);
|
|
spotlights.delete(m.uuid);
|
|
tearPeer(m.uuid);
|
|
if (hostUUID === m.uuid) hostUUID = '';
|
|
renderRoom();
|
|
reorderTiles();
|
|
}
|
|
break;
|
|
case 'sdp-from':
|
|
try { await onSDP(m.from, m.kind, await aesDecrypt(sigKey, unb64(m.data))); }
|
|
catch(e){ logLine('err','sdp from '+m.from+' failed: '+e.message); }
|
|
break;
|
|
case 'mic-state':
|
|
try { const s = JSON.parse(await aesDecrypt(sigKey, unb64(m.data)));
|
|
const mm = members.get(m.uuid); if (mm){ mm.muted = !!s.muted; renderRoom(); } } catch(_){}
|
|
break;
|
|
case 'spotlight':
|
|
/* who's looking at what — drives thumbnail popularity sort + log
|
|
* line so the room can see where attention is going */
|
|
{ const prev = spotlights.get(m.uuid) || '';
|
|
const next = m.key || '';
|
|
if (prev === next) break;
|
|
spotlights.set(m.uuid, next);
|
|
if (m.uuid !== myUUID) logSpotlightChange(m.uuid, next, pubHexFromMsg(m,'pubkey'));
|
|
reorderTiles();
|
|
}
|
|
break;
|
|
case 'hand-raised':
|
|
handraise.add(m.uuid); renderRoom();
|
|
{ const pub = pubHexFromMsg(m, 'pubkey'); logLine('', idTag(m.uuid, pub)+' raised hand'); }
|
|
break;
|
|
case 'hand-lowered':
|
|
handraise.delete(m.uuid); renderRoom();
|
|
{ const pub = pubHexFromMsg(m, 'pubkey'); logLine('', idTag(m.uuid, pub)+' lowered hand'); }
|
|
break;
|
|
case 'mic-invite':
|
|
if (m.to === myUUID){
|
|
outstandingInvite = { from:m.from, epoch:m.epoch };
|
|
const from = members.get(m.from);
|
|
$('invite-from').textContent = 'from '+(from?from.handle:m.from);
|
|
$('sec-invite').classList.remove('hidden');
|
|
const fromPub = pubHexFromMsg(m, 'from_pubkey');
|
|
logLine('', 'you were invited to the mic by '+idTag(m.from, fromPub));
|
|
} else {
|
|
const fromPub = pubHexFromMsg(m, 'from_pubkey');
|
|
const toPub = pubHexFromMsg(m, 'to_pubkey');
|
|
logLine('', idTag(m.from, fromPub)+' invited '+idTag(m.to, toPub)+' to the mic');
|
|
}
|
|
break;
|
|
case 'mic-invite-declined':
|
|
if (m.from === myUUID){
|
|
const toPub = pubHexFromMsg(m, 'to_pubkey');
|
|
logLine('', idTag(m.to, toPub)+' declined the mic');
|
|
}
|
|
break;
|
|
case 'role-change':
|
|
{
|
|
const mm = members.get(m.uuid);
|
|
if (mm){
|
|
const prev = mm.role;
|
|
mm.role = m.role;
|
|
roomEpoch = m.epoch;
|
|
if (m.role === 'host') hostUUID = m.uuid;
|
|
const subjPub = pubHexFromMsg(m, 'pubkey', 'pubkey_hex') || myKeys && (m.uuid===myUUID ? myKeys.pubHex : '');
|
|
const byPub = pubHexFromMsg(m, 'by_pubkey');
|
|
if (m.uuid === myUUID){
|
|
myRole = m.role;
|
|
roomMachines.call.send('ROLE_CHANGE', { role: m.role });
|
|
logLine('', 'you are now '+m.role+(subjPub?' ['+subjPub+']':'')+(m.by?' by '+idTag(m.by, byPub):''));
|
|
setStatus('connected as '+myRole, 'ok');
|
|
const byMod = members.get(m.by);
|
|
const byTxt = byMod ? ' by '+byMod.handle : '';
|
|
/* visible self-notification per role transition */
|
|
if (m.role === 'listener' && prev !== 'listener') showNotice('You were moved to listener'+byTxt+'. Your mic is off.', 'warn');
|
|
else if (m.role === 'speaker' && prev === 'listener') showNotice('You are now a speaker.', 'info');
|
|
else if (m.role === 'speaker' && prev === 'cohost') showNotice('You were stepped down to speaker'+byTxt+'.', 'warn');
|
|
else if (m.role === 'cohost') showNotice('You were promoted to co-host'+byTxt+'.', 'info');
|
|
else if (m.role === 'host') showNotice('You are now the host.', 'info');
|
|
onRoleChanged(prev, m.role);
|
|
} else {
|
|
logLine('', idTag(m.uuid, subjPub)+' is now '+m.role+(m.by?' by '+idTag(m.by, byPub):''));
|
|
/* mesh adjustments — fox: 'never drop people out of the mesh
|
|
* automatically'. Promote: connect if we can speak + they
|
|
* can speak + we don't already have them. Demote: do NOT
|
|
* tear existing mesh peer connections; let them ride as a
|
|
* back-channel until one side actually leaves the room. */
|
|
if (canSpeak(myRole) && canSpeak(m.role) && !peers.has(m.uuid)){
|
|
connectToPeer(m.uuid, myUUID < m.uuid);
|
|
}
|
|
}
|
|
renderRoom();
|
|
reorderTiles();
|
|
}
|
|
}
|
|
break;
|
|
case 'peer-booted':
|
|
{
|
|
const mm = members.get(m.uuid);
|
|
const by = members.get(m.by);
|
|
const victPub = pubHexFromMsg(m, 'pubkey');
|
|
const byPub = pubHexFromMsg(m, 'by_pubkey');
|
|
logLine('', idTag(m.uuid, victPub)+' was removed by '+(by||byPub?idTag(m.by, byPub):'a mod'));
|
|
if (m.uuid === myUUID){
|
|
/* server will close our socket; surface a clear notice and prevent
|
|
* the WS reconnect loop from auto-rejoining into a boot loop. Tear
|
|
* down our SFU + mesh PCs too so we actually stop hearing /
|
|
* broadcasting — closing the WS alone leaves the WebRTC paths up. */
|
|
wantConnected = false;
|
|
showNotice('You were removed from this space'+(by?' by '+by.handle:'')+'.', 'warn');
|
|
logLine('err','you were removed from this space');
|
|
for (const u of [...peers.keys()]) tearPeer(u);
|
|
sfuUnpublish().catch(()=>{});
|
|
sfuUnpublishScreen().catch(()=>{});
|
|
sfuUnpublishCamera().catch(()=>{});
|
|
sfuUnpublishGame().catch(()=>{});
|
|
sfuUnsubscribe().catch(()=>{});
|
|
dropMic();
|
|
}
|
|
members.delete(m.uuid); tearPeer(m.uuid); handraise.delete(m.uuid);
|
|
renderRoom();
|
|
}
|
|
break;
|
|
case 'host-left':
|
|
{ const pub = pubHexFromMsg(m, 'host_pubkey'); logLine('', 'host left the space — '+idTag(m.host_uuid, pub)); }
|
|
hostUUID = '';
|
|
break;
|
|
case 'host-promoted':
|
|
roomEpoch = m.epoch;
|
|
hostUUID = m.new_host_uuid;
|
|
{ const mm = members.get(m.new_host_uuid);
|
|
const pub = pubHexFromMsg(m, 'new_host_pubkey');
|
|
if (mm){ mm.role = 'host';
|
|
if (m.new_host_uuid === myUUID){ myRole = 'host'; setStatus('connected as host','ok'); onRoleChanged('cohost','host'); }
|
|
else { setStatus('connected as '+myRole, 'ok'); } /* clears the space-closing warning */
|
|
logLine('', idTag(m.new_host_uuid, pub)+' is now host'); }
|
|
flushSfuStreams();
|
|
renderRoom();
|
|
reorderTiles();
|
|
}
|
|
break;
|
|
case 'space-closing':
|
|
/* host left — 2h deadman is running. Only the host can chime to
|
|
* extend; cohosts can moderate the room during the absence but
|
|
* cannot keep the room alive. Room WILL close at grace expiry
|
|
* unless the host reclaims. */
|
|
logLine('err','space closing in '+m.grace_seconds+'s — '+m.reason);
|
|
setStatus('host left — space closing in '+m.grace_seconds+'s', 'err');
|
|
break;
|
|
case 'error':
|
|
logLine('err','signal: '+m.message);
|
|
setStatus('signal: '+m.message,'err');
|
|
/* terminal-block signal: stop the reconnect loop and tell the user */
|
|
if (/blocked/i.test(m.message)) handleBlocked('signal');
|
|
break;
|
|
}
|
|
}
|
|
|
|
function applyState(state){
|
|
members = new Map();
|
|
hostUUID = state.host_uuid || '';
|
|
for (const m of (state.members||[])) members.set(m.uuid, Object.assign({}, m));
|
|
handraise = new Set(state.handraise_queue||[]);
|
|
}
|
|
|
|
/* called once on welcome (whatever role we entered as) and on every
|
|
* role change. host/cohost/speaker need a mic; listener drops it.
|
|
*
|
|
* SFU bridge: speakers publish to the SFU (so listeners hear them);
|
|
* listeners subscribe to the SFU (so they hear the speakers). Speakers
|
|
* never subscribe — mesh gives them lower-latency audio already. */
|
|
async function onRoleEntered(){
|
|
if (canSpeak(myRole)) await ensureMicAndUI();
|
|
else updateRoleUI();
|
|
/* Everyone subscribes to the SFU. Listeners use it for mic+screen+camera.
|
|
* Speakers use it for screen+camera (and as a backup audio path); the
|
|
* SFU mic track is suppressed by the ontrack handler when we already
|
|
* have a mesh peer for that pubkey, so we don't get double audio. */
|
|
sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message));
|
|
if (canSpeak(myRole)){
|
|
for (const [uuid, mm] of members){
|
|
if (uuid === myUUID) continue;
|
|
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
|
|
}
|
|
sfuPublish().catch(e => logLine('err','sfu publish: '+e.message));
|
|
}
|
|
}
|
|
let inRoleTransition = false;
|
|
async function onRoleChanged(prev, next){
|
|
/* suppress spotlight broadcasts triggered by tile cleanup during the
|
|
* role transition — otherwise removeScreenTile / removeCameraTile
|
|
* fires pickNextSpotlight which broadcasts an empty spotlight key,
|
|
* which every other peer logs as 'X looked away'. */
|
|
inRoleTransition = true;
|
|
try {
|
|
if (!canSpeak(prev) && canSpeak(next)){
|
|
await ensureMicAndUI();
|
|
for (const [uuid, mm] of members){
|
|
if (uuid === myUUID) continue;
|
|
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
|
|
}
|
|
/* keep sfuSub alive — screen + camera tracks still ride it. The
|
|
* ontrack handler suppresses SFU mic when mesh is also up. */
|
|
sfuPublish().catch(e => logLine('err','sfu publish: '+e.message));
|
|
} else if (canSpeak(prev) && !canSpeak(next)){
|
|
/* demote to listener: drop everything we PUBLISH (mic + screen +
|
|
* camera + game) but DON'T tear mesh peers. fox: 'never drop
|
|
* people out of the mesh automatically'. The mesh connections
|
|
* stay up as a bonus low-latency audio path; they get GC'd
|
|
* naturally when the other end leaves or also demotes. */
|
|
dropMic(); muted = false;
|
|
await sfuUnpublish();
|
|
await sfuUnpublishScreen();
|
|
await sfuUnpublishCamera();
|
|
await sfuUnpublishGame();
|
|
}
|
|
updateRoleUI();
|
|
} finally {
|
|
inRoleTransition = false;
|
|
}
|
|
}
|
|
async function ensureMicAndUI(){
|
|
/* mic input + music-mode rows are always visible; here we just grant the
|
|
* mic and unblock mute. Toggling music-mode before getting a mic is fine —
|
|
* micConstraints() reads the live `musicMode` flag whenever we re-acquire. */
|
|
try {
|
|
await getMic(); await refreshMicList();
|
|
$('btn-mute').disabled = false;
|
|
/* restore the user's last mute choice. Hard refresh keeps
|
|
* sessionStorage so reconnecting in muted state is preserved
|
|
* without anyone hearing a hot-mic moment. */
|
|
applyMuteState();
|
|
if (muted) sendMicState();
|
|
if (myUUID) startMeter(myUUID, micStream);
|
|
} catch(e){ logLine('err','mic blocked: '+e.message); }
|
|
updateRoleUI();
|
|
}
|
|
function dropMic(){
|
|
if (myUUID) stopMeter(myUUID);
|
|
if (micStream){ micStream.getTracks().forEach(t=>t.stop()); micStream = null; }
|
|
$('btn-mute').disabled = true;
|
|
}
|
|
function updateScreenShareUI(){
|
|
/* the share-screen button is only available to people who can speak —
|
|
* publishing a screen via the SFU requires being a speaker anyway */
|
|
$('sec-screen-share').classList.toggle('hidden', !canSpeak(myRole));
|
|
if (!canSpeak(myRole) && (sfuScreenPC || sfuScreenStream)) sfuUnpublishScreen();
|
|
/* camera lives in the same section as screen-share — visibility is
|
|
* driven by canSpeak() above. If a role demotion lands while the
|
|
* camera is live, force it off the same way the screen share is. */
|
|
if (!canSpeak(myRole) && (sfuCameraPC || sfuCameraStream)) sfuUnpublishCamera();
|
|
if (!canSpeak(myRole) && (sfuGamePC || sfuGameStream)) sfuUnpublishGame();
|
|
}
|
|
|
|
function updateRoleUI(){
|
|
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
|
|
updateScreenShareUI();
|
|
}
|
|
|
|
/* ==================================================================
|
|
* mesh — one RTCPeerConnection per other speaker
|
|
*
|
|
* Deterministic offerer: lex-smaller uuid offers. Avoids both-offer
|
|
* collisions when two speakers arrive nearly simultaneously. Reconnect
|
|
* by tear + reconnect with the same rule, so the same side always
|
|
* drives recovery.
|
|
* ================================================================== */
|
|
async function connectToPeer(uuid, weOffer){
|
|
if (peers.has(uuid)) return;
|
|
if (!micStream){ try { await getMic(); } catch(e){ logLine('err','mic for '+uuid+': '+e.message); return; } }
|
|
const pc = new RTCPeerConnection(rtcConfig);
|
|
peers.set(uuid, pc);
|
|
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
|
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
|
pc.ontrack = (ev) => {
|
|
let a = remoteAudio.get(uuid);
|
|
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
|
|
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
|
|
/* 400ms jitter buffer — Wi-Fi peak inter-arrival is multiples of
|
|
* the smoothed jitter, so a generous buffer absorbs the bursts */
|
|
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
|
|
stopMeter(uuid); startMeter(uuid, a.srcObject);
|
|
};
|
|
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
|
|
pc.onconnectionstatechange = () => {
|
|
if (pc.connectionState === 'failed' && peers.get(uuid) === pc){
|
|
logLine('', 'peer '+uuid+' failed — reconnecting');
|
|
tearPeer(uuid);
|
|
/* let the offerer drive recovery */
|
|
setTimeout(()=>{ if (members.has(uuid) && canSpeak(members.get(uuid).role) && canSpeak(myRole))
|
|
connectToPeer(uuid, myUUID < uuid); }, 1500);
|
|
}
|
|
};
|
|
if (weOffer){
|
|
const offer = await pc.createOffer();
|
|
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
|
|
await pc.setLocalDescription(offer);
|
|
await waitForIceGathering(pc);
|
|
await sendEncSDP(uuid, 'offer', pc.localDescription);
|
|
}
|
|
}
|
|
function tearPeer(uuid){
|
|
stopMeter(uuid);
|
|
const pc = peers.get(uuid);
|
|
if (pc){ try { pc.close(); } catch(_){} peers.delete(uuid); }
|
|
const a = remoteAudio.get(uuid);
|
|
if (a){ try { a.srcObject = null; a.remove(); } catch(_){} remoteAudio.delete(uuid); }
|
|
}
|
|
function waitForIceGathering(p, timeoutMs=6000){
|
|
return new Promise(res=>{
|
|
if (p.iceGatheringState==='complete') return res();
|
|
let done=false; const fin=()=>{ if(done)return; done=true; p.removeEventListener('icegatheringstatechange',h); clearTimeout(t); res(); };
|
|
const h=()=>{ if(p.iceGatheringState==='complete') fin(); };
|
|
p.addEventListener('icegatheringstatechange',h); const t=setTimeout(fin,timeoutMs);
|
|
});
|
|
}
|
|
async function onSDP(fromUUID, kind, json){
|
|
const desc = JSON.parse(json);
|
|
let pc = peers.get(fromUUID);
|
|
if (kind === 'offer'){
|
|
/* if we had an old PC, tear it (renegotiation = fresh PC) */
|
|
if (pc){ try { pc.close(); } catch(_){} peers.delete(fromUUID); }
|
|
await connectToPeer(fromUUID, /*weOffer*/ false);
|
|
pc = peers.get(fromUUID); if (!pc) return;
|
|
await pc.setRemoteDescription(desc);
|
|
await pc.setLocalDescription(await pc.createAnswer());
|
|
await waitForIceGathering(pc);
|
|
await sendEncSDP(fromUUID, 'answer', pc.localDescription);
|
|
} else if (kind === 'answer' && pc){
|
|
await pc.setRemoteDescription(desc);
|
|
}
|
|
}
|
|
|
|
/* ==================================================================
|
|
* mod actions — signed messages sent to the server
|
|
* ================================================================== */
|
|
async function modInvite(uuid){
|
|
const sig = await signBytes(sigAction(roomID, roomEpoch, 'mic-invite', uuid));
|
|
send({ type:'mic-invite', to: uuid, epoch: roomEpoch, sig });
|
|
}
|
|
/* grant-mic: hand-raised listener doesn't need to accept — server promotes
|
|
* them directly to speaker. Use modInvite for cold (unsolicited) invites. */
|
|
async function modGrant(uuid){
|
|
const sig = await signBytes(sigAction(roomID, roomEpoch, 'grant-mic', uuid));
|
|
send({ type:'grant-mic', to: uuid, epoch: roomEpoch, sig });
|
|
}
|
|
async function modPromote(uuid){
|
|
const sig = await signBytes(sigAction(roomID, roomEpoch, 'promote', uuid, 'cohost'));
|
|
send({ type:'promote', target: uuid, to: 'cohost', epoch: roomEpoch, sig });
|
|
}
|
|
async function modDemote(uuid, to){
|
|
const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to));
|
|
send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig });
|
|
}
|
|
async function modBoot(uuid){
|
|
if (!confirm('boot this person from the space?')) return;
|
|
const sig = await signBytes(sigAction(roomID, roomEpoch, 'boot', uuid));
|
|
send({ type:'boot', target: uuid, epoch: roomEpoch, sig });
|
|
}
|
|
|
|
/* ==================================================================
|
|
* room rendering — one <div class="member"> per uuid
|
|
*
|
|
* Re-render on every state change. Members keep a stable _meterEl ref
|
|
* so the per-speaker meter survives identity (re-render replaces row
|
|
* nodes; meter() captures the element by closure and stops itself when
|
|
* its element leaves the DOM).
|
|
* ================================================================== */
|
|
const MIC_ON = '<svg viewBox="0 0 24 24" fill="none" stroke="#060" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
|
const MIC_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="#b00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
|
|
|
function rankOf(role){ return {host:4, cohost:3, speaker:2, listener:1}[role] || 0; }
|
|
|
|
function renderRoom(){
|
|
const wrap = $('members'); wrap.innerHTML = '';
|
|
/* sort host → cohosts → speakers → listeners, then by joined_at */
|
|
const arr = [...members.values()].sort((a,b)=>{
|
|
const r = rankOf(b.role) - rankOf(a.role);
|
|
return r !== 0 ? r : (a.joined_at - b.joined_at);
|
|
});
|
|
for (const m of arr){
|
|
const row = document.createElement('div'); row.className = 'member';
|
|
const badge = document.createElement('span'); badge.className = 'badge '+m.role; badge.textContent = m.role;
|
|
const handle = document.createElement('span'); handle.className = 'handle';
|
|
handle.textContent = m.handle;
|
|
if (m.uuid === myUUID){ const me=document.createElement('span'); me.className='me'; me.textContent='(you)'; handle.appendChild(me); }
|
|
const pub = document.createElement('span'); pub.className = 'pub-short'; pub.title = m.pubkey; pub.textContent = hex(unb64(m.pubkey));
|
|
const micEl = document.createElement('span'); micEl.className = 'mic';
|
|
if (canSpeak(m.role)){
|
|
micEl.innerHTML = m.muted ? MIC_OFF : MIC_ON;
|
|
} else if (handraise.has(m.uuid)){
|
|
micEl.innerHTML = '<span class="raised" title="hand raised">✋</span>';
|
|
}
|
|
const meter = document.createElement('div'); meter.className = 'meter';
|
|
const fill = document.createElement('div'); fill.className = 'meter-fill';
|
|
meter.appendChild(fill);
|
|
m._meterEl = fill;
|
|
/* mod controls — only render when we can act on this row */
|
|
const acts = document.createElement('span'); acts.className = 'mod-actions';
|
|
if (isMod(myRole) && m.uuid !== myUUID){
|
|
if (m.role === 'listener'){
|
|
const b = document.createElement('button'); b.className='small invert';
|
|
const raised = handraise.has(m.uuid);
|
|
b.textContent = raised ? 'give the mic' : 'invite mic';
|
|
b.onclick = raised
|
|
? () => modGrant(m.uuid).catch(e=>logLine('err','grant: '+e.message))
|
|
: () => modInvite(m.uuid).catch(e=>logLine('err','invite: '+e.message));
|
|
acts.appendChild(b);
|
|
}
|
|
if (m.role === 'speaker' && myRole === 'host'){
|
|
const b = document.createElement('button'); b.className='small';
|
|
b.textContent = '→ cohost'; b.onclick = () => modPromote(m.uuid).catch(e=>logLine('err','promote: '+e.message));
|
|
acts.appendChild(b);
|
|
}
|
|
if (m.role === 'speaker'){
|
|
const b = document.createElement('button'); b.className='small';
|
|
b.textContent = '→ listener'; b.onclick = () => modDemote(m.uuid, 'listener').catch(e=>logLine('err','demote: '+e.message));
|
|
acts.appendChild(b);
|
|
}
|
|
if (m.role === 'cohost' && myRole === 'host'){
|
|
const b = document.createElement('button'); b.className='small';
|
|
b.textContent = '→ speaker'; b.onclick = () => modDemote(m.uuid, 'speaker').catch(e=>logLine('err','demote: '+e.message));
|
|
acts.appendChild(b);
|
|
}
|
|
/* boot allowed against anyone except host; cohosts also can't boot cohosts (host only) */
|
|
if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){
|
|
const b = document.createElement('button'); b.className='small';
|
|
b.textContent = 'boot'; b.onclick = () => modBoot(m.uuid);
|
|
acts.appendChild(b);
|
|
}
|
|
}
|
|
row.appendChild(badge); row.appendChild(handle); row.appendChild(pub);
|
|
row.appendChild(micEl); row.appendChild(meter); row.appendChild(acts);
|
|
wrap.appendChild(row);
|
|
}
|
|
/* keep listener UI in sync */
|
|
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
|
|
$('btn-raise').classList.toggle('hidden', handraise.has(myUUID));
|
|
$('btn-lower').classList.toggle('hidden', !handraise.has(myUUID));
|
|
}
|
|
|
|
/* ==================================================================
|
|
* listener actions: raise/lower hand, accept/decline mic invite
|
|
* ================================================================== */
|
|
$('btn-raise').addEventListener('click', () => { send({ type:'raise-hand' }); });
|
|
$('btn-lower').addEventListener('click', () => { send({ type:'lower-hand' }); });
|
|
$('btn-screen-share').addEventListener('click', () => { sfuPublishScreen().catch(e => logLine('err','screen share: '+e.message)); });
|
|
$('btn-screen-stop').addEventListener('click', () => { sfuUnpublishScreen(); });
|
|
$('btn-camera-share').addEventListener('click', () => { sfuPublishCamera().catch(e => logLine('err','camera share: '+e.message)); });
|
|
$('btn-camera-stop').addEventListener('click', () => { sfuUnpublishCamera(); });
|
|
$('camera-select').addEventListener('change', async (e) => {
|
|
cameraDeviceId = e.target.value;
|
|
try { localStorage.setItem(CAM_DEV_KEY, cameraDeviceId); } catch(_){}
|
|
if (sfuCameraPC){
|
|
/* restart with the new device — getUserMedia must be re-called with
|
|
* the new deviceId; replaceTrack on a sender from a different device
|
|
* needs renegotiation anyway, so a clean restart is simpler */
|
|
await sfuUnpublishCamera();
|
|
sfuPublishCamera().catch(err => logLine('err','camera restart: '+err.message));
|
|
}
|
|
});
|
|
async function refreshCameraList(){
|
|
try {
|
|
const devs = await navigator.mediaDevices.enumerateDevices();
|
|
const cams = devs.filter(d => d.kind === 'videoinput');
|
|
const sel = $('camera-select'); if (!sel) return;
|
|
sel.innerHTML = '';
|
|
if (!cams.length){ sel.innerHTML = '<option value="">default camera</option>'; return; }
|
|
cams.forEach((c, i) => {
|
|
const o = document.createElement('option');
|
|
o.value = c.deviceId; o.textContent = c.label || ('camera '+(i+1));
|
|
sel.appendChild(o);
|
|
});
|
|
if (cameraDeviceId && cams.some(c => c.deviceId === cameraDeviceId)) sel.value = cameraDeviceId;
|
|
else cameraDeviceId = sel.value;
|
|
} catch(e){ logLine('err','could not list cameras: '+e.message); }
|
|
}
|
|
navigator.mediaDevices.addEventListener('devicechange', refreshCameraList);
|
|
refreshCameraList();
|
|
|
|
/* ==================================================================
|
|
* latency panel — polls getStats() across every live RTCPeerConnection
|
|
* and renders one row per PC with RTT (ms) + candidate-pair path kind.
|
|
*
|
|
* Architecture note: incoming media (mic, screen video, screen audio,
|
|
* camera) from every other speaker arrives over the SAME sfuSubPC, so
|
|
* 'sfu in' is one row that covers all received streams. Publishers
|
|
* have one PC per kind (sfuPubPC, sfuScreenPC, sfuCameraPC), so each
|
|
* gets its own row. Mesh peers get one row each.
|
|
* ================================================================== */
|
|
/* per-PC stats keyed by an arbitrary ID so we can compute deltas
|
|
* (packetsLost, packetsReceived) across polls — % loss is a delta over
|
|
* a delta, not a cumulative. The pcStatsPrev object holds the last poll. */
|
|
const pcStatsPrev = new Map();
|
|
async function statsForPC(pc, key){
|
|
if (!pc) return { rtt: null, path: '', lossPct: null, jitterMs: null };
|
|
try {
|
|
const stats = await pc.getStats();
|
|
let pair = null, selectedPairId = '';
|
|
stats.forEach(s => { if (s.type === 'transport' && s.selectedCandidatePairId) selectedPairId = s.selectedCandidatePairId; });
|
|
stats.forEach(s => {
|
|
if (s.type !== 'candidate-pair') return;
|
|
if (selectedPairId && s.id === selectedPairId){ pair = s; return; }
|
|
if (!pair && s.state === 'succeeded' && (s.nominated || s.selected)) pair = s;
|
|
});
|
|
if (!pair){
|
|
stats.forEach(s => { if (!pair && s.type === 'candidate-pair' && s.state === 'succeeded') pair = s; });
|
|
}
|
|
let path = '', rtt = null;
|
|
if (pair){
|
|
stats.forEach(s => { if (s.type === 'local-candidate' && s.id === pair.localCandidateId) path = s.candidateType || ''; });
|
|
if (typeof pair.currentRoundTripTime === 'number') rtt = Math.round(pair.currentRoundTripTime * 1000);
|
|
}
|
|
/* sum packetsLost + packetsReceived across inbound-rtp (subscriber
|
|
* side: many incoming streams). For publishers we use the remote
|
|
* report (remote-inbound-rtp tells the sender what its peer lost +
|
|
* the jitter at the receiver). Both directions are interesting. */
|
|
let pktsLost = 0, pktsBase = 0, jitter = 0, jitterSamples = 0;
|
|
stats.forEach(s => {
|
|
if (s.type === 'inbound-rtp' && typeof s.packetsLost === 'number'){
|
|
pktsLost += s.packetsLost;
|
|
pktsBase += (s.packetsReceived || 0) + s.packetsLost;
|
|
if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; }
|
|
} else if (s.type === 'remote-inbound-rtp' && typeof s.packetsLost === 'number'){
|
|
pktsLost += s.packetsLost;
|
|
/* remote report doesn't include packetsReceived; we use it only
|
|
* for loss when no inbound-rtp is present (publish-side PC) */
|
|
if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; }
|
|
}
|
|
});
|
|
/* convert cumulative loss/received into a delta vs last poll so the
|
|
* percentage reflects what's happening NOW, not the session lifetime */
|
|
const prev = pcStatsPrev.get(key) || { lost: 0, base: 0 };
|
|
const dLost = Math.max(0, pktsLost - prev.lost);
|
|
const dBase = Math.max(0, pktsBase - prev.base);
|
|
pcStatsPrev.set(key, { lost: pktsLost, base: pktsBase });
|
|
const lossPct = dBase > 0 ? (100 * dLost / dBase) : (dLost > 0 ? 100 : 0);
|
|
const jitterMs = jitterSamples > 0 ? Math.round(1000 * jitter / jitterSamples) : null;
|
|
return { rtt, path, lossPct, jitterMs };
|
|
} catch(_){ return { rtt: null, path: '', lossPct: null, jitterMs: null }; }
|
|
}
|
|
function rttClass(rtt){
|
|
if (rtt == null) return 'lat-na';
|
|
if (rtt < 50) return 'lat-good';
|
|
if (rtt < 150) return 'lat-mid';
|
|
return 'lat-bad';
|
|
}
|
|
function fmtRtt(rtt){ return (rtt == null) ? '—' : rtt + ' ms'; }
|
|
function fmtPath(p){
|
|
if (!p) return '';
|
|
if (p === 'host') return 'LAN';
|
|
if (p === 'srflx') return 'WAN';
|
|
if (p === 'prflx') return 'WAN';
|
|
if (p === 'relay') return 'TURN';
|
|
return p;
|
|
}
|
|
function fmtLoss(v){ return (v == null) ? '—' : (v < 0.05 ? '0%' : v.toFixed(1)+'%'); }
|
|
function fmtJitter(v){ return (v == null) ? '—' : v + 'ms'; }
|
|
function rowClass(rtt, lossPct, jitterMs){
|
|
/* worst of three signals drives the colour, so a green RTT with
|
|
* 5% loss still flags red */
|
|
let worst = 'lat-good';
|
|
const bump = c => { const order = ['lat-na','lat-good','lat-mid','lat-bad']; if (order.indexOf(c) > order.indexOf(worst)) worst = c; };
|
|
if (rtt == null) bump('lat-na');
|
|
else if (rtt < 50) bump('lat-good');
|
|
else if (rtt < 150) bump('lat-mid');
|
|
else bump('lat-bad');
|
|
if (lossPct != null){
|
|
if (lossPct < 0.5) bump('lat-good');
|
|
else if (lossPct < 2) bump('lat-mid');
|
|
else bump('lat-bad');
|
|
}
|
|
if (jitterMs != null){
|
|
if (jitterMs < 20) bump('lat-good');
|
|
else if (jitterMs < 50) bump('lat-mid');
|
|
else bump('lat-bad');
|
|
}
|
|
return worst;
|
|
}
|
|
async function refreshLatency(){
|
|
const rows = [];
|
|
/* SFU publishers — only present if you're a speaker */
|
|
if (sfuPubPC){ rows.push({ name: 'sfu mic out', pc: sfuPubPC, key: 'pub' }); }
|
|
if (sfuScreenPC){ rows.push({ name: 'sfu screen out', pc: sfuScreenPC, key: 'screen' }); }
|
|
if (sfuCameraPC){ rows.push({ name: 'sfu camera out', pc: sfuCameraPC, key: 'camera' }); }
|
|
/* SFU subscriber — carries every incoming stream from other speakers */
|
|
if (sfuSubPC){ rows.push({ name: 'sfu in (host + screen + cams)', pc: sfuSubPC, key: 'sub' }); }
|
|
/* mesh peers — one row each */
|
|
for (const [uuid, pc] of peers){
|
|
const mm = members.get(uuid);
|
|
const handle = (mm && mm.handle) || shortHex(uuid);
|
|
rows.push({ name: 'peer '+handle, pc, key: 'mesh-'+uuid });
|
|
}
|
|
const container = $('latency-rows');
|
|
if (!container) return;
|
|
if (rows.length === 0){
|
|
$('sec-latency').classList.add('hidden');
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
$('sec-latency').classList.remove('hidden');
|
|
/* gather all stats in parallel */
|
|
const data = await Promise.all(rows.map(r => statsForPC(r.pc, r.key)));
|
|
container.innerHTML = '';
|
|
for (let i = 0; i < rows.length; i++){
|
|
const { rtt, path, lossPct, jitterMs } = data[i];
|
|
const div = document.createElement('div');
|
|
div.className = 'lat-row ' + rowClass(rtt, lossPct, jitterMs);
|
|
const n = document.createElement('span'); n.className = 'lat-name'; n.textContent = rows[i].name;
|
|
const r = document.createElement('span'); r.className = 'lat-rtt'; r.textContent = fmtRtt(rtt);
|
|
const lo = document.createElement('span'); lo.className = 'lat-loss'; lo.textContent = fmtLoss(lossPct);
|
|
const j = document.createElement('span'); j.className = 'lat-jit'; j.textContent = fmtJitter(jitterMs);
|
|
const p = document.createElement('span'); p.className = 'lat-path'; p.textContent = fmtPath(path);
|
|
div.appendChild(n); div.appendChild(r); div.appendChild(lo); div.appendChild(j); div.appendChild(p);
|
|
container.appendChild(div);
|
|
}
|
|
}
|
|
setInterval(refreshLatency, 2000);
|
|
/* kick once on load so the panel doesn't show stale '—' for 2s after each join */
|
|
refreshLatency();
|
|
/* notice banner — visible callouts for events that affect you directly
|
|
* (boot, role change). Auto-clears after 8s for info; stays for warn. */
|
|
let noticeTimer = null;
|
|
function showNotice(text, kind){
|
|
if (noticeTimer){ clearTimeout(noticeTimer); noticeTimer = null; }
|
|
$('notice-text').textContent = text;
|
|
$('notice-banner').className = 'notice-banner ' + (kind || 'warn');
|
|
$('sec-notice').classList.remove('hidden');
|
|
if (kind === 'info'){
|
|
noticeTimer = setTimeout(()=>{ $('sec-notice').classList.add('hidden'); noticeTimer = null; }, 8000);
|
|
}
|
|
}
|
|
function hideNotice(){
|
|
if (noticeTimer){ clearTimeout(noticeTimer); noticeTimer = null; }
|
|
$('sec-notice').classList.add('hidden');
|
|
}
|
|
$('btn-notice-close').addEventListener('click', hideNotice);
|
|
|
|
/* terminal-block handler — called on any "blocked" signal (signal-server
|
|
* error, SFU 403). Stops every reconnect loop, surfaces a clear notice,
|
|
* and shuts the session down so the user sees the boot landed instead of
|
|
* watching their UI spin trying to rejoin a room they can't enter. */
|
|
let blocked = false;
|
|
function handleBlocked(source){
|
|
if (blocked) return;
|
|
blocked = true;
|
|
wantConnected = false;
|
|
roomMachines.call.send('BOOTED', { by: source });
|
|
/* booted = clear auto-rejoin so a refresh doesn't immediately retry */
|
|
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
|
|
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
|
|
showNotice('You are blocked from this space.', 'warn');
|
|
logLine('err','blocked ('+source+') — stopping reconnects');
|
|
setStatus('blocked from this space','err');
|
|
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
|
if (ws){ try { ws.close(); } catch(_){} ws = null; }
|
|
for (const u of [...peers.keys()]) tearPeer(u);
|
|
sfuUnpublish().catch(()=>{});
|
|
sfuUnpublishScreen().catch(()=>{});
|
|
sfuUnpublishCamera().catch(()=>{});
|
|
sfuUnsubscribe().catch(()=>{});
|
|
dropMic();
|
|
$('btn-leave').disabled = true;
|
|
$('btn-enter').disabled = false;
|
|
}
|
|
|
|
$('btn-accept-mic').addEventListener('click', () => {
|
|
if (!outstandingInvite) return;
|
|
send({ type:'accept-mic', epoch: outstandingInvite.epoch });
|
|
outstandingInvite = null;
|
|
$('sec-invite').classList.add('hidden');
|
|
});
|
|
$('btn-decline-mic').addEventListener('click', () => {
|
|
if (!outstandingInvite) return;
|
|
send({ type:'decline-mic', epoch: outstandingInvite.epoch });
|
|
outstandingInvite = null;
|
|
$('sec-invite').classList.add('hidden');
|
|
});
|
|
|
|
/* ==================================================================
|
|
* mute / mic input / music mode — same shape as zebra-audio but the
|
|
* mute applies to all live senders (we may have many).
|
|
* ================================================================== */
|
|
let muted = false;
|
|
try { muted = sessionStorage.getItem(MUTE_STATE_KEY) === '1'; } catch(_){}
|
|
function applyMuteState(){
|
|
/* keep button UI, mic-track enable, room row, and peer broadcast in
|
|
* sync. Used by both the click handler and the post-mic-acquire
|
|
* restore path (so a hard refresh comes back muted if that's how the
|
|
* user left it). Safe to call without a mic — guards each side. */
|
|
if (micStream){ micStream.getAudioTracks().forEach(t=>t.enabled=!muted); }
|
|
$('btn-mute').textContent = muted ? 'unmute' : 'mute';
|
|
$('btn-mute').className = muted ? 'invert' : '';
|
|
const mm = members.get(myUUID);
|
|
if (mm){ mm.muted = muted; renderRoom(); }
|
|
}
|
|
$('btn-mute').addEventListener('click', () => {
|
|
if (!micStream) return;
|
|
muted = !muted;
|
|
try { sessionStorage.setItem(MUTE_STATE_KEY, muted ? '1' : '0'); } catch(_){}
|
|
applyMuteState();
|
|
sendMicState();
|
|
});
|
|
$('mic-select').addEventListener('change', async (e) => {
|
|
micDeviceId = e.target.value;
|
|
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
|
|
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','input switch failed: '+err.message); await refreshMicList(); } }
|
|
});
|
|
$('music-mode').addEventListener('change', async (e) => {
|
|
musicMode = e.target.checked;
|
|
try { localStorage.setItem(MUSIC_MODE_KEY, musicMode ? '1' : '0'); } catch(_){}
|
|
logLine('', 'mic mode: '+(musicMode?'MUSIC':'VOICE'));
|
|
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','mic mode switch failed: '+err.message); } }
|
|
});
|
|
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
|
|
navigator.mediaDevices.addEventListener('devicechange', () => {
|
|
refreshMicList();
|
|
/* belt-and-suspenders for browsers that don't fire track.onended on
|
|
* device disappearance (Firefox/BT swap can leave the track in
|
|
* 'live' state but emitting silence). If our current track has
|
|
* gone dead since the last devicechange, re-acquire. */
|
|
if (micStream){
|
|
const t = micStream.getAudioTracks()[0];
|
|
if (t && (t.readyState === 'ended' || t.muted)){
|
|
reacquireMic().catch(e => logLine('err','mic re-acquire on devicechange: '+e.message));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
/* initial population so the dropdown lists the user's inputs before they
|
|
* enter a space (matches what the camera-select does). Labels are blank
|
|
* until mic permission is granted; deviceIds still populate so the user
|
|
* sees how many inputs exist. */
|
|
refreshMicList();
|
|
|
|
/* laptop-lid-close / sleep / suspend recovery: when the tab comes back
|
|
* to visible, check whether our SFU sub PC is still in a healthy state.
|
|
* Some browsers (Chromium on Linux specifically) don't fire
|
|
* connectionstatechange on suspend → resume — the PC sits silently in
|
|
* 'connected' but no RTP flows. If it's in any non-live state, force
|
|
* the rebuild. */
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState !== 'visible') return;
|
|
if (!wantConnected || !sfuSubPC) return;
|
|
/* Only rebuild on terminal failure. 'disconnected' is transient
|
|
* (WebRTC tries to recover); 'closed' never happens here because
|
|
* sfuUnsubscribe() nulls sfuSubPC before pc.close(). */
|
|
if (sfuSubPC.connectionState !== 'failed') return;
|
|
logLine('err', 'visibility back, sub PC failed — rebuilding');
|
|
sfuUnsubscribe().then(() => {
|
|
if (wantConnected && roomID) sfuSubscribe().catch(e => logLine('err','sfu re-subscribe: '+e.message));
|
|
});
|
|
});
|
|
|
|
/* ==================================================================
|
|
* leave / entry buttons
|
|
* ================================================================== */
|
|
$('btn-enter').addEventListener('click', joinSpace);
|
|
$('rdv-code').addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); joinSpace(); } });
|
|
|
|
/* ?code=… autofills the rendezvous code (used by the share URL). The code
|
|
* stays in the URL so a refresh keeps you in the same space; if you want
|
|
* to leave it cleanly, hit the leave button or close the tab. */
|
|
function renderShareUrl(){
|
|
const code = $('rdv-code').value.trim();
|
|
if (!code) return;
|
|
const u = new URL(location.href);
|
|
u.searchParams.set('code', code);
|
|
const url = u.toString();
|
|
$('share-url').value = url;
|
|
$('sec-share').classList.remove('hidden');
|
|
}
|
|
$('btn-copy-share').addEventListener('click', async () => {
|
|
const v = $('share-url').value;
|
|
try { await navigator.clipboard.writeText(v); $('btn-copy-share').textContent = 'copied'; setTimeout(()=>{ $('btn-copy-share').textContent='copy'; }, 1500); }
|
|
catch(_){ $('share-url').select(); document.execCommand('copy'); }
|
|
});
|
|
{
|
|
const c = new URLSearchParams(location.search).get('code');
|
|
if (c) $('rdv-code').value = c;
|
|
}
|
|
|
|
/* auto-rejoin after a hard refresh — sessionStorage carried the rendezvous
|
|
* code through the reload. Only fires when we have a handle AND the code,
|
|
* and the input hasn't been overridden by ?code= (which still wins so a
|
|
* fresh-share URL drops you into the linked space). Multi-tab safe because
|
|
* sessionStorage is per-tab. */
|
|
(function autoRejoin(){
|
|
let saved = '';
|
|
try { saved = sessionStorage.getItem(ACTIVE_CALL_KEY) || ''; } catch(_){}
|
|
if (!saved) return;
|
|
if (!myHandle){ logLine('', 'auto-rejoin pending: no handle yet'); return; }
|
|
/* ?code= in URL takes priority over the saved session — if they differ,
|
|
* trust the URL (user intentionally navigated to a different space) */
|
|
const urlCode = new URLSearchParams(location.search).get('code');
|
|
if (urlCode && urlCode !== saved){
|
|
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
|
|
return;
|
|
}
|
|
$('rdv-code').value = saved;
|
|
logLine('', 'auto-rejoining last space …');
|
|
setTimeout(() => { joinSpace().catch(e => logLine('err', 'auto-rejoin failed: ' + e.message)); }, 100);
|
|
})();
|
|
|
|
/* games as left-column tiles — click to spotlight, broadcast through
|
|
* the same channel cameras/screens use so the room sees the choice */
|
|
renderGamesColumn();
|
|
$('btn-leave').addEventListener('click', async () => {
|
|
wantConnected = false;
|
|
roomMachines.call.send('LEAVE');
|
|
/* explicit leave wipes the auto-rejoin state — user said 'out' */
|
|
try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){}
|
|
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){}
|
|
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
|
for (const u of [...peers.keys()]) tearPeer(u);
|
|
if (ws){ try { ws.close(); } catch(_){} ws = null; }
|
|
await sfuUnpublish(); await sfuUnpublishScreen(); await sfuUnpublishCamera(); await sfuUnsubscribe();
|
|
dropMic();
|
|
members.clear(); handraise.clear(); myUUID=''; myRole=''; hostUUID=''; outstandingInvite=null;
|
|
/* tear all video tiles regardless of source — fresh slate next time */
|
|
for (const k of [...screenVideos.keys()]) removeScreenTile(k);
|
|
for (const k of [...cameraVideos.keys()]) removeCameraTile(k);
|
|
$('sec-room').classList.add('hidden');
|
|
$('sec-invite').classList.add('hidden');
|
|
$('sec-listener-actions').classList.add('hidden');
|
|
$('sec-share').classList.add('hidden');
|
|
$('sec-screen-share').classList.add('hidden');
|
|
$('sec-spotlight').classList.add('hidden');
|
|
spotlight = null;
|
|
roomMachines.call.send('DONE');
|
|
hideNotice();
|
|
$('dot-call').className='dot warn';
|
|
setStatus('left', null);
|
|
$('btn-enter').disabled = false;
|
|
$('btn-leave').disabled = true;
|
|
$('btn-mute').disabled = true;
|
|
$('btn-mute').textContent = 'mute'; $('btn-mute').className=''; muted = false;
|
|
try { sessionStorage.removeItem(MUTE_STATE_KEY); } catch(_){}
|
|
logLine('', 'left the space');
|
|
});
|
|
|
|
logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
|
})();
|
|
</script>
|
|
|
|
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-03</span><br>
|
|
md5 <span class="stamp-md5">73eb0b99ecc23fd748c900712f9d5c98</span><br>
|
|
sha256 <span class="stamp-sha">7d33862c77c86ab4c9caa86d9244e740b21700e8e214bc97a7e3103b1283d334</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>
|