CLIENT_LOG telemetry showed the phone re-promoted to speaker, published
to SFU successfully, but neither the host nor the other speaker heard
them. Cause: the page's ontrack handler skipped attaching SFU mic
whenever peers.has(uuid) — even if that peer's mesh PC was in
'failed' or 'disconnected' state from an earlier role-change cycle.
Fix two paths:
1. ontrack-side: only skip SFU mic when peers.get(uuid).connectionState
is actually 'connected'. A stale entry or a failing PC no longer
blocks the SFU fallback; receiver hears the publisher via SFU until
mesh actually delivers.
2. mesh-fails-side: when an existing mesh PC transitions to 'failed',
the audio element was bound to the dying mesh stream. Reach into
sfuStreamsByPubHex and re-attach the cached SFU stream so the
listener hears continuous audio while the mesh reconnect runs in
the background, instead of a silent gap.
Mesh stays the preferred path when it's actually working — only
takes over the audio binding via its own ontrack when 'connected'.
Bug surfaced in CLIENT_LOG telemetry from a real session: when the
host's screen publish PC hit ICE failure, the watchPublishPC auto-
rebuilder called sfuPublishScreen() which immediately failed with
'getDisplayMedia requires transient activation from a user gesture.'
The auto-call path has no click, so getDisplayMedia can never succeed
from there.
Match the game-share pattern: on 'failed' tear down the dead PC + log
a clear 'tap share screen to re-share' message + clear state via
sfuUnpublishScreen(). User clicks the share button → picker opens (the
click IS the gesture) → fresh publish PC.
Mic + camera keep their auto-rebuild because getUserMedia honors the
persisted permission grant, no gesture needed.
Boot is the noisier sibling of demote — booted users can't publish
either, so their video tiles must come down immediately. The SFU-side
eviction (server /internal/block + OnConnectionStateChange) propagates
'ended' eventually but lags enough that a kicked speaker's camera tile
stayed visible after the boot. Authoritative removal by pubkey, same
pattern as the role-change-demote fix from 1e94fa6.
Was: when a speaker was bounced to listener their tiles froze on every
other peer until either the SFU's removePublisher path finally fired
'ended' on the subscriber's tracks or the 60s/120s mute window reaped
them. Pion's OnConnectionStateChange can lag and SSE renegotiation can
race, leaving visibly-frozen tiles for tens of seconds.
Authoritative removal: when role-change strips speak rights from
peer X (canSpeak(prev) && !canSpeak(next)), drop every kind of X's
video tile on the receiver side immediately. The demoted user can't
publish anymore by definition, so the tiles are guaranteed-stale —
no need to wait for the data-plane path to catch up.
Mirrors the existing peer-left handler which removes by pubkey.
Three-layer defense for the mic-state channel:
1. Authenticity: send signs over 'mic-state|' + room_id + payload
with the identity ed25519 key. Receivers verify against the
member's roster pubkey (which the server tied to me.uuid at join
time). Missing or invalid sig -> drop the message + log err line
to telemetry.
2. Freshness: payload carries a wall-clock timestamp. Receivers reject
anything older than 30s in either direction (covers clock drift).
3. Monotonicity: each peer's last-accepted timestamp is tracked on
mm._micT; an old or equal-timestamp message is silently dropped,
so a replay of a previously-valid mic-state can't reset state to
a stale value.
Pairs with signal 59ad814.
Pairs with signal 33dcabf. Fixes the 'host refreshed and now sees
everyone as unmuted' UX gap. Two paths:
- On welcome (any rejoin): send mic-state-req; every speaker in the
room responds with their current mic-state.
- On peer-joined for anyone else: if I'm a speaker with a live mic,
re-broadcast my own state so the new arrival sees it without
having to ping.
Both are bounded: only speakers respond, and the message is the same
E2E-encrypted blob used for normal mic toggles, so no extra privacy
surface beyond what already exists.
Reverts 35908cf's resume banner. The browser security model makes
getDisplayMedia ALWAYS show the picker — selection can't be remembered
across reload — so a resume banner adds an extra click without saving
the user from anything. Single 'share screen' button stays the canonical
path.
Add picker hints on getDisplayMedia that Chrome honors (Firefox / Safari
silently ignore — no behavior change):
surfaceSwitching: 'include' — show the in-stream switcher widget
selfBrowserSurface: 'include' — let the user pick this tab if useful
systemAudio: 'include' — surface the tab-audio toggle by default
Net result: same gesture flow as before the resume experiment, slightly
friendlier picker on Chrome, no extra UI.
Camera silently auto-resumes on refresh (getUserMedia keeps the
permission grant); screen-share couldn't because getDisplayMedia
requires transient activation — a fresh user gesture. The previous
attempt fired in a setTimeout after welcome, which has no gesture, so
the browser instantly returned NotAllowedError. We then dropped
screen-state tracking entirely.
This time: track ACTIVE_SCREEN_KEY in sessionStorage when sfuPublishScreen
succeeds; on welcome, if the user can speak AND the key is set,
unhide #sec-screen-resume — a banner with a single 'resume screen
share' button. The button click is the gesture; its handler calls
sfuPublishScreen() synchronously, and getDisplayMedia is the first
await inside, so the transient-activation flag is preserved through
the chain (same shape as the regular 'share screen' button, which
proves the mechanic works every day).
UX:
- Camera: silent resume on welcome (unchanged)
- Screen: banner appears, user taps once, picker opens
- Mobile: getDisplayMedia missing — the resume button rejects with a
logLine error and self-clears the key (handled by .catch on the
resume click)
Key clears on explicit unshare + on demote to listener + on rejection,
so the banner doesn't linger after the underlying state changes.
Listener -> speaker/cohost/host transition is a fresh mic grab — no
hot-mic surprise. Set muted=true + persist to sessionStorage BEFORE
ensureMicAndUI runs, so applyMuteState picks it up cleanly when the
mic track is acquired. User can click 'unmute' when ready.
Doesn't affect bless-reclaim rejoin (welcome path), where the user's
previously-saved mute state is restored — they come back in the same
mute state they last chose.
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.