2335 lines
111 KiB
HTML
2335 lines
111 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 .timeline-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; }
|
||
/* badges */
|
||
html.theme-dark .badge.host { background: #ccc; color: #000; border-color: #ccc; }
|
||
html.theme-dark .badge.cohost { background: #666; color: #000; border-color: #666; }
|
||
html.theme-dark .badge.speaker { background: #000; color: #ccc; border-color: #ccc; }
|
||
html.theme-dark .badge.listener{ background: #000; color: #888; border-color: #444; }
|
||
html.theme-dark .pub-short { color: #777; }
|
||
html.theme-dark .handle .me { color: #6c6; }
|
||
html.theme-dark .raised { color: #f66; }
|
||
/* log */
|
||
html.theme-dark .log { background: #050505; border-color: #333; }
|
||
html.theme-dark .log-line .ts { color: #666; }
|
||
html.theme-dark .log-line.err { color: #f66; }
|
||
html.theme-dark .status-line.ok { color: #6c6; }
|
||
html.theme-dark .status-line.err { color: #f66; }
|
||
/* notice banners */
|
||
html.theme-dark .notice-banner { background: #1a1a00; border-color: #553; color: #ccc; }
|
||
html.theme-dark .notice-banner.warn { background: #1a0000; border-color: #500; color: #f66; }
|
||
html.theme-dark .notice-banner.info { background: #001a00; border-color: #050; color: #6c6; }
|
||
/* latency rows */
|
||
html.theme-dark .lat-row { border-bottom-color: #1a1a1a; }
|
||
html.theme-dark .lat-row .lat-name { color: #aaa; }
|
||
html.theme-dark .lat-row .lat-loss::before,
|
||
html.theme-dark .lat-row .lat-jit::before { color: #666; }
|
||
html.theme-dark .lat-row .lat-path { color: #666; }
|
||
html.theme-dark .lat-row.lat-good .lat-rtt, html.theme-dark .lat-row.lat-good .lat-loss, html.theme-dark .lat-row.lat-good .lat-jit { color: #6c6; }
|
||
html.theme-dark .lat-row.lat-mid .lat-rtt, html.theme-dark .lat-row.lat-mid .lat-loss, html.theme-dark .lat-row.lat-mid .lat-jit { color: #ec0; }
|
||
html.theme-dark .lat-row.lat-bad .lat-rtt, html.theme-dark .lat-row.lat-bad .lat-loss, html.theme-dark .lat-row.lat-bad .lat-jit { color: #f66; }
|
||
html.theme-dark .lat-row.lat-na .lat-rtt, html.theme-dark .lat-row.lat-na .lat-loss, html.theme-dark .lat-row.lat-na .lat-jit { color: #555; }
|
||
/* vault */
|
||
html.theme-dark .vault-panel { border-color: #444; }
|
||
/* page-integrity footer */
|
||
html.theme-dark footer { color: #555 !important; }
|
||
html.theme-dark footer span { color: #555 !important; }
|
||
html.theme-dark footer a { color: #777 !important; }
|
||
|
||
.theme-toggle, .controls-toggle {
|
||
position: fixed; top: 0.6rem; z-index: 9999;
|
||
background: #fff; color: #000; border: 1px solid #000;
|
||
padding: 0.2rem 0.55rem; font-family: monospace; font-size: 0.7rem;
|
||
cursor: pointer; line-height: 1;
|
||
}
|
||
.theme-toggle { right: 0.8rem; }
|
||
.controls-toggle { right: 4.3rem; }
|
||
.theme-toggle:hover, .controls-toggle:hover { background: #f0f0f0; }
|
||
/* two-column page on desktop: left = timeline (1fr, takes remaining
|
||
* space — hosts screen shares + the running event feed), right =
|
||
* controls (kept narrow so the screen share gets maximum pixels).
|
||
* Both columns get min-width:0 so they shrink without forcing a
|
||
* horizontal scrollbar. */
|
||
/* three columns: cameras (left) | timeline/screen (middle) | controls (right).
|
||
* Each child is pinned to its column with grid-column so any one of
|
||
* them going display:none can't cause the others to slide. Controls
|
||
* column is also clamped to ≤50vw so a very wide window doesn't let
|
||
* the side panel eat the screen-share area. */
|
||
/* Page grid switches its template based on which columns are live.
|
||
* .cameras-col.hidden (no cameras) and .page.controls-collapsed
|
||
* (hide-panel) each remove a track; the timeline reassigns its
|
||
* grid-column for each combination so it always lands in the
|
||
* remaining slot. Four states:
|
||
* cams + ctrls : 220 1fr min(360,50vw) timeline col 2
|
||
* cams : 220 1fr timeline col 2
|
||
* ctrls only : 1fr min(360,50vw) timeline col 1
|
||
* alone : 1fr timeline col 1
|
||
*/
|
||
.page {
|
||
display: grid;
|
||
grid-template-columns: 220px minmax(0, 1fr) minmax(0, min(360px, 50vw));
|
||
gap: 1.25rem;
|
||
align-items: start;
|
||
}
|
||
.cameras-col { grid-column: 1; }
|
||
.timeline { grid-column: 2; }
|
||
.controls { grid-column: 3; }
|
||
.cameras-col {
|
||
min-width: 0;
|
||
border-right: 1px solid #ddd; padding-right: 1.25rem;
|
||
display: grid;
|
||
grid-auto-rows: max-content;
|
||
gap: 0.5rem;
|
||
}
|
||
/* state: no cameras (column track gone, timeline + controls take 2-col) */
|
||
.page:has(> .cameras-col.hidden) {
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, min(360px, 50vw));
|
||
}
|
||
.page:has(> .cameras-col.hidden) > .timeline { grid-column: 1; }
|
||
.page:has(> .cameras-col.hidden) > .controls { grid-column: 2; }
|
||
|
||
/* state: controls collapsed (right column gone) */
|
||
.page.controls-collapsed > .controls { display: none; }
|
||
.page.controls-collapsed:not(:has(> .cameras-col.hidden)) {
|
||
grid-template-columns: 220px minmax(0, 1fr);
|
||
}
|
||
/* state: controls collapsed AND no cameras → timeline owns the viewport */
|
||
.page.controls-collapsed:has(> .cameras-col.hidden) {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
gap: 0;
|
||
}
|
||
.page.controls-collapsed:has(> .cameras-col.hidden) > .timeline {
|
||
grid-column: 1; border-right: none; padding-right: 0;
|
||
}
|
||
/* pre-paint pref so a 'hidden' reload doesn't flash the panel */
|
||
html.controls-collapsed-pref .page > .controls { display: none; }
|
||
html.controls-collapsed-pref .page:not(:has(> .cameras-col.hidden)) {
|
||
grid-template-columns: 220px minmax(0, 1fr);
|
||
}
|
||
html.controls-collapsed-pref .page:has(> .cameras-col.hidden) {
|
||
grid-template-columns: minmax(0, 1fr); gap: 0;
|
||
}
|
||
html.controls-collapsed-pref .page:has(> .cameras-col.hidden) > .timeline {
|
||
grid-column: 1; border-right: none; padding-right: 0;
|
||
}
|
||
.timeline {
|
||
border-right: 1px solid #ddd; padding-right: 1.25rem; min-height: 60vh;
|
||
min-width: 0; display: grid; grid-auto-rows: max-content;
|
||
position: relative;
|
||
}
|
||
.controls { min-width: 0; }
|
||
.game-tabs {
|
||
display: grid; grid-auto-flow: column; grid-auto-columns: max-content;
|
||
gap: 0.4rem; margin-bottom: 0.5rem;
|
||
}
|
||
.game-tabs button {
|
||
background: #fff; color: #000; border: 1px solid #000;
|
||
padding: 0.3rem 0.8rem; font-family: monospace; font-size: 0.8rem; cursor: pointer;
|
||
}
|
||
.game-tabs button.active { background: #000; color: #fff; }
|
||
.game-tabs button:hover:not(.active) { background: #f0f0f0; }
|
||
.timeline-frame {
|
||
min-height: 80vh; width: 100%;
|
||
border: 1px solid #ddd; background: #fff;
|
||
}
|
||
@media (max-width: 800px) {
|
||
body { padding: 1.25rem; }
|
||
/* mobile: single column, all three sections stack to grid-column:1 */
|
||
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
|
||
.controls, .timeline, .cameras-col { grid-column: 1; }
|
||
.controls { grid-row: 1; }
|
||
.timeline { grid-row: 2;
|
||
border-right: none; border-top: 1px solid #ddd;
|
||
padding-right: 0; padding-top: 1rem; min-height: 0; }
|
||
.cameras-col { grid-row: 3;
|
||
border-right: none; border-top: 1px solid #ddd;
|
||
padding-right: 0; padding-top: 1rem; }
|
||
#cameras { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
||
}
|
||
/* never let dynamically-appended <audio> sinks (one per remote
|
||
* speaker) render their default ~300px control strip — they're just
|
||
* sinks for the WebRTC track, no UI required */
|
||
audio { display: none; }
|
||
|
||
/* screen-share tiles render in the LEFT (timeline) column at full width.
|
||
* When sec-screens is shown the game tabs + iframe are hidden so the
|
||
* screen owns the column. */
|
||
#screens { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
|
||
.screen-tile {
|
||
border: 1px solid #000; background: #000; padding: 0;
|
||
display: grid; grid-auto-rows: max-content; position: relative;
|
||
}
|
||
.screen-tile video {
|
||
width: 100%; height: auto; max-height: 92vh; display: block; background: #000;
|
||
}
|
||
.screen-tile .screen-meta {
|
||
background: #111; color: #ddd; 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; }
|
||
/* sec-screens visible → push everything else in the timeline column down or hide */
|
||
.timeline:has(> #sec-screens:not(.hidden)) > .game-tabs,
|
||
.timeline:has(> #sec-screens:not(.hidden)) > #game-frame { display: none; }
|
||
|
||
/* camera tiles live in the LEFT column now — single column, stacked
|
||
* vertically. Each tile keeps a 16:9 letterbox so faces don't squash. */
|
||
#cameras { display: grid; grid-template-columns: 1fr; gap: 0.45rem; }
|
||
.camera-tile {
|
||
border: 1px solid #000; background: #000; padding: 0;
|
||
display: grid; grid-auto-rows: max-content; position: relative;
|
||
}
|
||
.camera-tile video {
|
||
width: 100%; height: auto; display: block; background: #000;
|
||
aspect-ratio: 16 / 9; object-fit: cover;
|
||
}
|
||
.camera-tile .screen-meta {
|
||
background: #111; color: #ddd; font-size: 0.7rem; padding: 0.2rem 0.4rem;
|
||
display: grid; grid-template-columns: 1fr auto; gap: 0.4rem; align-items: center;
|
||
}
|
||
/* tap-to-play overlay applies to camera tiles too */
|
||
.camera-tile .tap-play {
|
||
position: absolute; left: 0; right: 0; top: 0; bottom: 1.2rem;
|
||
display: none; place-items: center;
|
||
background: rgba(0,0,0,0.65); color: #fff; cursor: pointer;
|
||
font-family: monospace; font-size: 0.9rem; user-select: none;
|
||
}
|
||
.camera-tile.needs-tap .tap-play { display: grid; }
|
||
|
||
/* latency panel: one row per live PC, fixed-width columns so the numbers
|
||
* line up vertically as values bounce. lat-good < 50ms, mid < 150ms,
|
||
* bad ≥ 150ms — colour-tagged so the user can scan at a glance. */
|
||
.latency-rows { display: grid; gap: 0.18rem; font-size: 0.78rem; margin-bottom: 0.4rem; }
|
||
.lat-row {
|
||
display: grid; grid-template-columns: 1fr 3.6rem 2.6rem 2.6rem 2.6rem; gap: 0.35rem;
|
||
align-items: baseline; padding: 0.12rem 0;
|
||
border-bottom: 1px dotted #eee; font-family: monospace;
|
||
}
|
||
.lat-row .lat-name { color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.lat-row .lat-rtt, .lat-row .lat-loss, .lat-row .lat-jit { text-align: right; font-weight: bold; }
|
||
.lat-row .lat-loss::before { content: 'loss '; color: #888; font-weight: normal; font-size: 0.65rem; }
|
||
.lat-row .lat-jit::before { content: 'jit '; color: #888; font-weight: normal; font-size: 0.65rem; }
|
||
.lat-row .lat-path { text-align: right; color: #888; font-size: 0.7rem; text-transform: uppercase; }
|
||
.lat-row.lat-good .lat-rtt, .lat-row.lat-good .lat-loss, .lat-row.lat-good .lat-jit { color: #060; }
|
||
.lat-row.lat-mid .lat-rtt, .lat-row.lat-mid .lat-loss, .lat-row.lat-mid .lat-jit { color: #b80; }
|
||
.lat-row.lat-bad .lat-rtt, .lat-row.lat-bad .lat-loss, .lat-row.lat-bad .lat-jit { color: #b00; }
|
||
.lat-row.lat-na .lat-rtt, .lat-row.lat-na .lat-loss, .lat-row.lat-na .lat-jit { color: #999; }
|
||
|
||
h1 {
|
||
font-family: 'chunkfiveregular', serif;
|
||
font-size: 3rem; font-weight: normal; letter-spacing: 0.02em;
|
||
line-height: 1; margin-bottom: 0.2rem;
|
||
}
|
||
.sub {
|
||
font-size: 0.75rem; color: #555; margin-bottom: 2rem;
|
||
letter-spacing: 0.05em; text-transform: uppercase;
|
||
}
|
||
.sub a { color: #555; }
|
||
h2 {
|
||
font-family: 'chunkfiveregular', serif; font-size: 1.1rem; font-weight: normal;
|
||
border-bottom: 1px solid #000; padding-bottom: 0.25rem; margin-bottom: 0.8rem;
|
||
}
|
||
section { margin-bottom: 1.6rem; }
|
||
button {
|
||
background: #fff; color: #000; border: 1px solid #000;
|
||
padding: 0.4rem 0.9rem; font-family: monospace; font-size: 0.85rem; cursor: pointer;
|
||
}
|
||
button:hover:not(:disabled) { background: #f0f0f0; }
|
||
button:disabled { opacity: 0.3; cursor: default; }
|
||
button.invert { background: #000; color: #fff; }
|
||
button.invert:hover:not(:disabled) { background: #333; }
|
||
button.small { padding: 0.2rem 0.55rem; font-size: 0.75rem; }
|
||
/* form-row style guide — every form row is a grid.
|
||
* default children pack at max-content width, no stretch
|
||
* <something> <input/select> ... → template "auto 1fr ..." so the
|
||
* input cell grows
|
||
* <input/select> ... → template "1fr ..." so the field fills,
|
||
* packed siblings follow
|
||
* any <span.note> child → moved to its own row inside the grid so
|
||
* long text never competes with the
|
||
* buttons it explains
|
||
* Items beyond the templated cells flow via grid-auto-columns:max-content. */
|
||
.row {
|
||
display: grid;
|
||
grid-auto-flow: column;
|
||
/* minmax(0,max-content) so a long single-child label (e.g. the
|
||
* 'music mode — …' description) can shrink and wrap inside the
|
||
* controls column instead of overflowing horizontally */
|
||
grid-auto-columns: minmax(0, max-content);
|
||
align-items: center; gap: 0.6rem; margin-bottom: 0.6rem;
|
||
min-width: 0;
|
||
}
|
||
.row:has(> :first-child + input[type=text]),
|
||
.row:has(> :first-child + input[type=password]),
|
||
.row:has(> :first-child + select) {
|
||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
||
}
|
||
.row:has(> input[type=text]:first-child),
|
||
.row:has(> input[type=password]:first-child),
|
||
.row:has(> select:first-child) {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
}
|
||
/* notes inside a row drop to their own line under the buttons/inputs
|
||
* instead of fighting them for horizontal space */
|
||
.row > .note {
|
||
grid-column: 1 / -1;
|
||
margin-top: 0.05rem; line-height: 1.4;
|
||
}
|
||
/* labels wrapping a checkbox + long text break naturally */
|
||
.row > label { white-space: normal; }
|
||
input[type=text], input[type=password] {
|
||
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
|
||
padding: 0.4rem; background: #fff; color: #000; min-width: 0; width: 100%;
|
||
}
|
||
textarea {
|
||
font-family: monospace; font-size: 0.75rem; border: 1px solid #000;
|
||
padding: 0.4rem; background: #fff; color: #000; width: 100%; min-height: 4rem;
|
||
word-break: break-all;
|
||
}
|
||
select {
|
||
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
|
||
padding: 0.4rem; background: #fff; color: #000; min-width: 0; width: 100%; cursor: pointer;
|
||
}
|
||
.dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #000; background: #fff; }
|
||
.dot.ok { background: #060; border-color: #060; }
|
||
.dot.warn { background: #888; }
|
||
.status-line { font-size: 0.8rem; color: #555; }
|
||
.status-line.ok { color: #060; }
|
||
.status-line.err { color: #b00; }
|
||
.note { font-size: 0.75rem; color: #555; line-height: 1.5; }
|
||
.meter { height: 8px; border: 1px solid #000; background: #fff; position: relative; overflow: hidden; }
|
||
.meter-fill { height: 100%; background: #000; width: 0%; transition: width 0.06s linear; }
|
||
/* member rows: badge | handle | pubkey short | mic | meter — mod actions
|
||
* wrap to a 2nd row underneath, indented under the handle */
|
||
.member {
|
||
display: grid;
|
||
grid-template-columns: 5.5rem 1fr 4rem 1.4rem 1fr;
|
||
align-items: center; gap: 0.55rem; padding: 0.35rem 0;
|
||
border-bottom: 1px dotted #ccc; font-size: 0.85rem;
|
||
}
|
||
.member:last-child { border-bottom: none; }
|
||
.badge {
|
||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||
padding: 0.1rem 0.4rem; border: 1px solid #000; text-align: center;
|
||
}
|
||
.badge.host { background: #000; color: #fff; }
|
||
.badge.cohost { background: #444; color: #fff; }
|
||
.badge.speaker { background: #fff; color: #000; }
|
||
.badge.listener{ background: #fff; color: #888; border-color: #888; }
|
||
.handle { font-weight: bold; word-break: break-all; }
|
||
.handle .me { color: #060; font-weight: normal; font-size: 0.7rem; margin-left: 0.3rem; }
|
||
.pub-short { font-size: 0.7rem; color: #888; }
|
||
.raised { color: #b00; font-weight: bold; }
|
||
.mic { width: 18px; height: 18px; }
|
||
.mic svg { width: 18px; height: 18px; display: block; }
|
||
.mod-actions {
|
||
display: grid; grid-auto-flow: column; grid-auto-columns: max-content;
|
||
gap: 0.3rem;
|
||
grid-column: 1 / -1; /* second row, full width */
|
||
justify-content: start;
|
||
padding-left: 6.05rem; /* line up under the handle (badge col + gap) */
|
||
margin-top: 0.2rem;
|
||
}
|
||
.mod-actions:empty { display: none; } /* no row gap when nothing to do */
|
||
.log { border: 1px solid #000; height: 150px; overflow-y: auto; padding: 0.5rem; font-size: 0.75rem; line-height: 1.5; background: #fafafa; }
|
||
.log-line { margin-bottom: 0.2rem; word-wrap: break-word; }
|
||
.log-line .ts { color: #888; }
|
||
.log-line.err { color: #b00; }
|
||
.invite-banner {
|
||
border: 2px solid #000; padding: 0.8rem; margin-bottom: 1rem; background: #ffd;
|
||
display: grid; grid-auto-rows: max-content; gap: 0.6rem; justify-items: start;
|
||
}
|
||
.invite-banner .invite-actions {
|
||
display: grid; grid-auto-flow: column; grid-auto-columns: max-content; gap: 0.5rem;
|
||
}
|
||
.notice-banner {
|
||
padding: 0.7rem 0.8rem; margin-bottom: 1rem;
|
||
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
|
||
gap: 0.6rem; font-size: 0.85rem;
|
||
}
|
||
.notice-banner.warn { border: 2px solid #b00; background: #fee; color: #b00; }
|
||
.notice-banner.info { border: 2px solid #060; background: #efe; color: #060; }
|
||
.notice-banner button { margin-left: auto; }
|
||
.vault-panel { border: 1px dashed #000; padding: 0.7rem; margin-top: 0.5rem; }
|
||
.hidden { display: none !important; }
|
||
@media (max-width: 500px) {
|
||
.member { grid-template-columns: 5rem 1fr 1.4rem; }
|
||
.member .pub-short, .member .meter { display: none; }
|
||
.mod-actions { padding-left: 5.55rem; }
|
||
}
|
||
</style>
|
||
<script>
|
||
/* apply theme before first paint to avoid a flash of the wrong theme.
|
||
* Default IS dark — only the explicit 'light' choice opts out, so a
|
||
* first-time visitor lands in dark without a flash. One unified key
|
||
* across every zebra page so the preference follows the user. */
|
||
try {
|
||
var pref = localStorage.getItem('zebra-theme-v1');
|
||
if (!pref) pref = localStorage.getItem('zebra-spaces-theme-v1'); // legacy migration
|
||
if (pref !== 'light'){
|
||
document.documentElement.classList.add('theme-dark');
|
||
}
|
||
} catch(_){ document.documentElement.classList.add('theme-dark'); }
|
||
/* sidebar-collapsed pre-paint: apply to <html> so the .page child can
|
||
* read it via :has() or via a body-level class added on DOMContentLoaded */
|
||
try {
|
||
if (localStorage.getItem('zebra-spaces-controls-v1') === 'hidden'){
|
||
document.documentElement.classList.add('controls-collapsed-pref');
|
||
}
|
||
} catch(_){}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<button id="btn-theme" class="theme-toggle" type="button" aria-label="toggle dark mode">dark</button>
|
||
<button id="btn-controls-toggle" class="controls-toggle" type="button" aria-label="hide side panel" title="hide right side panel">hide panel</button>
|
||
|
||
<div class="page">
|
||
|
||
<!-- left column: cameras stacked vertically so face-cams stay visible
|
||
during a screen share without dwarfing the screen itself. Hidden
|
||
when nobody has a camera on. -->
|
||
<aside id="sec-cameras" class="cameras-col hidden">
|
||
<h2>cameras</h2>
|
||
<div id="cameras"></div>
|
||
</aside>
|
||
|
||
<main class="timeline">
|
||
<!-- shared-screen tiles take priority over the game iframe. When sec-screens
|
||
is visible (someone is sharing) the game tabs + iframe get hidden via
|
||
CSS so the screen has the full middle column. -->
|
||
<section id="sec-screens" class="hidden">
|
||
<h2>screens</h2>
|
||
<div id="screens"></div>
|
||
</section>
|
||
|
||
<!-- placeholder content until the v0.3 event timeline lands: a game switcher
|
||
framing an external playable. Iframes load unsandboxed so the game JS can
|
||
run; if a target refuses framing (X-Frame-Options / CSP frame-ancestors)
|
||
the iframe goes blank — no impact on the controls column. -->
|
||
<div class="game-tabs" id="game-tabs">
|
||
<button type="button" class="active" data-game="unmario"
|
||
data-src="https://unmario.com/">unmario</button>
|
||
<button type="button" data-game="cake"
|
||
data-src="https://cuppcb.com/games/cake-murder-adventure/">cake murder adventure</button>
|
||
</div>
|
||
<iframe id="game-frame" class="timeline-frame"
|
||
src="https://unmario.com/"
|
||
title="game"
|
||
loading="lazy"
|
||
referrerpolicy="no-referrer"></iframe>
|
||
</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 key</button>
|
||
<button id="btn-logout" class="small">log out</button>
|
||
<span class="note">key lives in this browser only.</span>
|
||
</div>
|
||
<div id="vault-panel" class="vault-panel hidden">
|
||
<p class="note" style="margin-bottom:0.5rem"><strong>backup</strong> — pick a password; you'll get a text blob to save.</p>
|
||
<div class="row">
|
||
<input type="password" id="vault-pass" placeholder="password" autocomplete="new-password">
|
||
<button id="btn-vault-backup" class="small">export</button>
|
||
</div>
|
||
<p class="note" style="margin:0.6rem 0 0.4rem"><strong>restore</strong> — paste a backup blob + its password. <em>overwrites your current identity.</em></p>
|
||
<textarea id="vault-blob" placeholder="zspc-id-v1|…"></textarea>
|
||
<div class="row" style="margin-top:0.4rem">
|
||
<input type="password" id="vault-pass-restore" placeholder="password" autocomplete="current-password">
|
||
<button id="btn-vault-restore" class="small">import</button>
|
||
</div>
|
||
<div class="row" style="margin-top:0.4rem">
|
||
<span class="dot" id="vault-dot"></span>
|
||
<span id="vault-status" class="status-line">idle</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="sec-call">
|
||
<h2>space</h2>
|
||
<div class="row">
|
||
<input type="text" id="rdv-code" placeholder="rendezvous code — same string for everyone in the space">
|
||
<button id="btn-enter" class="invert">enter</button>
|
||
</div>
|
||
<div class="row">
|
||
<span class="dot warn" id="dot-call"></span>
|
||
<span id="call-status" class="status-line">idle</span>
|
||
<button id="btn-mute" disabled>mute</button>
|
||
<button id="btn-leave" disabled>leave</button>
|
||
</div>
|
||
<div class="row" id="row-mic-controls">
|
||
<label class="note" for="mic-select" style="flex:0 0 3rem">input</label>
|
||
<select id="mic-select" title="audio input device — applies once you have the mic"><option value="">default microphone</option></select>
|
||
</div>
|
||
<div class="row" id="row-music-mode">
|
||
<label class="note"><input type="checkbox" id="music-mode"> music mode — raw mic, no echo/noise cancellation (for playing audio through it)</label>
|
||
</div>
|
||
<p class="note">join as listener; host promotes to mic. end-to-end encrypted.</p>
|
||
</section>
|
||
|
||
<section id="sec-invite" class="hidden">
|
||
<div class="invite-banner">
|
||
<div>
|
||
<strong>you're invited to the mic.</strong>
|
||
<span id="invite-from" class="note"></span>
|
||
</div>
|
||
<div class="invite-actions">
|
||
<button id="btn-accept-mic" class="invert small">accept</button>
|
||
<button id="btn-decline-mic" class="small">decline</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="sec-notice" class="hidden">
|
||
<div id="notice-banner" class="notice-banner warn">
|
||
<span id="notice-text"></span>
|
||
<button id="btn-notice-close" class="small">dismiss</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="sec-room" class="hidden">
|
||
<h2>room</h2>
|
||
<div id="members"></div>
|
||
</section>
|
||
|
||
<section id="sec-screen-share" class="hidden">
|
||
<h2>share</h2>
|
||
<div class="row">
|
||
<button id="btn-screen-share" class="invert">share screen</button>
|
||
<button id="btn-screen-stop" class="hidden">stop sharing</button>
|
||
<span class="note">window or tab; tick "share audio" if offered.</span>
|
||
</div>
|
||
<div class="row" style="margin-top:0.4rem">
|
||
<button id="btn-camera-share" class="invert">share camera</button>
|
||
<button id="btn-camera-stop" class="hidden">stop camera</button>
|
||
<select id="camera-select" title="camera device — applies on next start"><option value="">default camera</option></select>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="sec-listener-actions" class="hidden">
|
||
<h2>your hand</h2>
|
||
<div class="row">
|
||
<button id="btn-raise" class="invert">raise hand</button>
|
||
<button id="btn-lower" class="hidden">lower hand</button>
|
||
<span id="hand-status" class="note">tap "raise hand" to ask a mod for the mic.</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="sec-latency" class="hidden">
|
||
<h2>latency</h2>
|
||
<div id="latency-rows" class="latency-rows"></div>
|
||
<span class="note">RTT from <code>candidate-pair.currentRoundTripTime</code> + path kind (host = LAN, srflx = direct WAN, relay = through TURN). Updates every 2s.</span>
|
||
</section>
|
||
|
||
<section id="sec-share" class="hidden">
|
||
<h2>link</h2>
|
||
<p class="note" style="margin-bottom:0.5rem">share only with people you trust to hear the room.</p>
|
||
<div class="row">
|
||
<input type="text" id="share-url" readonly>
|
||
<button id="btn-copy-share" class="small">copy</button>
|
||
</div>
|
||
<div id="qr-host" class="row" style="display:none">
|
||
<canvas id="share-qr" width="220" height="220" aria-label="QR code for this space"></canvas>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>log</h2>
|
||
<div class="log" id="log"></div>
|
||
</section>
|
||
|
||
</aside>
|
||
</div>
|
||
|
||
<script>
|
||
(async () => {
|
||
const $ = (id) => document.getElementById(id);
|
||
function b64(buf){ const u8=buf instanceof Uint8Array?buf:new Uint8Array(buf); let s=''; for(const b of u8) s+=String.fromCharCode(b); return btoa(s); }
|
||
function unb64(s){ return Uint8Array.from(atob(s), c=>c.charCodeAt(0)); }
|
||
function hex(buf){ const u8=buf instanceof Uint8Array?buf:new Uint8Array(buf); return Array.from(u8).map(b=>b.toString(16).padStart(2,'0')).join(''); }
|
||
function shortHex(s){ return s.slice(0,4)+'…'+s.slice(-4); }
|
||
|
||
const logEl = $('log');
|
||
function logLine(kind, msg){
|
||
const d=document.createElement('div'); d.className='log-line '+(kind||'');
|
||
d.innerHTML='<span class="ts">'+new Date().toLocaleTimeString()+'</span> '+msg.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
|
||
logEl.appendChild(d); logEl.scrollTop=logEl.scrollHeight;
|
||
}
|
||
|
||
/* ==================================================================
|
||
* 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';
|
||
|
||
/* theme toggle. The class is already applied pre-paint by the head
|
||
* script, so this just wires the click + keeps the button label in
|
||
* sync with the current state. */
|
||
function applyThemeLabel(){
|
||
const dark = document.documentElement.classList.contains('theme-dark');
|
||
const btn = $('btn-theme'); if (btn) btn.textContent = dark ? 'light' : 'dark';
|
||
}
|
||
applyThemeLabel();
|
||
$('btn-theme').addEventListener('click', () => {
|
||
const root = document.documentElement;
|
||
const goDark = !root.classList.contains('theme-dark');
|
||
root.classList.toggle('theme-dark', goDark);
|
||
try { localStorage.setItem(THEME_KEY, goDark ? 'dark' : 'light'); } catch(_){}
|
||
applyThemeLabel();
|
||
});
|
||
|
||
/* controls-panel collapse — promotes the pre-paint <html> pref onto the
|
||
* .page element, then keeps both in sync on click. Pre-paint class on
|
||
* <html> stays so a fresh load with a saved 'hidden' pref still shows
|
||
* the layout collapsed instantly. */
|
||
const CONTROLS_KEY = 'zebra-spaces-controls-v1';
|
||
function applyControlsLabel(){
|
||
const hidden = document.querySelector('.page').classList.contains('controls-collapsed');
|
||
const btn = $('btn-controls-toggle'); if (btn) btn.textContent = hidden ? 'show panel' : 'hide panel';
|
||
}
|
||
if (document.documentElement.classList.contains('controls-collapsed-pref')){
|
||
document.querySelector('.page').classList.add('controls-collapsed');
|
||
}
|
||
applyControlsLabel();
|
||
$('btn-controls-toggle').addEventListener('click', () => {
|
||
const page = document.querySelector('.page');
|
||
const goHidden = !page.classList.contains('controls-collapsed');
|
||
page.classList.toggle('controls-collapsed', goHidden);
|
||
document.documentElement.classList.toggle('controls-collapsed-pref', goHidden);
|
||
try { localStorage.setItem(CONTROLS_KEY, goHidden ? 'hidden' : 'shown'); } catch(_){}
|
||
applyControlsLabel();
|
||
});
|
||
const VAULT_PREFIX = 'zspc-id-v1|';
|
||
const PBKDF2_ITER = 600000;
|
||
|
||
let myKeys = null; /* { privateKey, publicKey, pubB64, pubHex } */
|
||
let myHandle = '';
|
||
|
||
async function generateIdentity(){
|
||
const kp = await crypto.subtle.generateKey({ name:'Ed25519' }, true, ['sign','verify']);
|
||
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
|
||
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
|
||
localStorage.setItem(ID_KEY, JSON.stringify(jwk));
|
||
return packKeys(kp.privateKey, kp.publicKey, raw);
|
||
}
|
||
function packKeys(priv, pub, rawPub){
|
||
const pubB64 = b64(rawPub), pubHex = hex(rawPub);
|
||
return { privateKey: priv, publicKey: pub, pubB64, pubHex };
|
||
}
|
||
async function loadOrCreateIdentity(){
|
||
const stored = localStorage.getItem(ID_KEY);
|
||
if (!stored){ myKeys = await generateIdentity(); logLine('','new identity created'); return; }
|
||
try {
|
||
const jwk = JSON.parse(stored);
|
||
const priv = await crypto.subtle.importKey('jwk', jwk, { name:'Ed25519' }, true, ['sign']);
|
||
/* derive pubkey JWK from the priv JWK so we can importKey for raw export */
|
||
const pubJwk = { kty:jwk.kty, crv:jwk.crv, x:jwk.x };
|
||
const pub = await crypto.subtle.importKey('jwk', pubJwk, { name:'Ed25519' }, true, ['verify']);
|
||
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pub));
|
||
myKeys = packKeys(priv, pub, raw);
|
||
} catch(e){
|
||
logLine('err','stored identity unreadable, generating new one: '+e.message);
|
||
myKeys = await generateIdentity();
|
||
}
|
||
}
|
||
async function signBytes(bytes){
|
||
const sig = await crypto.subtle.sign('Ed25519', myKeys.privateKey, bytes);
|
||
return b64(sig);
|
||
}
|
||
|
||
/* canonical sig inputs — must match the Go side byte-for-byte */
|
||
function sigJoin(roomID, nonce, pubB64, handle){
|
||
return new TextEncoder().encode('zebra-spaces|v1|join|'+roomID+'|'+nonce+'|'+pubB64+'|'+handle);
|
||
}
|
||
function sigAction(roomID, epoch, action, ...args){
|
||
return new TextEncoder().encode(['zebra-spaces','v1',roomID,String(epoch),action,...args].join('|'));
|
||
}
|
||
|
||
/* ==================================================================
|
||
* vault — password backup/restore of the identity JWK.
|
||
*
|
||
* Format: 'zspc-id-v1|' + base64(salt[16] | iv[12] | aes-gcm-ct).
|
||
* key = PBKDF2-SHA256(password, salt, 600000) -> AES-GCM-256
|
||
* ct = AES-GCM(iv, key, utf8(JSON(jwk)))
|
||
* Self-contained: anyone with the blob + password can restore.
|
||
* ================================================================== */
|
||
async function deriveVaultKey(password, salt, usage){
|
||
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password),
|
||
'PBKDF2', false, ['deriveKey']);
|
||
return crypto.subtle.deriveKey({ name:'PBKDF2', salt, iterations:PBKDF2_ITER, hash:'SHA-256' },
|
||
base, { name:'AES-GCM', length:256 }, false, usage);
|
||
}
|
||
async function vaultExport(password){
|
||
const jwk = JSON.parse(localStorage.getItem(ID_KEY));
|
||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const key = await deriveVaultKey(password, salt, ['encrypt']);
|
||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key,
|
||
new TextEncoder().encode(JSON.stringify(jwk))));
|
||
const blob = new Uint8Array(16+12+ct.length); blob.set(salt); blob.set(iv,16); blob.set(ct,28);
|
||
return VAULT_PREFIX + b64(blob);
|
||
}
|
||
async function vaultImport(blobStr, password){
|
||
if (!blobStr.startsWith(VAULT_PREFIX)) throw new Error('not a zebra-spaces vault blob');
|
||
const bytes = unb64(blobStr.slice(VAULT_PREFIX.length).trim());
|
||
if (bytes.length < 16+12+1) throw new Error('vault blob too short');
|
||
const salt = bytes.slice(0,16), iv = bytes.slice(16,28), ct = bytes.slice(28);
|
||
const key = await deriveVaultKey(password, salt, ['decrypt']);
|
||
const pt = new Uint8Array(await crypto.subtle.decrypt({ name:'AES-GCM', iv }, key, ct));
|
||
const jwk = JSON.parse(new TextDecoder().decode(pt));
|
||
/* round-trip through WebCrypto to validate it's a real Ed25519 key */
|
||
const priv = await crypto.subtle.importKey('jwk', jwk, { name:'Ed25519' }, true, ['sign']);
|
||
const pubJwk = { kty:jwk.kty, crv:jwk.crv, x:jwk.x };
|
||
const pub = await crypto.subtle.importKey('jwk', pubJwk, { name:'Ed25519' }, true, ['verify']);
|
||
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pub));
|
||
localStorage.setItem(ID_KEY, JSON.stringify(jwk));
|
||
myKeys = packKeys(priv, pub, raw);
|
||
renderIdentity();
|
||
}
|
||
|
||
function setVaultStatus(msg, cls){
|
||
const e=$('vault-status'); e.textContent=msg; e.className='status-line'+(cls?' '+cls:'');
|
||
$('vault-dot').className='dot'+(cls==='ok'?' ok':cls==='err'?' warn':'');
|
||
}
|
||
function renderIdentity(){ $('pub-short').textContent = myKeys ? shortHex(myKeys.pubHex) : ''; }
|
||
|
||
$('btn-vault').addEventListener('click', () => $('vault-panel').classList.toggle('hidden'));
|
||
|
||
/* log out — destructive: wipes Ed25519 + handle from localStorage and
|
||
* generates a fresh identity. The booted/blocked window keys off the
|
||
* pubkey, so logging out is also the escape hatch from a room block.
|
||
* The user keeps the same browser so we generate a new key right away;
|
||
* otherwise the page would refuse to enter any space (no identity). */
|
||
$('btn-logout').addEventListener('click', async () => {
|
||
if (ws){ setStatus('leave the space first','err'); return; }
|
||
if (!confirm('Log out wipes your identity key from this browser. ' +
|
||
'If you haven’t backed it up you cannot recover it. Continue?')) return;
|
||
try { localStorage.removeItem(ID_KEY); localStorage.removeItem(HANDLE_KEY); } catch(_){}
|
||
myHandle = ''; $('handle').value = '';
|
||
myKeys = await generateIdentity();
|
||
renderIdentity();
|
||
logLine('', 'logged out — fresh identity '+shortHex(myKeys.pubHex));
|
||
});
|
||
$('btn-vault-backup').addEventListener('click', async () => {
|
||
const pw = $('vault-pass').value;
|
||
if (!pw){ setVaultStatus('enter a password first','err'); return; }
|
||
if (pw.length < 8){ setVaultStatus('password too short (min 8 chars)','err'); return; }
|
||
try {
|
||
setVaultStatus('encrypting…');
|
||
const blob = await vaultExport(pw);
|
||
/* offer download — local file, no network */
|
||
const fname = 'zebra-id-'+shortHex(myKeys.pubHex).replace('…','-')+'.txt';
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([blob], {type:'text/plain'}));
|
||
a.download = fname; a.click();
|
||
setVaultStatus('exported as '+fname,'ok');
|
||
$('vault-pass').value = '';
|
||
} catch(e){ setVaultStatus('export failed: '+e.message,'err'); }
|
||
});
|
||
$('btn-vault-restore').addEventListener('click', async () => {
|
||
const blob = $('vault-blob').value.trim();
|
||
const pw = $('vault-pass-restore').value;
|
||
if (!blob || !pw){ setVaultStatus('paste blob + password','err'); return; }
|
||
try {
|
||
setVaultStatus('decrypting…');
|
||
await vaultImport(blob, pw);
|
||
setVaultStatus('identity restored — pubkey '+shortHex(myKeys.pubHex),'ok');
|
||
$('vault-blob').value=''; $('vault-pass-restore').value='';
|
||
} catch(e){ setVaultStatus('restore failed: '+e.message,'err'); }
|
||
});
|
||
|
||
/* handle is per-browser, also in localStorage so it survives reload */
|
||
$('handle').addEventListener('input', (e) => {
|
||
myHandle = e.target.value.trim().slice(0, 32);
|
||
localStorage.setItem(HANDLE_KEY, myHandle);
|
||
});
|
||
|
||
await loadOrCreateIdentity();
|
||
myHandle = localStorage.getItem(HANDLE_KEY) || '';
|
||
$('handle').value = myHandle;
|
||
/* preferences that should outlive a reload — music-mode toggle and the
|
||
* last-picked mic + camera deviceIds. Declared up here so the restore
|
||
* runs before their downstream `let` would put them in the temporal
|
||
* dead zone; downstream code now reads from these existing bindings. */
|
||
let musicMode = false, micDeviceId = '', cameraDeviceId = '';
|
||
try { musicMode = localStorage.getItem(MUSIC_MODE_KEY) === '1'; } catch(_){}
|
||
try { micDeviceId = localStorage.getItem(MIC_DEV_KEY) || ''; } catch(_){}
|
||
try { cameraDeviceId = localStorage.getItem(CAM_DEV_KEY) || ''; } catch(_){}
|
||
if ($('music-mode')) $('music-mode').checked = musicMode;
|
||
renderIdentity();
|
||
logLine('', 'pubkey '+myKeys.pubHex);
|
||
|
||
/* ==================================================================
|
||
* rendezvous signaling
|
||
* ================================================================== */
|
||
const SIGNAL_URL = new URLSearchParams(location.search).get('signal')
|
||
|| 'wss://cors-proxy.uncloseai.com/zebra-spaces-signal';
|
||
const SIGNAL_SALT = new TextEncoder().encode('zebra-spaces-v1');
|
||
async function deriveSignalRoom(code){
|
||
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('zebra-spaces-room|'+code));
|
||
return hex(h);
|
||
}
|
||
async function deriveSignalKey(code){
|
||
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(code), 'PBKDF2', false, ['deriveKey']);
|
||
return crypto.subtle.deriveKey({ name:'PBKDF2', salt:SIGNAL_SALT, iterations:PBKDF2_ITER, hash:'SHA-256' },
|
||
base, { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']);
|
||
}
|
||
async function aesEncrypt(key, str){
|
||
const iv=crypto.getRandomValues(new Uint8Array(12));
|
||
const ct=new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv}, key, new TextEncoder().encode(str)));
|
||
const out=new Uint8Array(12+ct.length); out.set(iv); out.set(ct,12); return out;
|
||
}
|
||
async function aesDecrypt(key, bytes){
|
||
const iv=bytes.slice(0,12), ct=bytes.slice(12);
|
||
return new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv}, key, ct));
|
||
}
|
||
|
||
/* ==================================================================
|
||
* SFU bridge — listeners subscribe to receive every speaker's audio;
|
||
* speakers publish their mic. Mesh handles speaker↔speaker low-latency;
|
||
* SFU handles broadcast fan-out to listeners. Speakers never subscribe
|
||
* (they'd hear their mesh peers a second time, delayed).
|
||
* ================================================================== */
|
||
const SFU_BASE = (new URLSearchParams(location.search).get('sfu')
|
||
|| 'https://cors-proxy.uncloseai.com/zebra-spaces-sfu').replace(/\/$/, '');
|
||
|
||
let sfuPubPC = null, sfuPubPeerID = null;
|
||
let sfuSubPC = null, sfuSubPeerID = null, sfuSubEvents = null;
|
||
let sfuScreenPC = null, sfuScreenPeerID = null, sfuScreenStream = null;
|
||
let sfuCameraPC = null, sfuCameraPeerID = null, sfuCameraStream = null;
|
||
/* cameraDeviceId is declared earlier so localStorage restore can write to it
|
||
* before the device-list refresh runs */
|
||
/* incoming screen/camera streams keyed by publisher pubkey hex (== streamID).
|
||
* Cleared when the corresponding publisher leaves (peer-left or boot). */
|
||
const screenStreams = new Map(); // pubHex -> MediaStream
|
||
const screenVideos = new Map(); // pubHex -> { tile, video }
|
||
const cameraStreams = new Map(); // pubHex -> MediaStream
|
||
const cameraVideos = new Map(); // pubHex -> { tile, video }
|
||
/* SFU MediaStream cache keyed by PUBLISHER pubkey hex (= streamID set by the
|
||
* SFU's TrackLocal). Survives across host leave/rejoin: when a speaker drops
|
||
* we tear their audio element, but Pion often REUSES the transceiver on
|
||
* their rejoin so ontrack doesn't fire a second time — the same MediaStream
|
||
* object keeps receiving new RTP under the hood. By caching by pubkey we
|
||
* can re-attach that same stream to a fresh audio element on peer-joined
|
||
* even when no fresh ontrack event arrives. */
|
||
const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream
|
||
|
||
function attachSfuTrack(uuid, stream){
|
||
let a = remoteAudio.get(uuid);
|
||
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
|
||
a.srcObject = stream;
|
||
/* programmatic play in case autoplay policy needs the nudge after a track
|
||
* swap — caller already had a user gesture (entered the space) */
|
||
try { const p = a.play(); if (p && p.catch) p.catch(()=>{}); } catch(_){}
|
||
stopMeter(uuid); startMeter(uuid, stream);
|
||
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || shortHex(uuid)));
|
||
}
|
||
/* If we already have a cached SFU stream for this member's pubkey (from a
|
||
* prior subscribe-side ontrack), attach it. Used on peer-joined / host
|
||
* promotions / room state updates so a rejoined speaker's audio reattaches
|
||
* without needing the SFU to emit a fresh ontrack. */
|
||
function attachCachedSfuStreamFor(uuid){
|
||
const mm = members.get(uuid);
|
||
if (!mm || !mm.pubkey) return;
|
||
let pubHex;
|
||
try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ return; }
|
||
const stream = sfuStreamsByPubHex.get(pubHex);
|
||
if (stream) attachSfuTrack(uuid, stream);
|
||
}
|
||
function flushSfuStreams(){
|
||
for (const [pubHex, stream] of sfuStreamsByPubHex){
|
||
for (const [uuid, mm] of members){
|
||
try {
|
||
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
|
||
if (!remoteAudio.has(uuid)) attachSfuTrack(uuid, stream);
|
||
break;
|
||
}
|
||
} catch(_){}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* shared video-tile renderer for screens + cameras. The two kinds share
|
||
* the autoplay + tap-to-play + unmute machinery; only the DOM target
|
||
* (container + tile class), label prefix, and backing Map differ.
|
||
* Removed on peer-left / boot / SFU track removal via removeVideoTile. */
|
||
const TILE_KINDS = {
|
||
screen: { container:'screens', section:'sec-screens', tileClass:'screen-tile', labelPrefix:'screen', store: screenVideos, streams: screenStreams },
|
||
camera: { container:'cameras', section:'sec-cameras', tileClass:'camera-tile', labelPrefix:'camera', store: cameraVideos, streams: cameraStreams },
|
||
};
|
||
function renderVideoTile(kind, pubHex, stream, opts){
|
||
const k = TILE_KINDS[kind]; if (!k) return;
|
||
const local = !!(opts && opts.local);
|
||
/* find the matching member for the title; self isn't in members map */
|
||
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);
|
||
if (!entry){
|
||
const tile = document.createElement('div'); tile.className = k.tileClass;
|
||
const video = document.createElement('video');
|
||
/* set BOTH the HTML attributes and the IDL properties — Firefox Android
|
||
* checks the attribute when deciding autoplay eligibility, not just the
|
||
* .muted property. */
|
||
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 = () => {
|
||
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');
|
||
const pop = document.createElement('button'); pop.className = 'small';
|
||
pop.textContent = 'fullscreen'; pop.onclick = () => video.requestFullscreen && video.requestFullscreen().catch(()=>{});
|
||
ctl.appendChild(pop);
|
||
meta.appendChild(who); meta.appendChild(ctl);
|
||
tile.appendChild(video); tile.appendChild(tap); tile.appendChild(meta);
|
||
$(k.container).appendChild(tile);
|
||
entry = { tile, 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');
|
||
}
|
||
$(k.section).classList.remove('hidden');
|
||
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;
|
||
try { entry.video.srcObject = null; entry.tile.remove(); } catch(_){}
|
||
k.store.delete(pubHex);
|
||
k.streams.delete(pubHex);
|
||
if (k.store.size === 0) $(k.section).classList.add('hidden');
|
||
}
|
||
/* back-compat shims — keep the old names callable so the rest of the
|
||
* file doesn't have to be rewritten in one shot */
|
||
function renderScreenTile(pubHex, stream, opts){ return renderVideoTile('screen', pubHex, stream, opts); }
|
||
function removeScreenTile(pubHex){ return removeVideoTile('screen', pubHex); }
|
||
function renderCameraTile(pubHex, stream, opts){ return renderVideoTile('camera', pubHex, stream, opts); }
|
||
function removeCameraTile(pubHex){ return removeVideoTile('camera', pubHex); }
|
||
|
||
async function sfuPublish(){
|
||
if (sfuPubPC){ logLine('','sfu publish: already publishing'); return; }
|
||
if (!micStream || !myKeys || !roomID){
|
||
logLine('err','sfu publish skipped: mic='+(!!micStream)+' keys='+(!!myKeys)+' room='+(!!roomID));
|
||
return;
|
||
}
|
||
logLine('', 'sfu publish: starting (base='+SFU_BASE+')');
|
||
const pc = new RTCPeerConnection(rtcConfig);
|
||
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
||
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
||
const offer = await pc.createOffer();
|
||
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
|
||
await pc.setLocalDescription(offer);
|
||
await waitForIceGathering(pc);
|
||
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
||
});
|
||
if (res.status === 403){ pc.close(); handleBlocked('publish'); return; }
|
||
if (!res.ok){ pc.close(); throw new Error('sfu publish http '+res.status); }
|
||
const ans = await res.json();
|
||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||
sfuPubPC = pc; sfuPubPeerID = ans.peer_id;
|
||
logLine('', 'sfu: publishing as '+shortHex(sfuPubPeerID));
|
||
}
|
||
|
||
/* ----- screen share ----- */
|
||
async function sfuPublishScreen(){
|
||
if (sfuScreenPC || !myKeys || !roomID) return;
|
||
let stream;
|
||
try {
|
||
/* broadcast-quality capture: 1080p30 video, raw stereo 48kHz audio.
|
||
* Browsers treat these as 'ideal' — if a window is smaller it downscales
|
||
* gracefully; nothing is rejected. The constraint matters for the audio
|
||
* side: without channelCount:2 + sampleRate:48000, getDisplayMedia on
|
||
* Chrome can hand back mono 16kHz, which kills music quality. */
|
||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||
video: { width:{ideal:1920}, height:{ideal:1080}, frameRate:{ideal:30} },
|
||
audio: { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
|
||
channelCount:2, sampleRate:48000 }
|
||
});
|
||
} catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
|
||
sfuScreenStream = stream;
|
||
const vTracks = stream.getVideoTracks(), aTracks = stream.getAudioTracks();
|
||
logLine('', 'screen capture: '+vTracks.length+' video + '+aTracks.length+' audio track(s)');
|
||
if (aTracks.length === 0){
|
||
/* Firefox getDisplayMedia never captures tab/window audio — only the
|
||
* 'entire screen' source carries system audio, and only on some
|
||
* platforms. Chrome captures tab audio when the user ticks 'share
|
||
* audio' in the picker. Make the limitation visible instead of
|
||
* silently broadcasting video-only. */
|
||
const ua = navigator.userAgent;
|
||
const ff = /Firefox/.test(ua);
|
||
logLine('err','no audio captured — '+(ff
|
||
? 'Firefox getDisplayMedia ignores tab/window audio. Share "entire screen" with system audio, or stream the audio via mic music mode (toggle music mode + route the tab through your mic).'
|
||
: 'tick the "share tab/system audio" checkbox in the picker, or use music-mode mic.'));
|
||
}
|
||
const pc = new RTCPeerConnection(rtcConfig);
|
||
for (const tr of stream.getTracks()){
|
||
if (tr.kind === 'video') tr.contentHint = 'detail'; /* favour pixel fidelity over framerate */
|
||
if (tr.kind === 'audio') tr.contentHint = 'music';
|
||
pc.addTrack(tr, stream);
|
||
}
|
||
/* the user can stop the share from the browser's native "stop sharing"
|
||
* bar — propagate that into a clean unpublish */
|
||
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishScreen(); });
|
||
const offer = await pc.createOffer();
|
||
/* screen-share audio is always music-grade — system audio capture is
|
||
* what users actually broadcast, not voice */
|
||
offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true });
|
||
await pc.setLocalDescription(offer);
|
||
await waitForIceGathering(pc);
|
||
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
|
||
+ '&pub=' + myKeys.pubHex + '&kind=screen';
|
||
let res;
|
||
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
|
||
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw e; }
|
||
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; handleBlocked('publish-screen'); return; }
|
||
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw new Error('sfu publish-screen http '+res.status); }
|
||
const ans = await res.json();
|
||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||
sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
|
||
/* raise the RTP-level caps: 6 Mbps for video (high-detail 1080p screen),
|
||
* 256 kbps for audio (transparent stereo Opus). The codec-level cap was
|
||
* already raised via preferStereoOpus(). */
|
||
for (const s of pc.getSenders()){
|
||
if (!s.track) continue;
|
||
if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000);
|
||
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
|
||
}
|
||
logLine('', 'sfu: sharing screen as '+shortHex(sfuScreenPeerID));
|
||
/* render a muted local preview so the publisher sees what they're
|
||
* sharing — SFU does not echo the publisher's own stream back */
|
||
renderScreenTile(myKeys.pubHex, stream, { local: true });
|
||
$('btn-screen-share').classList.add('hidden');
|
||
$('btn-screen-stop').classList.remove('hidden');
|
||
}
|
||
async function sfuUnpublishScreen(){
|
||
if (!sfuScreenPC && !sfuScreenStream) return;
|
||
const pid = sfuScreenPeerID;
|
||
if (myKeys) removeScreenTile(myKeys.pubHex);
|
||
if (sfuScreenStream){ sfuScreenStream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; }
|
||
if (sfuScreenPC){ try { sfuScreenPC.close(); } catch(_){} sfuScreenPC = null; sfuScreenPeerID = null; }
|
||
if (pid && roomID){
|
||
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
||
}
|
||
$('btn-screen-share').classList.remove('hidden');
|
||
$('btn-screen-stop').classList.add('hidden');
|
||
logLine('', 'screen share stopped');
|
||
}
|
||
|
||
/* ----- camera publish (kind=camera) ----- */
|
||
async function sfuPublishCamera(){
|
||
if (sfuCameraPC || !myKeys || !roomID) return;
|
||
let stream;
|
||
const videoConstraints = { width:{ideal:1280}, height:{ideal:720}, frameRate:{ideal:30} };
|
||
if (cameraDeviceId) videoConstraints.deviceId = { exact: cameraDeviceId };
|
||
try {
|
||
/* camera only — mic comes through the separate sfuPublish path so a
|
||
* speaker can choose camera-on while still using a different audio
|
||
* input (monitor source, music mode, etc) */
|
||
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
|
||
} catch(e){ logLine('err','camera open cancelled: '+e.message); return; }
|
||
sfuCameraStream = stream;
|
||
const pc = new RTCPeerConnection(rtcConfig);
|
||
for (const tr of stream.getTracks()){
|
||
if (tr.kind === 'video') tr.contentHint = 'motion'; /* face/scene cam = motion over detail */
|
||
pc.addTrack(tr, stream);
|
||
}
|
||
stream.getVideoTracks()[0].addEventListener('ended', () => { sfuUnpublishCamera(); });
|
||
await pc.setLocalDescription(await pc.createOffer());
|
||
await waitForIceGathering(pc);
|
||
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
|
||
+ '&pub=' + myKeys.pubHex + '&kind=camera';
|
||
let res;
|
||
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
|
||
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw e; }
|
||
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; handleBlocked('publish-camera'); return; }
|
||
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw new Error('sfu publish-camera http '+res.status); }
|
||
const ans = await res.json();
|
||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||
sfuCameraPC = pc; sfuCameraPeerID = ans.peer_id;
|
||
/* 1.5 Mbps is plenty for 720p30 face cam — keeps the screen-share
|
||
* headroom intact when both are live */
|
||
for (const s of pc.getSenders()){
|
||
if (s.track && s.track.kind === 'video') setSenderMaxBitrate(s, 1500000);
|
||
}
|
||
logLine('', 'sfu: camera on as '+shortHex(sfuCameraPeerID));
|
||
renderCameraTile(myKeys.pubHex, stream, { local: true });
|
||
$('btn-camera-share').classList.add('hidden');
|
||
$('btn-camera-stop').classList.remove('hidden');
|
||
}
|
||
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');
|
||
}
|
||
|
||
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: PUBKEY (mic) | PUBKEY:screen | PUBKEY:camera.
|
||
* Route video kinds to the tile renderer; mics to the audio path. */
|
||
const colon = sid.indexOf(':');
|
||
if (colon > 0){
|
||
const pubHex = sid.slice(0, colon);
|
||
const kind = sid.slice(colon + 1);
|
||
/* skip echo of our own publish — we already render a local preview */
|
||
if (myKeys && pubHex === myKeys.pubHex) return;
|
||
/* video kinds: when the publisher unshares, the SFU stops the
|
||
* transceiver and the corresponding remote track fires 'ended'.
|
||
* Wire that to the tile remover so the listener's UI matches the
|
||
* publisher's state instead of holding a frozen last frame. */
|
||
if (kind === 'screen'){
|
||
screenStreams.set(pubHex, ev.streams[0]);
|
||
renderScreenTile(pubHex, ev.streams[0]);
|
||
ev.track.addEventListener('ended', () => removeScreenTile(pubHex));
|
||
return;
|
||
}
|
||
if (kind === 'camera'){
|
||
cameraStreams.set(pubHex, ev.streams[0]);
|
||
renderCameraTile(pubHex, ev.streams[0]);
|
||
ev.track.addEventListener('ended', () => removeCameraTile(pubHex));
|
||
return;
|
||
}
|
||
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
|
||
return;
|
||
}
|
||
/* mic audio — 400ms jitter-buffer target. Mobile/Wi-Fi peak inter-
|
||
* arrival is 3-5x the smoothed jitter reported by getStats, so a
|
||
* smoothed 50ms means 150-250ms peaks. 400ms absorbs that and feels
|
||
* fine for music broadcasts; conversation gains ~quarter-second of
|
||
* delay which is well below noticeable. */
|
||
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
|
||
const pubHex = sid;
|
||
/* cache stream by publisher pubkey so it survives the member's session
|
||
* uuid changing across leave/rejoin — see flushSfuStreams */
|
||
sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
|
||
for (const [uuid, mm] of members){
|
||
try {
|
||
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
|
||
attachSfuTrack(uuid, ev.streams[0]);
|
||
return;
|
||
}
|
||
} catch(_){}
|
||
}
|
||
/* no matching member yet — flushSfuStreams will attach on peer-joined */
|
||
};
|
||
/* server-initiated offer: POST /subscribe (empty body) — SFU answers with
|
||
* an SDP offer containing one m-line per current publisher. We answer it
|
||
* and POST the answer back, which completes the initial handshake. */
|
||
/* pass our pubkey as `sub` so the SFU can evict us if we're booted —
|
||
* server-side boot also calls SFU /internal/block which kicks any
|
||
* subscriber whose sub pubkey matches */
|
||
const subUrl = SFU_BASE + '/subscribe?room=' + encodeURIComponent(roomID) + '&sub=' + myKeys.pubHex;
|
||
const offerRes = await fetch(subUrl, {
|
||
method:'POST', headers:{'Content-Type':'application/json'}, body: '{}'
|
||
});
|
||
if (offerRes.status === 403){ pc.close(); handleBlocked('subscribe'); return; }
|
||
if (!offerRes.ok){ pc.close(); throw new Error('sfu subscribe http '+offerRes.status); }
|
||
const offer = await offerRes.json();
|
||
await pc.setRemoteDescription({ type:'offer', sdp: offer.sdp });
|
||
const answer = await pc.createAnswer();
|
||
await pc.setLocalDescription(answer);
|
||
await waitForIceGathering(pc);
|
||
const ackRes = await fetch(SFU_BASE + '/subscribe-answer?room=' + encodeURIComponent(roomID) + '&peer=' + offer.peer_id, {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
||
});
|
||
if (!ackRes.ok){ pc.close(); throw new Error('sfu subscribe-answer http '+ackRes.status); }
|
||
sfuSubPC = pc; sfuSubPeerID = offer.peer_id;
|
||
/* SSE: server pushes renegotiation offers when publisher set changes.
|
||
* We answer each via POST /answer. ping events are keepalive only. */
|
||
sfuSubEvents = new EventSource(SFU_BASE + '/events?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID);
|
||
sfuSubEvents.onmessage = async (ev) => {
|
||
let m; try { m = JSON.parse(ev.data); } catch(_){ return; }
|
||
if (m.type !== 'offer' || !sfuSubPC) return;
|
||
try {
|
||
await sfuSubPC.setRemoteDescription({ type:'offer', sdp: m.sdp });
|
||
const ans = await sfuSubPC.createAnswer();
|
||
await sfuSubPC.setLocalDescription(ans);
|
||
await waitForIceGathering(sfuSubPC);
|
||
await fetch(SFU_BASE + '/answer?room=' + encodeURIComponent(roomID) + '&peer=' + sfuSubPeerID, {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ sdp: sfuSubPC.localDescription.sdp })
|
||
});
|
||
} catch(e){ logLine('err','sfu renegotiate: '+e.message); }
|
||
};
|
||
sfuSubEvents.onerror = () => { /* EventSource auto-reconnects */ };
|
||
logLine('', 'sfu: subscribed as '+shortHex(sfuSubPeerID));
|
||
}
|
||
|
||
async function sfuUnsubscribe(){
|
||
if (sfuSubEvents){ try { sfuSubEvents.close(); } catch(_){} sfuSubEvents = null; }
|
||
if (sfuSubPC){ try { sfuSubPC.close(); } catch(_){} sfuSubPC = null; sfuSubPeerID = null; }
|
||
sfuStreamsByPubHex.clear();
|
||
}
|
||
|
||
/* ==================================================================
|
||
* ephemeral TURN credentials (reused from zebra-audio model)
|
||
* ================================================================== */
|
||
const TURN_CRED_URL = new URLSearchParams(location.search).get('turncred')
|
||
|| 'https://cors-proxy.uncloseai.com/turn-cred';
|
||
let rtcConfig = { iceServers: [{ urls: ['stun:proxy.uncloseai.com:3478','stun:stun.l.google.com:19302'] }] };
|
||
async function refreshTurnCred(){
|
||
try {
|
||
const c = await (await fetch(TURN_CRED_URL, {cache:'no-store'})).json();
|
||
if (c && c.credential && c.uris) {
|
||
rtcConfig = { iceServers: [
|
||
{ urls: c.stun || ['stun:proxy.uncloseai.com:3478'] },
|
||
{ urls: c.uris, username: c.username, credential: c.credential }
|
||
] };
|
||
logLine('', 'TURN credentials fetched');
|
||
}
|
||
} catch (e) { logLine('', 'no TURN creds — direct/STUN only ('+e.message+')'); }
|
||
}
|
||
|
||
/* ==================================================================
|
||
* audio — mic acquisition + per-PC remote audio elements
|
||
*
|
||
* One mic stream local; one <audio> per remote speaker (so all speakers
|
||
* are heard concurrently). Music-mode + mid-call device switching from
|
||
* zebra-audio carries over directly: re-acquire + replaceTrack on every
|
||
* live sender (now multiple senders, one per peer).
|
||
* ================================================================== */
|
||
let micStream = null, audioCtx = null;
|
||
/* musicMode + micDeviceId declared earlier so localStorage restore can
|
||
* populate them before getMic() reads micConstraints() */
|
||
function micConstraints(){
|
||
/* music mode = high-fidelity broadcast: stereo, raw, 48kHz so we can
|
||
* push 256kbps Opus and let Opus's stereo modes carry music properly.
|
||
* voice mode stays mono + the three cleanups so speech is intelligible. */
|
||
const base = musicMode
|
||
? { echoCancellation:false, noiseSuppression:false, autoGainControl:false,
|
||
channelCount:2, sampleRate:48000, sampleSize:16 }
|
||
: { echoCancellation:true, noiseSuppression:true, autoGainControl:true };
|
||
if (micDeviceId) base.deviceId = { exact: micDeviceId };
|
||
return base;
|
||
}
|
||
async function refreshMicList(){
|
||
try {
|
||
const devs = await navigator.mediaDevices.enumerateDevices();
|
||
const mics = devs.filter(d=>d.kind==='audioinput');
|
||
const sel = $('mic-select'); if (!sel) return;
|
||
sel.innerHTML = '';
|
||
if (!mics.length){ sel.innerHTML = '<option value="">default microphone</option>'; return; }
|
||
mics.forEach((m,i)=>{
|
||
const o = document.createElement('option');
|
||
o.value = m.deviceId; o.textContent = m.label || ('microphone '+(i+1));
|
||
sel.appendChild(o);
|
||
});
|
||
if (micDeviceId && mics.some(m=>m.deviceId===micDeviceId)) sel.value = micDeviceId;
|
||
else micDeviceId = sel.value;
|
||
} catch(e){ logLine('err','could not list inputs: '+e.message); }
|
||
}
|
||
function tagTrack(t){ if (t) t.contentHint = musicMode ? 'music' : 'speech'; }
|
||
/* Firefox sometimes ignores the EC/NS/AGC constraints at getUserMedia time
|
||
* for non-mic sources (e.g. PulseAudio monitor) and applies its default
|
||
* processing pipeline anyway. applyConstraints() after the fact tends to
|
||
* stick. Log the actual settings so we can see what the UA ended up with —
|
||
* silent disagreement between requested and effective constraints is what
|
||
* makes music-mode-on-a-monitor-source sound 'cleaned up'. */
|
||
async function enforceMicConstraints(track){
|
||
if (!track) return;
|
||
try { await track.applyConstraints(micConstraints()); } catch(e){ logLine('', 'applyConstraints rejected: '+e.message); }
|
||
try {
|
||
const s = track.getSettings();
|
||
logLine('', 'mic track settings: '+JSON.stringify({
|
||
ec: s.echoCancellation, ns: s.noiseSuppression, agc: s.autoGainControl,
|
||
ch: s.channelCount, hz: s.sampleRate, dev: (s.deviceId||'').slice(0,8)
|
||
}));
|
||
} catch(_){}
|
||
}
|
||
async function getMic(){
|
||
if (micStream) return micStream;
|
||
micStream = await navigator.mediaDevices.getUserMedia({ audio: micConstraints(), video:false });
|
||
const t = micStream.getAudioTracks()[0];
|
||
tagTrack(t); await enforceMicConstraints(t);
|
||
return micStream;
|
||
}
|
||
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);
|
||
async function swap(pc){
|
||
const sender = pc.getSenders().find(s=>s.track && s.track.kind==='audio') || pc.getSenders()[0];
|
||
if (sender){ try { await sender.replaceTrack(nt); } catch(_){} setSenderBitrate(sender); }
|
||
}
|
||
for (const [_, pc] of peers) await swap(pc);
|
||
/* SFU PC needs a full renegotiation — replaceTrack alone doesn't change
|
||
* the negotiated Opus fmtp (stereo/maxaveragebitrate), so a mono publish
|
||
* keeps emitting mono even after we swap in a stereo track. Tear down
|
||
* and re-publish so the new SDP carries the music-mode codec params. */
|
||
if (sfuPubPC){
|
||
await sfuUnpublish();
|
||
}
|
||
if (micStream) micStream.getTracks().forEach(t=>t.stop());
|
||
micStream = ns;
|
||
if (myRole && canSpeak(myRole)){
|
||
sfuPublish().catch(e => logLine('err','sfu re-publish: '+e.message));
|
||
}
|
||
/* old analyser is now dead — rewire local meter against the fresh stream */
|
||
if (myUUID){ stopMeter(myUUID); startMeter(myUUID, micStream); }
|
||
}
|
||
/* per-peer meter: one analyser node + one rAF loop, keyed by uuid. The tick
|
||
* reads members.get(uuid)._meterEl fresh each frame so renderRoom can replace
|
||
* the DOM element without killing the meter. Cleanup happens when the uuid
|
||
* leaves the room (members loses the key) or stopMeter is called. */
|
||
const meterCtl = new Map(); /* uuid -> {an, buf} */
|
||
function startMeter(uuid, stream){
|
||
if (!stream || meterCtl.has(uuid)) return;
|
||
if (!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
|
||
let src;
|
||
try { src = audioCtx.createMediaStreamSource(stream); }
|
||
catch(e){ logLine('err','meter for '+shortHex(uuid)+': '+e.message); return; }
|
||
const an = audioCtx.createAnalyser(); an.fftSize = 512;
|
||
src.connect(an);
|
||
const buf = new Uint8Array(an.fftSize);
|
||
meterCtl.set(uuid, { an, buf });
|
||
(function tick(){
|
||
const c = meterCtl.get(uuid);
|
||
if (!c) return;
|
||
if (!members.has(uuid)){ meterCtl.delete(uuid); return; }
|
||
c.an.getByteTimeDomainData(c.buf);
|
||
let peak=0;
|
||
for (let i=0;i<c.buf.length;i++){ const v=Math.abs(c.buf[i]-128)/128; if(v>peak)peak=v; }
|
||
const m = members.get(uuid);
|
||
if (m && m._meterEl) m._meterEl.style.width = Math.min(100, Math.round(peak*180))+'%';
|
||
requestAnimationFrame(tick);
|
||
})();
|
||
}
|
||
function stopMeter(uuid){ meterCtl.delete(uuid); }
|
||
|
||
/* ==================================================================
|
||
* room state mirror (server is source of truth, we mirror locally for
|
||
* rendering). updated by welcome / peer-joined / peer-left / state /
|
||
* role-change / peer-booted / host-promoted.
|
||
* ================================================================== */
|
||
let myUUID = '';
|
||
let myRole = ''; /* host | cohost | speaker | listener */
|
||
let roomEpoch = 0;
|
||
let roomID = '';
|
||
let members = new Map(); /* uuid -> {uuid,pubkey,handle,role,joined_at} */
|
||
let hostUUID = '';
|
||
let handraise = new Set(); /* uuids of listeners with hand raised */
|
||
let outstandingInvite = null; /* {from, epoch} when we're invited */
|
||
|
||
const peers = new Map(); /* uuid -> RTCPeerConnection (mesh among speakers) */
|
||
const remoteAudio = new Map(); /* uuid -> <audio> element */
|
||
|
||
function isMod(role){ return role==='host' || role==='cohost'; }
|
||
function canSpeak(role){ return role==='host' || role==='cohost' || role==='speaker'; }
|
||
|
||
/* ==================================================================
|
||
* signaling websocket — JSON text frames, see protocol header in
|
||
* cmd/zebra-spaces-signal/main.go
|
||
* ================================================================== */
|
||
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…');
|
||
openSignal();
|
||
}
|
||
|
||
function openSignal(){
|
||
ws = new WebSocket(SIGNAL_URL + '?room=' + encodeURIComponent(roomID));
|
||
ws.onopen = async () => {
|
||
/* first message: signed join. Server assigns role: host if room is
|
||
* empty/we're the recently-departed host coming back, else listener. */
|
||
const nonce = hex(crypto.getRandomValues(new Uint8Array(16)));
|
||
const sig = await signBytes(sigJoin(roomID, nonce, myKeys.pubB64, myHandle));
|
||
send({ type:'join', pubkey: myKeys.pubB64, handle: myHandle, nonce, sig });
|
||
};
|
||
ws.onclose = () => {
|
||
ws = null;
|
||
if (wantConnected){
|
||
setStatus('rendezvous dropped — reconnecting…');
|
||
if (sigReconnect) clearTimeout(sigReconnect);
|
||
sigReconnect = setTimeout(()=>{ if (wantConnected) openSignal(); }, 1500);
|
||
} else {
|
||
setStatus('left',null);
|
||
$('btn-enter').disabled = false;
|
||
}
|
||
};
|
||
ws.onerror = () => setStatus('signal error','err');
|
||
ws.onmessage = (ev) => { handleSignal(ev.data).catch(e=>logLine('err','signal: '+e.message)); };
|
||
}
|
||
|
||
async function handleSignal(raw){
|
||
let m; try { m = JSON.parse(raw); } catch(_){ return; }
|
||
switch (m.type){
|
||
case 'welcome':
|
||
myUUID = m.your_uuid; myRole = m.role; roomEpoch = m.epoch;
|
||
applyState(m.state);
|
||
logLine('', 'joined as '+myRole+' — uuid '+shortHex(myUUID));
|
||
setStatus('connected as '+myRole, 'ok');
|
||
$('dot-call').className='dot ok';
|
||
$('btn-leave').disabled = false;
|
||
$('sec-room').classList.remove('hidden');
|
||
renderShareUrl();
|
||
onRoleEntered();
|
||
renderRoom();
|
||
break;
|
||
case 'state':
|
||
roomEpoch = m.epoch; applyState(m.state); flushSfuStreams(); renderRoom(); break;
|
||
case 'peer-joined':
|
||
members.set(m.uuid, { uuid:m.uuid, pubkey:m.pubkey, handle:m.handle, role:m.role, joined_at: Date.now()/1000 });
|
||
logLine('', m.handle+' joined as '+m.role);
|
||
/* if the original host rejoined during their grace window, the room
|
||
* is rescued — drop the "space closing" status from our top bar */
|
||
if (m.role === 'host'){
|
||
hostUUID = m.uuid;
|
||
setStatus('connected as '+myRole, 'ok');
|
||
}
|
||
/* a new member may resolve a queued SFU track (e.g. host's rejoin
|
||
* race where ontrack fired before peer-joined) */
|
||
flushSfuStreams();
|
||
/* establish mesh PC if both us and them are speakers (or mods).
|
||
* present-member-offers: existing speaker offers when a new speaker
|
||
* arrives. deterministic by uuid string compare. */
|
||
if (canSpeak(myRole) && canSpeak(m.role)) connectToPeer(m.uuid, /*weOffer*/ myUUID < m.uuid);
|
||
renderRoom();
|
||
break;
|
||
case 'peer-left':
|
||
{
|
||
const left = members.get(m.uuid);
|
||
if (left) logLine('', left.handle+' left');
|
||
/* if they were sharing screen or camera, drop the tiles */
|
||
if (left && left.pubkey){
|
||
try { const h = hex(unb64(left.pubkey)); removeScreenTile(h); removeCameraTile(h); } catch(_){}
|
||
}
|
||
members.delete(m.uuid);
|
||
handraise.delete(m.uuid);
|
||
tearPeer(m.uuid);
|
||
if (hostUUID === m.uuid) hostUUID = '';
|
||
renderRoom();
|
||
}
|
||
break;
|
||
case 'sdp-from':
|
||
try { await onSDP(m.from, m.kind, await aesDecrypt(sigKey, unb64(m.data))); }
|
||
catch(e){ logLine('err','sdp from '+shortHex(m.from)+' failed: '+e.message); }
|
||
break;
|
||
case 'mic-state':
|
||
try { const s = JSON.parse(await aesDecrypt(sigKey, unb64(m.data)));
|
||
const mm = members.get(m.uuid); if (mm){ mm.muted = !!s.muted; renderRoom(); } } catch(_){}
|
||
break;
|
||
case 'hand-raised':
|
||
handraise.add(m.uuid); renderRoom();
|
||
{ const mm = members.get(m.uuid); if (mm) logLine('', mm.handle+' raised hand'); }
|
||
break;
|
||
case 'hand-lowered':
|
||
handraise.delete(m.uuid); renderRoom(); break;
|
||
case 'mic-invite':
|
||
if (m.to === myUUID){
|
||
outstandingInvite = { from:m.from, epoch:m.epoch };
|
||
const from = members.get(m.from);
|
||
$('invite-from').textContent = 'from '+(from?from.handle:shortHex(m.from));
|
||
$('sec-invite').classList.remove('hidden');
|
||
logLine('', 'you were invited to the mic by '+(from?from.handle:shortHex(m.from)));
|
||
} else {
|
||
const from = members.get(m.from), to = members.get(m.to);
|
||
if (from && to) logLine('', from.handle+' invited '+to.handle+' to the mic');
|
||
}
|
||
break;
|
||
case 'mic-invite-declined':
|
||
if (m.from === myUUID){
|
||
const to = members.get(m.to);
|
||
logLine('', (to?to.handle:shortHex(m.to))+' declined the mic');
|
||
}
|
||
break;
|
||
case 'role-change':
|
||
{
|
||
const mm = members.get(m.uuid);
|
||
if (mm){
|
||
const prev = mm.role;
|
||
mm.role = m.role;
|
||
roomEpoch = m.epoch;
|
||
if (m.role === 'host') hostUUID = m.uuid;
|
||
if (m.uuid === myUUID){
|
||
myRole = m.role;
|
||
logLine('', 'you are now '+m.role);
|
||
setStatus('connected as '+myRole, 'ok');
|
||
const byMod = members.get(m.by);
|
||
const byTxt = byMod ? ' by '+byMod.handle : '';
|
||
/* visible self-notification per role transition */
|
||
if (m.role === 'listener' && prev !== 'listener') showNotice('You were moved to listener'+byTxt+'. Your mic is off.', 'warn');
|
||
else if (m.role === 'speaker' && prev === 'listener') showNotice('You are now a speaker.', 'info');
|
||
else if (m.role === 'speaker' && prev === 'cohost') showNotice('You were stepped down to speaker'+byTxt+'.', 'warn');
|
||
else if (m.role === 'cohost') showNotice('You were promoted to co-host'+byTxt+'.', 'info');
|
||
else if (m.role === 'host') showNotice('You are now the host.', 'info');
|
||
onRoleChanged(prev, m.role);
|
||
} else {
|
||
logLine('', mm.handle+' is now '+m.role);
|
||
/* mesh adjustments */
|
||
if (canSpeak(myRole)){
|
||
if (canSpeak(m.role) && !peers.has(m.uuid)) connectToPeer(m.uuid, myUUID < m.uuid);
|
||
if (!canSpeak(m.role) && peers.has(m.uuid)) tearPeer(m.uuid);
|
||
} else if (peers.has(m.uuid)){
|
||
tearPeer(m.uuid);
|
||
}
|
||
}
|
||
renderRoom();
|
||
}
|
||
}
|
||
break;
|
||
case 'peer-booted':
|
||
{
|
||
const mm = members.get(m.uuid);
|
||
const by = members.get(m.by);
|
||
if (mm) logLine('', mm.handle+' was removed by '+(by?by.handle:'a mod'));
|
||
if (m.uuid === myUUID){
|
||
/* server will close our socket; surface a clear notice and prevent
|
||
* the WS reconnect loop from auto-rejoining into a boot loop. Tear
|
||
* down our SFU + mesh PCs too so we actually stop hearing /
|
||
* broadcasting — closing the WS alone leaves the WebRTC paths up. */
|
||
wantConnected = false;
|
||
showNotice('You were removed from this space'+(by?' by '+by.handle:'')+'.', 'warn');
|
||
logLine('err','you were removed from this space');
|
||
for (const u of [...peers.keys()]) tearPeer(u);
|
||
sfuUnpublish().catch(()=>{});
|
||
sfuUnpublishScreen().catch(()=>{});
|
||
sfuUnpublishCamera().catch(()=>{});
|
||
sfuUnsubscribe().catch(()=>{});
|
||
dropMic();
|
||
}
|
||
members.delete(m.uuid); tearPeer(m.uuid); handraise.delete(m.uuid);
|
||
renderRoom();
|
||
}
|
||
break;
|
||
case 'host-left':
|
||
logLine('', 'host left the space');
|
||
hostUUID = '';
|
||
break;
|
||
case 'host-promoted':
|
||
roomEpoch = m.epoch;
|
||
hostUUID = m.new_host_uuid;
|
||
{ const mm = members.get(m.new_host_uuid);
|
||
if (mm){ mm.role = 'host';
|
||
if (m.new_host_uuid === myUUID){ myRole = 'host'; setStatus('connected as host','ok'); onRoleChanged('cohost','host'); }
|
||
else { setStatus('connected as '+myRole, 'ok'); } /* clears the space-closing warning */
|
||
logLine('', (mm.handle||shortHex(m.new_host_uuid))+' is now host'); }
|
||
flushSfuStreams();
|
||
renderRoom();
|
||
}
|
||
break;
|
||
case 'space-closing':
|
||
logLine('err','space closing in '+m.grace_seconds+'s — '+m.reason);
|
||
setStatus('host left — space closing in '+m.grace_seconds+'s', 'err');
|
||
break;
|
||
case 'error':
|
||
logLine('err','signal: '+m.message);
|
||
setStatus('signal: '+m.message,'err');
|
||
/* terminal-block signal: stop the reconnect loop and tell the user */
|
||
if (/blocked/i.test(m.message)) handleBlocked('signal');
|
||
break;
|
||
}
|
||
}
|
||
|
||
function applyState(state){
|
||
members = new Map();
|
||
hostUUID = state.host_uuid || '';
|
||
for (const m of (state.members||[])) members.set(m.uuid, Object.assign({}, m));
|
||
handraise = new Set(state.handraise_queue||[]);
|
||
}
|
||
|
||
/* called once on welcome (whatever role we entered as) and on every
|
||
* role change. host/cohost/speaker need a mic; listener drops it.
|
||
*
|
||
* SFU bridge: speakers publish to the SFU (so listeners hear them);
|
||
* listeners subscribe to the SFU (so they hear the speakers). Speakers
|
||
* never subscribe — mesh gives them lower-latency audio already. */
|
||
async function onRoleEntered(){
|
||
if (canSpeak(myRole)) await ensureMicAndUI();
|
||
else updateRoleUI();
|
||
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));
|
||
} else {
|
||
sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message));
|
||
}
|
||
}
|
||
async function onRoleChanged(prev, next){
|
||
if (!canSpeak(prev) && canSpeak(next)){
|
||
await ensureMicAndUI();
|
||
for (const [uuid, mm] of members){
|
||
if (uuid === myUUID) continue;
|
||
if (canSpeak(mm.role)) connectToPeer(uuid, myUUID < uuid);
|
||
}
|
||
await sfuUnsubscribe();
|
||
sfuPublish().catch(e => logLine('err','sfu publish: '+e.message));
|
||
} else if (canSpeak(prev) && !canSpeak(next)){
|
||
for (const u of [...peers.keys()]) tearPeer(u);
|
||
dropMic(); muted = false;
|
||
await sfuUnpublish();
|
||
sfuSubscribe().catch(e => logLine('err','sfu subscribe: '+e.message));
|
||
}
|
||
updateRoleUI();
|
||
}
|
||
async function ensureMicAndUI(){
|
||
/* mic input + music-mode rows are always visible; here we just grant the
|
||
* mic and unblock mute. Toggling music-mode before getting a mic is fine —
|
||
* micConstraints() reads the live `musicMode` flag whenever we re-acquire. */
|
||
try {
|
||
await getMic(); await refreshMicList();
|
||
$('btn-mute').disabled = false;
|
||
if (myUUID) startMeter(myUUID, micStream);
|
||
} catch(e){ logLine('err','mic blocked: '+e.message); }
|
||
updateRoleUI();
|
||
}
|
||
function dropMic(){
|
||
if (myUUID) stopMeter(myUUID);
|
||
if (micStream){ micStream.getTracks().forEach(t=>t.stop()); micStream = null; }
|
||
$('btn-mute').disabled = true;
|
||
}
|
||
function updateScreenShareUI(){
|
||
/* the share-screen button is only available to people who can speak —
|
||
* publishing a screen via the SFU requires being a speaker anyway */
|
||
$('sec-screen-share').classList.toggle('hidden', !canSpeak(myRole));
|
||
if (!canSpeak(myRole) && (sfuScreenPC || sfuScreenStream)) sfuUnpublishScreen();
|
||
/* camera lives in the same section as screen-share — visibility is
|
||
* driven by canSpeak() above. If a role demotion lands while the
|
||
* camera is live, force it off the same way the screen share is. */
|
||
if (!canSpeak(myRole) && (sfuCameraPC || sfuCameraStream)) sfuUnpublishCamera();
|
||
}
|
||
|
||
function updateRoleUI(){
|
||
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
|
||
updateScreenShareUI();
|
||
}
|
||
|
||
/* ==================================================================
|
||
* mesh — one RTCPeerConnection per other speaker
|
||
*
|
||
* Deterministic offerer: lex-smaller uuid offers. Avoids both-offer
|
||
* collisions when two speakers arrive nearly simultaneously. Reconnect
|
||
* by tear + reconnect with the same rule, so the same side always
|
||
* drives recovery.
|
||
* ================================================================== */
|
||
async function connectToPeer(uuid, weOffer){
|
||
if (peers.has(uuid)) return;
|
||
if (!micStream){ try { await getMic(); } catch(e){ logLine('err','mic for '+shortHex(uuid)+': '+e.message); return; } }
|
||
const pc = new RTCPeerConnection(rtcConfig);
|
||
peers.set(uuid, pc);
|
||
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
||
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
||
pc.ontrack = (ev) => {
|
||
let a = remoteAudio.get(uuid);
|
||
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
|
||
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
|
||
/* 400ms jitter buffer — Wi-Fi peak inter-arrival is multiples of
|
||
* the smoothed jitter, so a generous buffer absorbs the bursts */
|
||
try { ev.receiver.playoutDelayHint = 0.4; } catch(_){}
|
||
stopMeter(uuid); startMeter(uuid, a.srcObject);
|
||
};
|
||
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
|
||
pc.onconnectionstatechange = () => {
|
||
if (pc.connectionState === 'failed' && peers.get(uuid) === pc){
|
||
logLine('', 'peer '+shortHex(uuid)+' failed — reconnecting');
|
||
tearPeer(uuid);
|
||
/* let the offerer drive recovery */
|
||
setTimeout(()=>{ if (members.has(uuid) && canSpeak(members.get(uuid).role) && canSpeak(myRole))
|
||
connectToPeer(uuid, myUUID < uuid); }, 1500);
|
||
}
|
||
};
|
||
if (weOffer){
|
||
const offer = await pc.createOffer();
|
||
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
|
||
await pc.setLocalDescription(offer);
|
||
await waitForIceGathering(pc);
|
||
await sendEncSDP(uuid, 'offer', pc.localDescription);
|
||
}
|
||
}
|
||
function tearPeer(uuid){
|
||
stopMeter(uuid);
|
||
const pc = peers.get(uuid);
|
||
if (pc){ try { pc.close(); } catch(_){} peers.delete(uuid); }
|
||
const a = remoteAudio.get(uuid);
|
||
if (a){ try { a.srcObject = null; a.remove(); } catch(_){} remoteAudio.delete(uuid); }
|
||
}
|
||
function waitForIceGathering(p, timeoutMs=6000){
|
||
return new Promise(res=>{
|
||
if (p.iceGatheringState==='complete') return res();
|
||
let done=false; const fin=()=>{ if(done)return; done=true; p.removeEventListener('icegatheringstatechange',h); clearTimeout(t); res(); };
|
||
const h=()=>{ if(p.iceGatheringState==='complete') fin(); };
|
||
p.addEventListener('icegatheringstatechange',h); const t=setTimeout(fin,timeoutMs);
|
||
});
|
||
}
|
||
async function onSDP(fromUUID, kind, json){
|
||
const desc = JSON.parse(json);
|
||
let pc = peers.get(fromUUID);
|
||
if (kind === 'offer'){
|
||
/* if we had an old PC, tear it (renegotiation = fresh PC) */
|
||
if (pc){ try { pc.close(); } catch(_){} peers.delete(fromUUID); }
|
||
await connectToPeer(fromUUID, /*weOffer*/ false);
|
||
pc = peers.get(fromUUID); if (!pc) return;
|
||
await pc.setRemoteDescription(desc);
|
||
await pc.setLocalDescription(await pc.createAnswer());
|
||
await waitForIceGathering(pc);
|
||
await sendEncSDP(fromUUID, 'answer', pc.localDescription);
|
||
} else if (kind === 'answer' && pc){
|
||
await pc.setRemoteDescription(desc);
|
||
}
|
||
}
|
||
|
||
/* ==================================================================
|
||
* mod actions — signed messages sent to the server
|
||
* ================================================================== */
|
||
async function modInvite(uuid){
|
||
const sig = await signBytes(sigAction(roomID, roomEpoch, 'mic-invite', uuid));
|
||
send({ type:'mic-invite', to: uuid, epoch: roomEpoch, sig });
|
||
}
|
||
/* grant-mic: hand-raised listener doesn't need to accept — server promotes
|
||
* them directly to speaker. Use modInvite for cold (unsolicited) invites. */
|
||
async function modGrant(uuid){
|
||
const sig = await signBytes(sigAction(roomID, roomEpoch, 'grant-mic', uuid));
|
||
send({ type:'grant-mic', to: uuid, epoch: roomEpoch, sig });
|
||
}
|
||
async function modPromote(uuid){
|
||
const sig = await signBytes(sigAction(roomID, roomEpoch, 'promote', uuid, 'cohost'));
|
||
send({ type:'promote', target: uuid, to: 'cohost', epoch: roomEpoch, sig });
|
||
}
|
||
async function modDemote(uuid, to){
|
||
const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to));
|
||
send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig });
|
||
}
|
||
async function modBoot(uuid){
|
||
if (!confirm('boot this person from the space?')) return;
|
||
const sig = await signBytes(sigAction(roomID, roomEpoch, 'boot', uuid));
|
||
send({ type:'boot', target: uuid, epoch: roomEpoch, sig });
|
||
}
|
||
|
||
/* ==================================================================
|
||
* room rendering — one <div class="member"> per uuid
|
||
*
|
||
* Re-render on every state change. Members keep a stable _meterEl ref
|
||
* so the per-speaker meter survives identity (re-render replaces row
|
||
* nodes; meter() captures the element by closure and stops itself when
|
||
* its element leaves the DOM).
|
||
* ================================================================== */
|
||
const MIC_ON = '<svg viewBox="0 0 24 24" fill="none" stroke="#060" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||
const MIC_OFF = '<svg viewBox="0 0 24 24" fill="none" stroke="#b00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||
|
||
function rankOf(role){ return {host:4, cohost:3, speaker:2, listener:1}[role] || 0; }
|
||
|
||
function renderRoom(){
|
||
const wrap = $('members'); wrap.innerHTML = '';
|
||
/* sort host → cohosts → speakers → listeners, then by joined_at */
|
||
const arr = [...members.values()].sort((a,b)=>{
|
||
const r = rankOf(b.role) - rankOf(a.role);
|
||
return r !== 0 ? r : (a.joined_at - b.joined_at);
|
||
});
|
||
for (const m of arr){
|
||
const row = document.createElement('div'); row.className = 'member';
|
||
const badge = document.createElement('span'); badge.className = 'badge '+m.role; badge.textContent = m.role;
|
||
const handle = document.createElement('span'); handle.className = 'handle';
|
||
handle.textContent = m.handle;
|
||
if (m.uuid === myUUID){ const me=document.createElement('span'); me.className='me'; me.textContent='(you)'; handle.appendChild(me); }
|
||
const pub = document.createElement('span'); pub.className = 'pub-short'; pub.title = m.pubkey; pub.textContent = shortHex(hex(unb64(m.pubkey)));
|
||
const micEl = document.createElement('span'); micEl.className = 'mic';
|
||
if (canSpeak(m.role)){
|
||
micEl.innerHTML = m.muted ? MIC_OFF : MIC_ON;
|
||
} else if (handraise.has(m.uuid)){
|
||
micEl.innerHTML = '<span class="raised" title="hand raised">✋</span>';
|
||
}
|
||
const meter = document.createElement('div'); meter.className = 'meter';
|
||
const fill = document.createElement('div'); fill.className = 'meter-fill';
|
||
meter.appendChild(fill);
|
||
m._meterEl = fill;
|
||
/* mod controls — only render when we can act on this row */
|
||
const acts = document.createElement('span'); acts.className = 'mod-actions';
|
||
if (isMod(myRole) && m.uuid !== myUUID){
|
||
if (m.role === 'listener'){
|
||
const b = document.createElement('button'); b.className='small invert';
|
||
const raised = handraise.has(m.uuid);
|
||
b.textContent = raised ? 'give the mic' : 'invite mic';
|
||
b.onclick = raised
|
||
? () => modGrant(m.uuid).catch(e=>logLine('err','grant: '+e.message))
|
||
: () => modInvite(m.uuid).catch(e=>logLine('err','invite: '+e.message));
|
||
acts.appendChild(b);
|
||
}
|
||
if (m.role === 'speaker' && myRole === 'host'){
|
||
const b = document.createElement('button'); b.className='small';
|
||
b.textContent = '→ cohost'; b.onclick = () => modPromote(m.uuid).catch(e=>logLine('err','promote: '+e.message));
|
||
acts.appendChild(b);
|
||
}
|
||
if (m.role === 'speaker'){
|
||
const b = document.createElement('button'); b.className='small';
|
||
b.textContent = '→ listener'; b.onclick = () => modDemote(m.uuid, 'listener').catch(e=>logLine('err','demote: '+e.message));
|
||
acts.appendChild(b);
|
||
}
|
||
if (m.role === 'cohost' && myRole === 'host'){
|
||
const b = document.createElement('button'); b.className='small';
|
||
b.textContent = '→ speaker'; b.onclick = () => modDemote(m.uuid, 'speaker').catch(e=>logLine('err','demote: '+e.message));
|
||
acts.appendChild(b);
|
||
}
|
||
/* boot allowed against anyone except host; cohosts also can't boot cohosts (host only) */
|
||
if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){
|
||
const b = document.createElement('button'); b.className='small';
|
||
b.textContent = 'boot'; b.onclick = () => modBoot(m.uuid);
|
||
acts.appendChild(b);
|
||
}
|
||
}
|
||
row.appendChild(badge); row.appendChild(handle); row.appendChild(pub);
|
||
row.appendChild(micEl); row.appendChild(meter); row.appendChild(acts);
|
||
wrap.appendChild(row);
|
||
}
|
||
/* keep listener UI in sync */
|
||
$('sec-listener-actions').classList.toggle('hidden', myRole !== 'listener');
|
||
$('btn-raise').classList.toggle('hidden', handraise.has(myUUID));
|
||
$('btn-lower').classList.toggle('hidden', !handraise.has(myUUID));
|
||
}
|
||
|
||
/* ==================================================================
|
||
* listener actions: raise/lower hand, accept/decline mic invite
|
||
* ================================================================== */
|
||
$('btn-raise').addEventListener('click', () => { send({ type:'raise-hand' }); });
|
||
$('btn-lower').addEventListener('click', () => { send({ type:'lower-hand' }); });
|
||
$('btn-screen-share').addEventListener('click', () => { sfuPublishScreen().catch(e => logLine('err','screen share: '+e.message)); });
|
||
$('btn-screen-stop').addEventListener('click', () => { sfuUnpublishScreen(); });
|
||
$('btn-camera-share').addEventListener('click', () => { sfuPublishCamera().catch(e => logLine('err','camera share: '+e.message)); });
|
||
$('btn-camera-stop').addEventListener('click', () => { sfuUnpublishCamera(); });
|
||
$('camera-select').addEventListener('change', async (e) => {
|
||
cameraDeviceId = e.target.value;
|
||
try { localStorage.setItem(CAM_DEV_KEY, cameraDeviceId); } catch(_){}
|
||
if (sfuCameraPC){
|
||
/* restart with the new device — getUserMedia must be re-called with
|
||
* the new deviceId; replaceTrack on a sender from a different device
|
||
* needs renegotiation anyway, so a clean restart is simpler */
|
||
await sfuUnpublishCamera();
|
||
sfuPublishCamera().catch(err => logLine('err','camera restart: '+err.message));
|
||
}
|
||
});
|
||
async function refreshCameraList(){
|
||
try {
|
||
const devs = await navigator.mediaDevices.enumerateDevices();
|
||
const cams = devs.filter(d => d.kind === 'videoinput');
|
||
const sel = $('camera-select'); if (!sel) return;
|
||
sel.innerHTML = '';
|
||
if (!cams.length){ sel.innerHTML = '<option value="">default camera</option>'; return; }
|
||
cams.forEach((c, i) => {
|
||
const o = document.createElement('option');
|
||
o.value = c.deviceId; o.textContent = c.label || ('camera '+(i+1));
|
||
sel.appendChild(o);
|
||
});
|
||
if (cameraDeviceId && cams.some(c => c.deviceId === cameraDeviceId)) sel.value = cameraDeviceId;
|
||
else cameraDeviceId = sel.value;
|
||
} catch(e){ logLine('err','could not list cameras: '+e.message); }
|
||
}
|
||
navigator.mediaDevices.addEventListener('devicechange', refreshCameraList);
|
||
refreshCameraList();
|
||
|
||
/* ==================================================================
|
||
* latency panel — polls getStats() across every live RTCPeerConnection
|
||
* and renders one row per PC with RTT (ms) + candidate-pair path kind.
|
||
*
|
||
* Architecture note: incoming media (mic, screen video, screen audio,
|
||
* camera) from every other speaker arrives over the SAME sfuSubPC, so
|
||
* 'sfu in' is one row that covers all received streams. Publishers
|
||
* have one PC per kind (sfuPubPC, sfuScreenPC, sfuCameraPC), so each
|
||
* gets its own row. Mesh peers get one row each.
|
||
* ================================================================== */
|
||
/* per-PC stats keyed by an arbitrary ID so we can compute deltas
|
||
* (packetsLost, packetsReceived) across polls — % loss is a delta over
|
||
* a delta, not a cumulative. The pcStatsPrev object holds the last poll. */
|
||
const pcStatsPrev = new Map();
|
||
async function statsForPC(pc, key){
|
||
if (!pc) return { rtt: null, path: '', lossPct: null, jitterMs: null };
|
||
try {
|
||
const stats = await pc.getStats();
|
||
let pair = null, selectedPairId = '';
|
||
stats.forEach(s => { if (s.type === 'transport' && s.selectedCandidatePairId) selectedPairId = s.selectedCandidatePairId; });
|
||
stats.forEach(s => {
|
||
if (s.type !== 'candidate-pair') return;
|
||
if (selectedPairId && s.id === selectedPairId){ pair = s; return; }
|
||
if (!pair && s.state === 'succeeded' && (s.nominated || s.selected)) pair = s;
|
||
});
|
||
if (!pair){
|
||
stats.forEach(s => { if (!pair && s.type === 'candidate-pair' && s.state === 'succeeded') pair = s; });
|
||
}
|
||
let path = '', rtt = null;
|
||
if (pair){
|
||
stats.forEach(s => { if (s.type === 'local-candidate' && s.id === pair.localCandidateId) path = s.candidateType || ''; });
|
||
if (typeof pair.currentRoundTripTime === 'number') rtt = Math.round(pair.currentRoundTripTime * 1000);
|
||
}
|
||
/* sum packetsLost + packetsReceived across inbound-rtp (subscriber
|
||
* side: many incoming streams). For publishers we use the remote
|
||
* report (remote-inbound-rtp tells the sender what its peer lost +
|
||
* the jitter at the receiver). Both directions are interesting. */
|
||
let pktsLost = 0, pktsBase = 0, jitter = 0, jitterSamples = 0;
|
||
stats.forEach(s => {
|
||
if (s.type === 'inbound-rtp' && typeof s.packetsLost === 'number'){
|
||
pktsLost += s.packetsLost;
|
||
pktsBase += (s.packetsReceived || 0) + s.packetsLost;
|
||
if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; }
|
||
} else if (s.type === 'remote-inbound-rtp' && typeof s.packetsLost === 'number'){
|
||
pktsLost += s.packetsLost;
|
||
/* remote report doesn't include packetsReceived; we use it only
|
||
* for loss when no inbound-rtp is present (publish-side PC) */
|
||
if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; }
|
||
}
|
||
});
|
||
/* convert cumulative loss/received into a delta vs last poll so the
|
||
* percentage reflects what's happening NOW, not the session lifetime */
|
||
const prev = pcStatsPrev.get(key) || { lost: 0, base: 0 };
|
||
const dLost = Math.max(0, pktsLost - prev.lost);
|
||
const dBase = Math.max(0, pktsBase - prev.base);
|
||
pcStatsPrev.set(key, { lost: pktsLost, base: pktsBase });
|
||
const lossPct = dBase > 0 ? (100 * dLost / dBase) : (dLost > 0 ? 100 : 0);
|
||
const jitterMs = jitterSamples > 0 ? Math.round(1000 * jitter / jitterSamples) : null;
|
||
return { rtt, path, lossPct, jitterMs };
|
||
} catch(_){ return { rtt: null, path: '', lossPct: null, jitterMs: null }; }
|
||
}
|
||
function rttClass(rtt){
|
||
if (rtt == null) return 'lat-na';
|
||
if (rtt < 50) return 'lat-good';
|
||
if (rtt < 150) return 'lat-mid';
|
||
return 'lat-bad';
|
||
}
|
||
function fmtRtt(rtt){ return (rtt == null) ? '—' : rtt + ' ms'; }
|
||
function fmtPath(p){
|
||
if (!p) return '';
|
||
if (p === 'host') return 'LAN';
|
||
if (p === 'srflx') return 'WAN';
|
||
if (p === 'prflx') return 'WAN';
|
||
if (p === 'relay') return 'TURN';
|
||
return p;
|
||
}
|
||
function fmtLoss(v){ return (v == null) ? '—' : (v < 0.05 ? '0%' : v.toFixed(1)+'%'); }
|
||
function fmtJitter(v){ return (v == null) ? '—' : v + 'ms'; }
|
||
function rowClass(rtt, lossPct, jitterMs){
|
||
/* worst of three signals drives the colour, so a green RTT with
|
||
* 5% loss still flags red */
|
||
let worst = 'lat-good';
|
||
const bump = c => { const order = ['lat-na','lat-good','lat-mid','lat-bad']; if (order.indexOf(c) > order.indexOf(worst)) worst = c; };
|
||
if (rtt == null) bump('lat-na');
|
||
else if (rtt < 50) bump('lat-good');
|
||
else if (rtt < 150) bump('lat-mid');
|
||
else bump('lat-bad');
|
||
if (lossPct != null){
|
||
if (lossPct < 0.5) bump('lat-good');
|
||
else if (lossPct < 2) bump('lat-mid');
|
||
else bump('lat-bad');
|
||
}
|
||
if (jitterMs != null){
|
||
if (jitterMs < 20) bump('lat-good');
|
||
else if (jitterMs < 50) bump('lat-mid');
|
||
else bump('lat-bad');
|
||
}
|
||
return worst;
|
||
}
|
||
async function refreshLatency(){
|
||
const rows = [];
|
||
/* SFU publishers — only present if you're a speaker */
|
||
if (sfuPubPC){ rows.push({ name: 'sfu mic out', pc: sfuPubPC, key: 'pub' }); }
|
||
if (sfuScreenPC){ rows.push({ name: 'sfu screen out', pc: sfuScreenPC, key: 'screen' }); }
|
||
if (sfuCameraPC){ rows.push({ name: 'sfu camera out', pc: sfuCameraPC, key: 'camera' }); }
|
||
/* SFU subscriber — carries every incoming stream from other speakers */
|
||
if (sfuSubPC){ rows.push({ name: 'sfu in (host + screen + cams)', pc: sfuSubPC, key: 'sub' }); }
|
||
/* mesh peers — one row each */
|
||
for (const [uuid, pc] of peers){
|
||
const mm = members.get(uuid);
|
||
const handle = (mm && mm.handle) || shortHex(uuid);
|
||
rows.push({ name: 'peer '+handle, pc, key: 'mesh-'+uuid });
|
||
}
|
||
const container = $('latency-rows');
|
||
if (!container) return;
|
||
if (rows.length === 0){
|
||
$('sec-latency').classList.add('hidden');
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
$('sec-latency').classList.remove('hidden');
|
||
/* gather all stats in parallel */
|
||
const data = await Promise.all(rows.map(r => statsForPC(r.pc, r.key)));
|
||
container.innerHTML = '';
|
||
for (let i = 0; i < rows.length; i++){
|
||
const { rtt, path, lossPct, jitterMs } = data[i];
|
||
const div = document.createElement('div');
|
||
div.className = 'lat-row ' + rowClass(rtt, lossPct, jitterMs);
|
||
const n = document.createElement('span'); n.className = 'lat-name'; n.textContent = rows[i].name;
|
||
const r = document.createElement('span'); r.className = 'lat-rtt'; r.textContent = fmtRtt(rtt);
|
||
const lo = document.createElement('span'); lo.className = 'lat-loss'; lo.textContent = fmtLoss(lossPct);
|
||
const j = document.createElement('span'); j.className = 'lat-jit'; j.textContent = fmtJitter(jitterMs);
|
||
const p = document.createElement('span'); p.className = 'lat-path'; p.textContent = fmtPath(path);
|
||
div.appendChild(n); div.appendChild(r); div.appendChild(lo); div.appendChild(j); div.appendChild(p);
|
||
container.appendChild(div);
|
||
}
|
||
}
|
||
setInterval(refreshLatency, 2000);
|
||
/* kick once on load so the panel doesn't show stale '—' for 2s after each join */
|
||
refreshLatency();
|
||
/* notice banner — visible callouts for events that affect you directly
|
||
* (boot, role change). Auto-clears after 8s for info; stays for warn. */
|
||
let noticeTimer = null;
|
||
function showNotice(text, kind){
|
||
if (noticeTimer){ clearTimeout(noticeTimer); noticeTimer = null; }
|
||
$('notice-text').textContent = text;
|
||
$('notice-banner').className = 'notice-banner ' + (kind || 'warn');
|
||
$('sec-notice').classList.remove('hidden');
|
||
if (kind === 'info'){
|
||
noticeTimer = setTimeout(()=>{ $('sec-notice').classList.add('hidden'); noticeTimer = null; }, 8000);
|
||
}
|
||
}
|
||
function hideNotice(){
|
||
if (noticeTimer){ clearTimeout(noticeTimer); noticeTimer = null; }
|
||
$('sec-notice').classList.add('hidden');
|
||
}
|
||
$('btn-notice-close').addEventListener('click', hideNotice);
|
||
|
||
/* terminal-block handler — called on any "blocked" signal (signal-server
|
||
* error, SFU 403). Stops every reconnect loop, surfaces a clear notice,
|
||
* and shuts the session down so the user sees the boot landed instead of
|
||
* watching their UI spin trying to rejoin a room they can't enter. */
|
||
let blocked = false;
|
||
function handleBlocked(source){
|
||
if (blocked) return;
|
||
blocked = true;
|
||
wantConnected = false;
|
||
showNotice('You are blocked from this space.', 'warn');
|
||
logLine('err','blocked ('+source+') — stopping reconnects');
|
||
setStatus('blocked from this space','err');
|
||
if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; }
|
||
if (ws){ try { ws.close(); } catch(_){} ws = null; }
|
||
for (const u of [...peers.keys()]) tearPeer(u);
|
||
sfuUnpublish().catch(()=>{});
|
||
sfuUnpublishScreen().catch(()=>{});
|
||
sfuUnpublishCamera().catch(()=>{});
|
||
sfuUnsubscribe().catch(()=>{});
|
||
dropMic();
|
||
$('btn-leave').disabled = true;
|
||
$('btn-enter').disabled = false;
|
||
}
|
||
|
||
$('btn-accept-mic').addEventListener('click', () => {
|
||
if (!outstandingInvite) return;
|
||
send({ type:'accept-mic', epoch: outstandingInvite.epoch });
|
||
outstandingInvite = null;
|
||
$('sec-invite').classList.add('hidden');
|
||
});
|
||
$('btn-decline-mic').addEventListener('click', () => {
|
||
if (!outstandingInvite) return;
|
||
send({ type:'decline-mic', epoch: outstandingInvite.epoch });
|
||
outstandingInvite = null;
|
||
$('sec-invite').classList.add('hidden');
|
||
});
|
||
|
||
/* ==================================================================
|
||
* mute / mic input / music mode — same shape as zebra-audio but the
|
||
* mute applies to all live senders (we may have many).
|
||
* ================================================================== */
|
||
let muted = false;
|
||
$('btn-mute').addEventListener('click', () => {
|
||
if (!micStream) return;
|
||
muted = !muted;
|
||
micStream.getAudioTracks().forEach(t=>t.enabled=!muted);
|
||
$('btn-mute').textContent = muted ? 'unmute' : 'mute';
|
||
$('btn-mute').className = muted ? 'invert' : '';
|
||
/* flip our own mic icon — mic-state broadcasts inform OTHER peers, but
|
||
* without this our own row never updates locally */
|
||
const mm = members.get(myUUID);
|
||
if (mm){ mm.muted = muted; renderRoom(); }
|
||
sendMicState();
|
||
});
|
||
$('mic-select').addEventListener('change', async (e) => {
|
||
micDeviceId = e.target.value;
|
||
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
|
||
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','input switch failed: '+err.message); await refreshMicList(); } }
|
||
});
|
||
$('music-mode').addEventListener('change', async (e) => {
|
||
musicMode = e.target.checked;
|
||
try { localStorage.setItem(MUSIC_MODE_KEY, musicMode ? '1' : '0'); } catch(_){}
|
||
logLine('', 'mic mode: '+(musicMode?'MUSIC':'VOICE'));
|
||
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','mic mode switch failed: '+err.message); } }
|
||
});
|
||
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){
|
||
navigator.mediaDevices.addEventListener('devicechange', refreshMicList);
|
||
}
|
||
|
||
/* ==================================================================
|
||
* 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;
|
||
}
|
||
|
||
/* game-tabs: click a button → swap iframe src + mark active. data-src is
|
||
* the only source of truth so new games just need a button. */
|
||
$('game-tabs').addEventListener('click', (ev) => {
|
||
const b = ev.target.closest('button[data-src]');
|
||
if (!b) return;
|
||
const frame = $('game-frame');
|
||
if (frame.src !== b.dataset.src) frame.src = b.dataset.src;
|
||
for (const el of $('game-tabs').querySelectorAll('button')) el.classList.toggle('active', el === b);
|
||
});
|
||
$('btn-leave').addEventListener('click', async () => {
|
||
wantConnected = false;
|
||
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-screens').classList.add('hidden');
|
||
hideNotice();
|
||
$('dot-call').className='dot warn';
|
||
setStatus('left', null);
|
||
$('btn-enter').disabled = false;
|
||
$('btn-leave').disabled = true;
|
||
$('btn-mute').disabled = true;
|
||
$('btn-mute').textContent = 'mute'; $('btn-mute').className=''; muted = false;
|
||
logLine('', 'left the space');
|
||
});
|
||
|
||
logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
||
})();
|
||
</script>
|
||
|
||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-02</span><br>
|
||
md5 <span class="stamp-md5">5e52fb393afcebe7f8500da0ad8e3170</span><br>
|
||
sha256 <span class="stamp-sha">df072d2fc56d260f0c393f22fb0ba5ad59c7fe83a6f176b140434d1d56810249</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>
|