Receive-side mesh state machine (the bit that decides which peer gets
which tile) was buried inside an anonymous pc.ontrack callback inside
sfuSubscribe(). Extracted into a named top-level function
handleRemoteSfuTrack so tests can drive it directly with synthetic
RTCTrackEvents — no real RTCPeerConnection, no real SFU.
test/multi-peer-mesh.test.js pins fox's stated invariant:
'whatever one device shares all should see, and when unshared none
should see.'
Eight scenarios across 2-3 fake browser sandboxes, each holding the
shipped handleRemoteSfuTrack + renderVideoTile + removeVideoTile +
watchVideoTrackForRemoval + the maps they own:
- one peer publishes camera -> every other peer ends with that pubHex
in cameraStreams + a tile entry
- one peer unshares (track ended) -> every other peer drops that pubHex
- one peer unshares mid-flow (mute past window) -> drops correctly
- hiccup supplant (same pubkey, new track) -> tile preserved AND
pointed at the new stream object (this is the MSID-supplant fix from
d5e9e4e — fresh MediaStream per track means the video element binds
to the new RTP cleanly)
- hiccup supplant + the OLD track's stream-identity guard prevents the
NEW tile from being reaped
- supplant + sustained mute past window on the NEW track -> reaped
correctly
- echo guard: a peer's own publish never enters their own cameraStreams
- three publishers fan-out: A B C all publish, every peer ends with
exactly the other two
Wired into Makefile as test-mesh + added to test-all. Pure Node, no
browser or proxy server needed. Will catch the regressions where one
peer's publish/unpublish silently desyncs another peer's view.
Diagnostic-only: logLine fires on every share/stop button click with
the current sfuXPC/sfuXStream truthiness so the telemetry channel
shows whether (a) the click reached the JS at all, (b) the unpublish
function bailed at its 'nothing to do' early return, or (c) it
actually entered the close path. Helps diagnose the 'stop sharing
doesn't work' report without copy-pasting.
Pairs with signal commit 3aebd67. Every logLine call after the local
render also fires a WS message to the signal server, which writes a
CLIENT_LOG line into /var/log/zebra-spaces-signal.log. Lets us debug
WebRTC cascades across multiple peers from a single grep instead of
asking each user to copy-paste their tab's log column.
Real cause of 'host can't see laptop's camera on rejoin' (and the phone-
camera analogue): SFU's TrackLocalStaticRTP uses the same streamID
(shortPub-kind) for every publish of a given (pubkey, kind). When a
publisher supplants themselves the new track's MSID matches the old
one. Per WebRTC spec the browser merges it into the SAME MediaStream
object — ev.streams[0] is the same instance as before, containing
BOTH the dead old track AND the new live one. Setting
video.srcObject = ev.streams[0] doesn't switch the source; the video
element keeps showing the old track's last frame, reports muted, and
the 60s mute-window then reaps a tile that was never going to come
back on its own.
Fix: construct a fresh MediaStream from just ev.track. The video
element binds to the new RTP cleanly and the receive-side state
machine sees a real unmute as soon as packets arrive.
Repro: host shares camera, laptop sees it fine, phone (FF Android on
cellular) reaped the tile at 15s even though the publisher was alive
and well. The laptop's subscribe path doesn't see the same RTP jitter
the phone does — 15s was too tight for mobile networks. 60s sits
above the typical NACK/transport-cc recovery window and Pion's ICE
timeout: a path that's really broken fires renegotiation + 'ended'
within that window anyway, so the only difference is fewer false
reaps on jittery mobile paths.
Screen + game window stays at 120s — static content needs the longer
fuse.
Matches signal commit 0547b4c. The leave button now sends a 'bye'
message before closing the WS — the server distinguishes a strong
leave from a hiccup, and we want the strong-leave path here. Without
the bye, an explicit leave would defer peer-left for 8s and other
peers would see the user's tiles + mesh PCs linger.
Screen shares and game shares can sit static for long stretches — a still
desktop, a paused video, a code editor with no caret movement. The
encoder genuinely stops emitting RTP, the subscriber's track goes muted,
and the 15s camera window would falsely reap the live tile.
watchVideoTrackForRemoval now takes a per-call windowMs; the sub-PC
ontrack handler passes VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS (120s) for
screen + game and VIDEO_REMOVE_MUTE_WINDOW_MS (15s) for camera. A
genuine unshare still resolves through the 'ended' path within a
frame, so the longer window only affects the slow-failure case.
Tests bumped to 18: new screen-window assertions + invalid-windowMs
fallback to the default rather than disabling removal entirely.
15 unit + integration tests that extract the function and the shipped
VIDEO_REMOVE_MUTE_WINDOW_MS constant from web/zebra-spaces.html so the
assertions track exactly what's deployed. Drives synthetic mute/
unmute/ended event sequences against a fake EventTarget track with a
fake clock injected through a Function-constructor harness.
Unit coverage:
- shipped window must be >= 10s (catches accidental shorten)
- initial mute (never flowed) never removes
- flowing + sustained mute past window removes
- mute + unmute within window cancels removal
- ended event removes immediately + cancels pending timer
- removeFn is idempotent (no double-call across mute, ended, or later
events)
- redundant mute events do not stack timers
- rescue and removal both emit logLine telemetry
- rapid mute/unmute oscillation never removes while unmute lands in time
Integration coverage (realistic lifecycles):
- fresh track -> flow -> publisher unshares -> tile removed
- mobile network handoff (long mute) recovers without removal
- peer leaves abruptly (ended fires) -> tile removed once
- hard refresh of publisher (SFU supplant renegotiation gap) -> tile
survives — this is the cascade fox flagged where a phone reconnect
was killing the host's view of its camera
- publisher process crashes (mute holds indefinitely) -> removed at
window
Wired into Makefile as test-video-removal + added to test-all. Pure
Node, no browser or proxy server needed.
Repros from fox:
- host shares screen, all peers see it, then non-phone speaker loses
it after a few seconds
- hard refresh: tiles appear then disappear
Root cause: watchVideoTrackForRemoval declared the publisher gone
after 3s of mute. That's way too aggressive — a transient network
blip, NACK retransmission gap, brief CPU pressure on the publisher,
or a mobile network handoff can pause RTP for 3s without the publisher
actually unsharing. 3s mute fired remove(), tile disappeared even
though the SFU was still forwarding.
15s is enough to weather Wi-Fi stalls and mobile network handoffs
while still removing the tile within a reasonable window when the
publisher genuinely unshares.
Added logLine on both the timeout fire ('video track muted >15s —
removing tile') and the rescue ('RTP resumed before timeout — keeping
tile') so we can see what's actually happening on the receiver side
the next time something looks wrong.
Was the truncated 25cc…1766 dangling next to the handle text field.
Now a dedicated row between handle and the backup/logout buttons
prints the full 64-char hex, monospace, word-break:break-all so it
wraps cleanly into 2-3 lines. user-select:all so a single click
selects the whole key for copy — the place where a user is most
likely to want to grab their identity for backup or to paste into
another channel for verification.
Desktop rule .page.controls-collapsed promotes the layout to a 2-column
rail (cameras 35% + timeline rest). Inside the mobile breakpoint that
same rule was winning by specificity and changing the shares column
width when the panel toggled. Override inside the mobile media query so
controls-collapsed on a narrow viewport still uses minmax(0, 1fr) —
single stack regardless of panel visibility.
Was: video opacity 0.35 + black overlay 0.55 — combined nearly opaque.
Now: video opacity 0.75 + black overlay 0.22 + text-shadow on the
badge so 'VIEWING' stays readable against any background. Still clearly
distinguishable from the un-spotlighted thumbs but the underlying
camera/screen content is visible.
Two grid-only changes (no flex per CLAUDE.md):
1. When controls is collapsed, cameras-col grows from a fixed 220px to
minmax(220px, 35%). On a wide viewport the freed right-side width
gets split between cameras-col + timeline instead of leaving the
cameras at 220px while the spotlight takes everything.
2. #tiles-thumbs goes from a single 1fr column to
repeat(auto-fill, minmax(180px, 1fr)). Narrow column = 1 thumb per
row (unchanged in default panel-showing layout); wide column = 2 / 3
/ N thumbs per row, depending on how much space they got.
Mobile breakpoint already used auto-fill with the same minmax; promoting
it to all viewports unifies the rule.
The aspectRatio constraint hint at getUserMedia time is unreliable on
Android Chrome / Firefox — the sensor's natural read-out orientation
gets locked at acquire time and rotating the phone does not update the
encoded frames. Listeners stay seeing whatever orientation the camera
was first opened in.
screen.orientation 'change' (with the orientationchange legacy event
as fallback) now triggers a re-acquire of the camera with the same
constraints. The fresh video track is swapped into the existing publish
PC's sender via replaceTrack — no SDP renegotiation, no SFU-side
supplant, no subscriber renegotiation. Subscribers just start
receiving frames in the new orientation within a few hundred ms.
Debounced 350ms so a fast rotation flick doesn't double-fire. Old
stream's tracks are stopped only after the swap so the encoder has
the new source ready before the old one ends.
Android Chrome / Firefox getUserMedia honour aspectRatio:{ideal:16/9}
on the camera constraints to lock the sensor read-out to landscape
regardless of the device's current screen orientation. Adding it stops
the back camera from sending a portrait frame the receivers can't
rotate.
Belt-and-suspenders on the render side: camera tile video switches from
object-fit:cover to object-fit:contain so any portrait stream that
slips through (older browsers, manual override) letterboxes inside the
16:9 box instead of being middle-cropped into a square. Black
background fills the bars.
Thumbnails stay cover-cropped (visual consistency in the column);
spotlight view always uses contain.
The pubkey IS the user's identity: host claims, room reservations,
cohost grants, meeting-life blocks all key off it. Without a vault
backup, logging out is one-way. The old confirm() prompt was a single
line that didn't convey 'you cannot be you again' clearly enough.
New prompt:
- prints the full pubkey hex so the user can copy it before clicking OK
(panic-safety net)
- enumerates exactly what they lose
- ends with the recovery path ('backup / restore')
Wording chosen to nudge a backup BEFORE confirm, not to scare-off
intentional log-outs.
sessionStorage key MUTE_STATE_KEY persists '1' (muted) / '0' (live) so
the page reconnects in the same mute state the user last clicked. No
hot-mic on refresh — if you left muted, you come back muted.
Restored from ensureMicAndUI() after getMic() succeeds; applyMuteState()
syncs the button label, the mic track enable flag, the user's own room
row, and broadcasts mic-state to peers if we came back muted. Cleared
only on explicit leave; survives role demote → re-promote so reclaiming
the mic keeps the user's previously-chosen state.
Repro from fox: a listener joined a room while host's publish PCs were
silently in 'failed' state — saw no audio, no camera, no screen for
~60s until host left and rejoined, which created fresh publish PCs.
SFU log confirmed: host's mic/screen/camera publish PCs all hit ICE
failure within the first minute. SFU reaped them on
OnConnectionStateChange(failed). When the listener subscribed there
were zero publishers in the room — they got an SDP with only the
inactive placeholder m-line and never recovered until host re-published.
Page had a 'failed' rebuild path only for sfuSubPC (the subscribe side).
The three publish PCs were unwatched — once Pion-on-server killed them
they were dead but the page UI kept saying 'sfu: publishing as ...'
with nothing actually broadcasting.
New watchPublishPC() helper installs the same rebuild pattern on
sfuPubPC, sfuScreenPC, sfuCameraPC: on 'failed' the dead PC is closed,
the slot is nulled, and the publish entrypoint runs again. The
'wasOurs' guard prevents the rebuild from firing if the user explicitly
unpublished (which also fires onconnectionstatechange).
sfuGamePC gets a softer treatment — its source is a user-picked iframe
via Region Capture, so auto-restart isn't safe. The page just logs the
failure clearly + tears down so the share button comes back.
Matches signal 0d5d6fa. Cohost cannot extend the room (no chime
authority), so space-closing always means 'this is going to end' —
removed the will_close=false branch.
Two-row (sometimes three-row) grid per member using grid-template-areas:
- row 1: badge | handle | mic | meter
- row 2: full 64-char pubkey, monospace, word-break:break-all, wraps as
many lines as needed (rather than the previous shortHex truncation)
- row 3: mod-action buttons when present
Handle gets white-space:nowrap + ellipsis so a long handle ('bluediamond')
no longer fragments mid-word ('bluediamo nd') across grid lines. Pubkey
column also returns on mobile (was display:none under 500px); the new
layout fits naturally on narrow screens.
On publisher republish (cohost hard refresh → same pubkey + kind → SFU
supplants), the OLD track sees mute → ended on the subscriber side and
its watchVideoTrackForRemoval closure called removeXTile(pubHex). But by
then the NEW track had already populated the same tile entry — so the
old watcher tore down the live tile. Net effect: other peers saw the
cohost's camera flash on and disappear (the local preview kept rendering
because that path is independent of SFU subscribe).
Each removeFn now checks the streams store still maps pubHex to the
stream it was registered against; if not (the new track replaced it),
the old watcher is a no-op.
Page now reads will_close from space-closing broadcast and shows a soft
warning (warn status, plain log line) when a cohost is present and will
succeed the absent host at grace expiry; keeps the hard 'space closing'
error styling for rooms that will actually end.
Pulled from the matching server broadcast enrichment (commit 7d16a16,
zebra-spaces-signal). Each role-change / hand-raised / hand-lowered /
mic-invite / mic-invite-declined / peer-joined / peer-left / peer-booted
/ host-left / host-promoted / spotlight log line now carries the actor's
authoritative pubkey from the server message instead of trusting the
local uuid -> handle map. Full hex, never truncated. New helpers
pubHexFromMsg() + idTag() keep call sites compact.
Three defects fox hit in one demotion. Fixes:
1) 'looked away' triggered on every demoted person.
Cause: dropping the user's screen/camera tile in sfuUnpublishX during
demotion fired pickNextSpotlight → broadcastSpotlight with empty key
→ every peer logged 'X looked away'. False signal — they didn't look
away, the room tore their tile.
Fix: inRoleTransition flag set inside onRoleChanged(); broadcast-
Spotlight() short-circuits when true.
2) Speaker links not severed — they could keep talking to listeners.
Cause: onRoleChanged tore down mic+sfuPubPC but NOT screen / camera /
game publishes. Listeners kept seeing the demoted user's mic +
screen via the now-orphan publishers.
Fix: demotion now awaits sfuUnpublish + sfuUnpublishScreen +
sfuUnpublishCamera + sfuUnpublishGame.
3) Mesh peers were torn automatically on demotion (per fox: 'never drop
people out of the mesh automatically').
Cause: both onRoleChanged AND the other-side handler in case
'role-change' called tearPeer when a member became listener.
Fix: removed both tearPeer calls. Mesh connections ride as a
back-channel until one side actually leaves the room. Promote
path still connects (idempotent, only when both can speak and
the peer doesn't exist).
The 'demoted user can't see screen-shares' part of fox's report is
likely a sub-PC renegotiation race during the multi-unpublish — diagnostic
log lines from earlier commit (bc7bbbf) should help us narrow it down
once fox reproduces and pastes the log.
Per fox. Previously the left column had three separate sections
(cameras / screens-thumbs / games) each with their own header. Now:
- one #tiles-thumbs container holds every camera, screen-share, and
game-share thumbnail
- single 'shares' h2 header (hidden when no thumbs)
- games stay below as a fixed persistent group (not popularity-ranked)
- TILE_KINDS.{screen,camera,gameshare} all set
thumbContainer:'tiles-thumbs'
- reorderTiles is now ONE sort over the unified list. Each tile carries
data-kind on the dataset so tileScore can pick the right TILE_KINDS
entry for owner-role lookup. A speaker's screen with 3 viewers ranks
above the same speaker's camera with 1 viewer; both rank below a
host's screen regardless of viewers.
- updateContainerVisibility collapsed to one anyThumb check
- mobile media query updates to grid-auto-fill the unified container
instead of just #cameras
Stale CSS (#cameras column rule, .screens-thumbs-h2) removed.
Per fox. shortHex() rendered things like 'joined as host — uuid
8255…6dd6' / 'sfu: subscribed as bfd2…17c8' — useless for diagnosing
defects because the truncated hex doesn't match what's in the SFU
log or the WS server log.
Stripped shortHex from every logLine() callsite (17 lines):
- 'logged out — fresh identity <full pubHex>'
- 'sfu: receiving <full uuid>' (mic attach)
- 'sfu: publishing as <full peerID>' (mic publish)
- 'sfu: sharing screen as <full peerID>'
- 'sfu: sharing gameplay as <full peerID>'
- 'sfu: camera on as <full peerID>'
- 'sfu ontrack: kind=X pub=<full pubHex>'
- 'sfu: unknown kind X from <full pubHex>'
- 'sfu: subscribed as <full peerID>'
- 'meter for <full uuid>: <err>'
- 'joined as <role> — uuid <full uuid>'
- 'sdp from <full uuid> failed: <err>'
UI sites (badges, pub-short label, member-row chips, invite-from
banner) still call shortHex — those are display, not diagnostic.
Per fox. Pulled the log section out of <aside class="controls"> and
moved it to a new <section id="sec-log"> sibling of .page, right
above the page-integrity footer. CSS:
- 240px default height (was 150px and stuck in a 360px column)
- resize: vertical so the user can drag it taller as needed
- min-height: 120px so it can't be collapsed to nothing
- full viewport width inside the body's padding box
Same dark-mode rules apply (#050505 bg / #333 border) since the
.log class selector didn't change.
Listener reports seeing host's screen-share but NOT camera-share on
rejoin. SFU log shows both pubs alive + addPubToSub fires for the
new sub, but the listener-side visible result diverges between
screen and camera. Add a log line that captures everything we know
when each video-kind track lands:
sfu ontrack: kind=camera pub=abc12345
track=video mute=true state=live
Fields:
- kind: which routing branch we took
- pub: short pubHex prefix
- track.kind: should be 'video' for screen/camera/game
- track.muted: initial mute state (true is normal pre-RTP)
- track.readyState: 'live' = good, anything else = bad
Fox can paste the lines so we can see whether (a) the camera
ontrack never fires (SFU subscribe SDP is dropping it), (b) it
fires with track.muted=true and stays that way (no RTP arriving),
or (c) it fires healthy and gets pruned by some downstream bug.
The signal-WS reconnects periodically (network blips, tab background-
ing). Each reconnect produces a fresh welcome → onRoleEntered() fired
the full setup again, which called sfuPublish() → it bailed out via the
'already publishing' early-return + logged it as an alarm. Looked
exactly like 'something kicked out my speaker' in the log even though
the existing mic publish was still healthy.
Two fixes:
- welcome handler detects re-entry by checking myUUID === m.your_uuid.
When true, just refresh role + state + flushSfuStreams + renderRoom
and break — don't re-run sessionStorage saves, spotlight broadcasts,
onRoleEntered, etc.
- sfuPublish silently returns when sfuPubPC is non-null (still
idempotent, just not log-noisy).
ICE-failure recovery on sub PC stays unchanged — that's a real
'failed' state, not a duplicate welcome.
Fix two complaints from fox:
1. share-gameplay used to grab the only sfuScreenPC slot, so a normal
screen-share would be torn down. Now uses dedicated sfuGamePC /
sfuGamePeerID / sfuGameStream so the two coexist freely.
2. The browser picker exposes whole tabs, not iframes. We now call
CropTarget.fromElement(iframe) → videoTrack.cropTo(cropTarget)
(Chromium Region Capture API) which restricts the captured frame
to the iframe's rect — audience sees only the gameplay, none of
the surrounding meeting UI. Firefox + Safari lack CropTarget so
they fall back to whole-tab capture; user-controlled.
Paired with SFU 855f798 which adds 'game' to the kind allowlist.
streamID format: <16hex>-game (parallel to -screen / -camera).
Client wiring:
- new gameStreams / gameVideos Maps and TILE_KINDS.gameshare
- ontrack: kind === 'game' → renderVideoTile('gameshare', ...) with
label prefix 'gameplay'. Listeners' screens-thumbs column gets the
tile alongside any regular screen-shares from the same speaker.
- sfuPublishGame(iframe) / sfuUnpublishGame() mirror the screen helpers
- game-tile's 'share gameplay' button toggles sfuPublishGame ↔
sfuUnpublishGame (not sfuPublishScreen — that's now untouched)
- leave / role-demotion / boot all clean up game-share too
Speakers can now broadcast their gameplay to the room. The spotlit
game tile's meta bar gets a 'share gameplay' button (next to where
fullscreen/etc live for other tile kinds). Click:
- sfuPublishScreen({ preferCurrentTab: true }) — Chromium picker
auto-selects the current tab; Firefox ignores the hint and shows
the normal picker (user just clicks 'this tab')
- existing SFU screen-share pipeline takes over from there — listeners
and other speakers see the tab via their subscribe leg, with the
game iframe visible at full size in their spotlight
- button label flips to 'stop sharing' while live; click again unpubs
Only canSpeak(myRole) gets the button — listeners are audience only.
The game iframe still loads + plays for the local user the same way
(the share is just a screen-capture of what's already on screen), so
there's no double-iframe or weird audio routing. Audience sees a
screen-share with the game running in real time.
Confirming the design fox specified: each game (unmario, cake murder
adventure) is a separate STABLE tile in the left column. The thumbnail
NEVER mounts an iframe (no game JS running in the background, no
audio bleed, no network cost) — it's a card with the game's name in
chunkfive and a green '▶ play' cue in the meta strip.
Clicking a tile:
- Mounts a fresh iframe in the middle spotlight slot (only then does
the game actually load and become playable)
- Updates spotlight state to { kind: 'game', pubHex: id }
- Broadcasts the spotlight change so every other person in the room
sees 'fxhp now viewing unmario' in their log
- Re-orders thumbnails by popularity (viewer count)
- The previous spotlight tile's iframe is torn out of the DOM, so
switching games stops the previous one cleanly
Clicking the already-spotlit game tile is a no-op (already playing).
CSS rename: .game-label → .game-poster + new .game-meta strip with
the play cue. Title now uses the chunkfive serif (same family as
the page headings) so the games read as 'content' rather than 'UI'.
Symptom: listener saw camera tiles appear black, then vanish ~1.5s later.
Cause: remote MediaStreamTracks ALWAYS start in muted state until the
first RTP packet arrives. My watchVideoTrackForRemoval had a
'defensive' branch that scheduled a prune if the track was already
muted at attach time — so the moment we attached, the 1.5s timer
started, and if the publisher's encoder hadn't pushed a keyframe yet
(common for cameras), the tile was pruned before any frame rendered.
Fix: track a hasFlowed flag. Set true on the FIRST 'unmute' (RTP
actually arrived). Only schedule a prune on 'mute' events that fire
AFTER hasFlowed — those are the real 'publisher stopped sending' case.
Initial muted state is now ignored. Also bumped the debounce from
1.5s to 3s for extra safety on slow networks.
'ended' still removes immediately (terminal state, no debounce).
83 FSM tests green.
Root cause of the 'late listener can't see screens' symptom: my recent
'rebuild sub PC on failure' commit (6a72e77) triggered on connection
state 'closed' as well as 'failed', AND the guard `if (sfuSubPC === pc)`
only worked because we ASSUMED Chrome would fire the state change
asynchronously. Chrome fires it SYNCHRONOUSLY during pc.close(), at
which point sfuSubPC still points at the closing pc — guard passes,
rebuild fires. Then sfuUnsubscribe closes the new PC, which triggers
another rebuild. SFU log showed listeners cycling
subscribe → 40s → close → subscribe forever.
Fix:
1. onconnectionstatechange now only rebuilds on 'failed' (the actually-
terminal state). 'closed' = sfuUnsubscribe(), 'disconnected' =
transient and WebRTC may recover on its own.
2. sfuUnsubscribe nulls sfuSubPC BEFORE pc.close(), so even if the
handler fired synchronously the === guard would correctly fail.
3. visibilitychange handler also tightened to only fire on 'failed' —
same reasoning.
The rebuild path for actual ICE failures still works (state goes
'connected' → 'disconnected' → 'failed' → rebuild).
83 FSM tests still green.
Per fox: 'listeners should be able to see all shared screens as audience'.
Listeners are passive — they're not running the meeting, they're
watching. So they shouldn't have to know they CAN click the screen
thumbnail to see the screen big; the system should auto-promote the
most recent screen-share into their spotlight.
Promotion rules in renderVideoTile when a new tile arrives:
- no spotlight up → first tile auto-promotes (anyone)
- listener + new screen + spotlight is camera/game
→ switch to the screen (load-bearing content)
- listener + new screen + spotlight is older screen
→ switch to the newer screen
- listener + new camera
→ do NOT override an active screen spotlight
- speakers + new anything
→ keep their manual choice
All other screens stay in the screens-thumbs column as before, so a
listener can still click any prior screen to view it. The thumbnails
of arriving cameras / screens still render normally — listeners just
get the new screen pre-spotlit instead of buried.
Speakers / hosts keep their existing behavior: first tile auto-
spotlights, subsequent tiles become thumbnails, manual click swaps.
Per fox: each game is its own tile in the left column. Click makes it
the spotlight (big middle slot) AND broadcasts via the same spotlight
channel cameras/screens use, so the log says 'alice now viewing
unmario' and popularity-sort can rank games by viewer count.
Changes:
- GAMES = { unmario, cake } map (id → label + src) — single source
of truth, easy to extend
- buildGameThumb(id) creates a 16:9 clickable card with the game name
and a 'viewing' badge that surfaces when spotlit
- renderGamesColumn() rebuilds #games-thumbs from the GAMES map at
page init (idempotent, can re-run on config change)
- thumbElementFor(kind, pubHex) unified thumb lookup so spotlight
unmark-previous works across camera / screen / game without case
branches in the hot path
- setSpotlight refactored: previous-thumb cleanup runs first, then
branches on kind=game (build iframe-backed big tile) vs kind=
camera/screen (existing video-backed path). Both still go through
the same spotlights.set(myUUID, ...) + broadcastSpotlight() +
reorderTiles() at the end, so the broadcast path is unified.
- ownerLabel('game', id) → game label so log lines read 'alice now
viewing unmario' instead of 'alice now viewing <hex>'s game'
- old #game-frame + game-tabs DOM and handler removed; their CSS too
Lid-close / suspend recovery. When the OS suspends the browser, the
SFU sub PC goes 'disconnected' → 'failed' (or just stops carrying RTP
silently). On resume the old PC is dead but JS still holds a reference
to it, so we get frozen remote screens until the host hard-reboots.
Two recovery paths added:
1. pc.onconnectionstatechange — on 'failed' or 'closed' for the active
sub PC, sfuUnsubscribe() then sfuSubscribe() to rebuild. The
wantConnected guard prevents fighting a deliberate leave.
2. document visibilitychange → 'visible' — Chromium on Linux can
silently keep the PC in 'connected' state through a suspend cycle
without firing connectionstatechange. So we also probe on tab/lid
wake: if the sub PC's connectionState is anything other than
'connected'/'new'/'connecting', force the rebuild.
Both paths converge on the same teardown + re-subscribe so the SFU
sends a fresh initial SDP with every current publisher's tracks.
83 FSM tests still green; this is runtime-only and doesn't affect
the pure state machines yet.
Per fox: 'make unmario and cake murder adventure as tiles on the left
which always push down as stuff joins. this will allow people to play
games during a meeting.'
Layout change:
- game-tabs + iframe moved out of <main class="timeline"> and into
<aside id="sec-cameras"> as a third group (cameras → screens-thumbs
→ games), DOM order = stacking order, so a new camera or screen
thumbnail naturally pushes the games further down the column
- iframe sized to fit the 220px column (aspect-ratio: 4/3, ~165px tall),
tabs stack vertically as full-width buttons
- removed the timeline:has(> #sec-spotlight:not(.hidden)) → hide-games
rules — no longer needed since games aren't in the timeline anymore
- sec-cameras is always visible now (games live there permanently);
the 'cameras' h2 hides when no live cameras to avoid an empty header
- :has(> .cameras-col.hidden) page-grid rules stay as harmless dead code
Games + screen-share + cameras now coexist: someone can play unmario
in the corner while a colleague shares their screen at full size in
the middle.
Step 6 — first integration step. roomMachines is a real wireZebraMachines()
instance instantiated at module load (also exposed on window for DevTools
inspection). The imperative handlers still drive every side effect; this
commit only adds shadow events feeding the CallFSM so QA can see the
lifecycle in the page log AND inspect state in DevTools while we work.
Events wired:
- joinSpace → call.send('ENTER', { code, handle })
- welcome handler → call.send('WELCOME', { uuid, role })
- role-change for self → call.send('ROLE_CHANGE', { role })
- handleBlocked → call.send('BOOTED', { by: source })
- leave button → call.send('LEAVE')
- leave teardown → call.send('DONE')
An observer logs every CallFSM transition into the page log panel
('call: idle → connecting [ENTER]'), so when QA hits a defect we can
correlate the symptom with the exact state transition that fired (or
didn't fire — equally informative).
Tests stay green at 83. Next: shadow the SubscribeFSM + PublishFSMs
the same way (events only, no effects), then start swapping the
imperative paths to be FSM-driven.
Step five — composition layer. wireZebraMachines() returns a coherent
room:
- one CallFSM
- one SubscribeFSM
- three PublishFSMs (mic / screen / camera)
- lazy Map of RemoteTileFSMs created on first tileFor(kind, pubHex)
- tileLeft(pubHex) fans LEFT to every tile keyed by that publisher
Observers wire transitions between machines but the orchestrator
itself stays pure — no WebRTC, no DOM, no fetch. The page's runtime
layers its OWN observers on top to drive real side effects, and the
test extracts the orchestrator directly.
Cascades modelled:
- CallFSM joined (from anything except reconnecting) ── starts the sub
- CallFSM reconnecting → joined does NOT re-START (sub stayed alive)
- CallFSM leaving / booted ── stops sub AND every live publish
- RemoteTileFSMs lazy: tileFor returns the same instance per key
- tileLeft sends LEFT to every kind for that pubHex
+ 11 integration tests + 1 full end-to-end scenario walking through
host publishes mic+screen / listener joins late / listener sees the
screen / host unshares / mute+prune cycle removes the tile / listener
leaves and sub stops.
Total: 83 tests passing. The pure-FSM layer + orchestrator are now
ready to be wired into the imperative call sites in the live runtime.
That's the next step — gradually replace the firefighting code paths
(sfuPublishCamera, sfuSubscribe, role transitions) by feeding events
into these machines from the existing handlers, then observing
state changes to invoke the side effects. Tests catch regressions
on the pure layer while the QA loop catches what touches the wire.
Fourth state machine. Orchestrates the per-leg FSMs:
idle ──ENTER──▶ connecting ──WELCOME──▶ joined ──LEAVE──▶ leaving ──DONE──▶ idle
▲ │ FAILED │ ▲
│ ▼ │ WS_DROPPED │
│ idle ▼ │
│ reconnecting ──WELCOME──▶ joined │
│ │ LEAVE / FAILED │
│ ▼ │
│ leaving ────────────────────────────── ┘
│ ▲
│ │ ACK
└─────────────────────────────────── booted ◀── BOOTED ── (any live state)
Role lives in ctx (host / cohost / speaker / listener). ROLE_CHANGE
re-enters joined so observers fire on every promotion / demotion —
that's how the runtime decides whether to start mic+publish or stop
them, without needing a state per role permutation.
reconnecting handles signal-WS drops without tearing down the
SubscribeFSM or PublishFSMs (WebRTC PCs are independent of the WS).
booted is the explicit terminal for being kicked + ACK returns to
idle so the entry screen comes back.
+ 19 unit tests. test-fsm now 72 passed.
Next step: the integration layer — observers on each FSM that drive
the actual side effects, plus integration tests that compose multiple
FSMs (a CallFSM with SubscribeFSM + RemoteTileFSMs) to assert the
multi-machine interactions match what the live code does.
Third state machine. One instance per incoming screen/camera track,
keyed by kind+pubHex. Codifies the lifecycle:
inactive ──TRACK_ARRIVED──▶ receiving ──MUTED──▶ muted
▲ │
│ UNMUTED │ PRUNE / ENDED
└────────────────────┤
▼
removed
{receiving, muted} + ENDED → removed
* + LEFT → removed
MUTED is a debounce gate, not a deletion: UNMUTED within the runtime's
~1.5s window cancels the prune and stays receiving (transient network
blip). PRUNE fires from the runtime's setTimeout if still muted.
ENDED skips the debounce. LEFT (peer-left) wipes the tile from any
live state. TRACK_ARRIVED in receiving/muted swaps to the new stream
(publisher re-shared before our prune fired).
Removed is terminal — a re-share spins up a fresh FSM. entry into
removed nulls ctx.stream so the runtime can drop refs.
+ 15 unit tests covering happy path, debounce semantics, ENDED
short-circuit, LEFT from every state, re-share refresh, removed
terminality. test-fsm now reports 53 passed.
Second state machine. Models the SFU subscribe leg explicitly:
off ──START──▶ connecting ──CONNECTED──▶ subscribed
│ FAILED │ RENEG
▼ ▼
off renegotiating
│ RENEG_DONE / RENEG_FAILED
▼
subscribed
│ LOST
▼
reconnecting ──CONNECTED──▶ subscribed
│ STOP │ FAILED
▼ ▼
stopping off
│ DONE
▼
off
Renegotiation is its own state so concurrent SSE offers can't race
setRemoteDescription (the bug ae9721e patched imperatively with a
promise queue). A RENEG event during renegotiating parks the SDP on
ctx.pendingOffers; the runtime will drain that queue from an observer
when RENEG_DONE fires. RENEG_FAILED returns to subscribed without
killing the PC — the negotiation attempt is what failed, the channel
itself is still up.
LOST during renegotiating jumps straight to reconnecting (drops the
in-flight reneg cleanly; when the connection comes back the runtime
will re-deliver any still-relevant SDP via fresh RENEGs).
+ 15 new unit tests covering connect, queue, drops, teardown, illegal
transitions. test-fsm now reports 38 passed.
First step of the state-machine refactor. Same self-contained pattern
as the rest of the page — FSMs live inline in web/zebra-spaces.html so
the page-integrity stamp keeps working, and the tests extract them with
the same regex/brace-match technique web-protocol.test.js already uses
(page = source of truth, tests track the page).
Added:
- createFSM(spec): minimal state machine. spec.states[name] has optional
entry/exit hooks and an .on table mapping events → target (string) or
{ target, action }. Observers fire after each transition with
{ state, prev, ev, ctx }. No async in transitions; effects belong in
observers (which can call send() to advance the machine).
- publishSpec: pure transition table for the publish flow.
off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
│ FAILED │ FAILED │ STOP/LOST
▼ ▼ ▼
off stopping ◀──── stopping ──┘
│ DONE
▼
off
One instance per kind (mic / screen / camera). FAILED in negotiating
goes to stopping (not off) so any acquired stream/pc gets torn down.
- test/zebra-fsm.test.js: 23 unit tests covering framework semantics +
publishSpec happy path + error/cancel paths + illegal-transition
no-ops. Function-constructor scope handles const-leak; bare eval()
doesn't expose const declarations to the harness.
- Makefile: test-fsm target + included in test-all.
Next: SubscribeFSM, CallFSM, RemoteTileFSM. Then wire each into the
imperative call sites progressively, replacing the firefighting code.
The remote screen/camera thumb was freezing on the listener side after
the publisher stopped sharing. Root cause: when the SFU stops the
transceiver and renegotiates, browsers DON'T reliably fire 'ended' on
the remote track (Chromium half-fires, Firefox stays silent).
'mute' DOES fire when RTP stops arriving.
watchVideoTrackForRemoval(track, fn):
- 'ended' → remove immediately
- 'mute' → schedule remove in 1.5s
- 'unmute' → cancel pending remove (transient network blip ≠ unshare)
- already-muted at attach time → schedule remove immediately
Applied to both screen and camera ontrack paths. fixes the symptom in
both Firefox and Chromium without waiting for a hard refresh.
Pairs with proxy.unturf.com#9575418. Firefox enforces RFC 7941 msid
strictly: 1*64 token-chars, no ':'. Our streamID was pubkey + ':kind'
= 71 chars with an invalid separator, so Firefox silently dropped
every track and the listener saw only the game iframe.
Client parse now:
- format SHORT16HEX or SHORT16HEX-screen / SHORT16HEX-camera
- 16-char prefix resolved back to the full pubhex via member roster
- own-publish echo check matches by prefix (myKeys.pubHex.startsWith)
- screens / cameras keep using full pubhex as the map key so existing
identity-keyed code (renderScreenTile, removeCameraTile, screenStreams
map, etc.) doesn't have to change
When the host's mic device disappears (BT disconnect, USB unplug, OS
audio swap to laptop speakers), the active MediaStreamTrack ends but
the senders on every PC keep pointing at the dead track. Host goes
silent until a full leave+rejoin tears everything down and re-publishes.
Fix:
- watchMicTrack(track) listens for 'ended' on every mic track we hand
out (initial getMic + applyMicMode re-acquire)
- reacquireMic() gets a fresh getUserMedia with the same constraints,
then sender.replaceTrack on every live mesh peer + sfuPubPC.
No renegotiation — codec / SDP stays the same, just the track
swaps under the existing transceiver. Single in-flight guard so a
burst of devicechange events doesn't race.
- devicechange listener is now an active probe: if the current track
has gone readyState='ended' or muted=true since the last event,
trigger reacquireMic. Firefox in particular doesn't always fire
'ended' on BT swap — the track stays 'live' but emits silence.