Commit graph

176 commits

Author SHA1 Message Date
23a4649b37
zebra-spaces: music-mode polish — 510k Opus, fullband pins, NACK, speaker picker
Codec headroom (Tier 1):
  - Opus 256 kbps → 510 kbps (spec ceiling) at every music-mode site:
    mic publish (SFU + mesh), screen audio, game-share audio. Both
    SDP maxaveragebitrate and RTP-level encodings[0].maxBitrate raised.
  - Pin Opus fullband: maxplaybackrate=48000, sprop-maxcapturerate=48000
    so BWE pressure can't opportunistically narrow to 16/24 kHz.
  - cbr=0 explicit (VBR — Opus only spends what it needs).

Loss resilience (Tier 2):
  - Audio NACK feedback (a=rtcp-fb:<opus_pt> nack) injected after Opus
    rtpmap. Reactive packet recovery, near-zero overhead, ignored by
    browsers that don't honor it.
  - a=maxptime:120 in music mode advertises tolerance for larger frames
    from peers (more encode context = cleaner music at same bitrate).

Playback chain (Tier 3):
  - New speaker output selector (setSinkId) so listeners can route peer
    audio to studio monitors / external DAC. Hidden on Safari (no
    setSinkId on HTMLMediaElement). Persists to localStorage; applied
    to every remote <audio> on creation and on user switch.

Live sessions need leave+enter to pick up the new SDP (per CLAUDE.md —
existing RTCPeerConnections are locked to whatever was negotiated at
creation time).

make stamp updates web/zebra-spaces.html footer date + hashes.
2026-06-03 17:45:50 -04:00
1dde43fc69
zebra-spaces: JS-bound heartbeat — 15s alive ping while joined
Server-side counterpart to proxy.unturf.com edb1009. Page sends
{type:"alive"} every 15s via setInterval on the signal WS; backgrounded
tabs throttle this timer hard (1Hz Android, ~1/minute iOS) so a
truly frozen tab reliably misses 3 heartbeats and the server's
aliveTTL=45s fires.

Pairs with the just-shipped pagehide-bye for the two distinct
failure modes:
 - clean tab-close → pagehide fires bye → instant strong-leave
 - dirty close / OS-backgrounded → JS heartbeat stops → server
   aliveTTL fires after ~45s → standard hiccup grace → peer-left.
2026-06-03 17:37:33 -04:00
740ed2bf7f
zebra-spaces: kick + ban buttons; pagehide sends 'bye'
Two moderation actions, two buttons:
  - kick : evict the session, allow rejoin
  - ban  : evict + block the pubkey (old 'boot' semantics)
Renders 'X was kicked by Y' vs 'X was banned by Y' off the new
peer-booted.action field. Self-notice text follows the same split.

pagehide / beforeunload now sends a synchronous 'bye' so a closed
tab counts as a strong-leave (peer-left + SFU evict immediately)
instead of waiting 8s for the hiccup grace. Fox 2026-06-03 —
"will closed tab on phone but the audio kept playing until i
kicked him out". With the new bye on pagehide his tab-close will
trigger the same fast-path the leave button does.

beforeunload kept as a fallback for older desktop browsers that
fire it before pagehide; pagehide is the cross-mobile primary.
2026-06-03 16:56:49 -04:00
fb7228b289
zebra-spaces: rebuild <video> on MSID supplant — autoplay grant resets
Live-room defect (2026-06-03): Will refreshed his browser; the
supplant fired a SECOND ontrack for kind=camera pub=Will at +16s.
The page swapped srcObject on the existing <video>, called play(),
got 'fetching process for the media resource was aborted by the
user agent at the user's request' — browser refused to start a new
playback session on the same element after its autoplay grant had
already been consumed. Tile sat black on the moderator's screen.

Fix: swapFreshVideoElement() — on supplant, replace the <video>
with a freshly-built one carrying the same attrs. A brand-new
<video> is eligible for muted-autoplay even when the prior one had
its play() rejected, so the supplant lands cleanly without needing
a tap. Applied to both the thumbnail and the spotlight tile.

Mesh test harness extracts the helper so renderVideoTile still
runs end-to-end under the sandbox.
2026-06-03 16:08:01 -04:00
3ca9042e54
zebra-spaces: boot-error logging + watchFirstFrame keyframe diagnostic
- moderation: 'boot' button onclick now .catch'es and logs server
   errors. Server now returns 'boot target not found (stale uuid?)'
   instead of silently no-op'ing when the page's member roster lagged
   the room — the moderator was clicking 'boot' and seeing nothing
   happen because their page held a stale uuid.

 - diagnostic: watchFirstFrame logs 'still black after 2500ms — no
   keyframe?' on any fresh SFU video track (screen/camera/game) whose
   decoder never unmutes. Pairs with the SFU's extended kfBurst so we
   can tell next session which path actually broke when a camera tile
   renders black.

multi-peer-mesh test harness extracts the new fn alongside
watchVideoTrackForRemoval so handleRemoteSfuTrack still runs end-to-end
in the headless sandbox.

Stamp date refresh on the other pages (no behavior change).
2026-06-03 15:51:18 -04:00
867333f6a5
zebra-spaces: exponential backoff + give-up cap on mesh PC retry
Telemetry showed Willdabeast's mesh PC (peer edc257b4...) failing every
~20s in a perfect loop for 4+ minutes — the old 1.5s flat retry was
firing connectToPeer over and over against a NAT we couldn't traverse.
Each cycle ate CPU, network, signaling churn, and contributed to the
robot-voice + lost-mic noise we've been chasing.

New behavior:
- 2s -> 4s -> 8s -> 16s backoff (capped) between attempts
- Hard cap at PEER_MESH_MAX_RETRIES = 4 attempts. After that,
  peerMeshGiveUp.add(uuid): connectToPeer becomes a no-op for that
  uuid and audio rides on SFU permanently.
- Successful 'connected' transition resets the retry counter so a
  much-later transient blip gets a fresh budget.
- peer-left / peer-booted clear all retry state via
  clearMeshRetryState() so a rejoin from the same uuid starts over.

Log lines surface the decision so the next pathological mesh peer
shows up in CLIENT_LOG as 'mesh failed Nx — giving up, audio stays
on SFU' rather than a wall of identical 'failed — reconnecting' lines.

Side benefit: less mesh churn means the SFU mic fallback path (the
2f1e481 + 441c086 fix) gets to settle and stay settled, so receivers
don't keep flipping their audio elements between mesh and SFU
streams under a failing peer.
2026-06-03 15:15:48 -04:00
94e11fea48
zebra-spaces: latency + link move to the full-width footer band
Were in the right column inside .controls, which on mobile cramped the
latency rows and squeezed the share URL. Now they sit as siblings of
sec-log below the .page grid — full viewport width everywhere. Order
top-to-bottom under .page:

  latency
  link
  log

Same DOM whether the controls panel is shown or hidden, so the
mobile + hide-panel layout no longer needs the cameras column to
absorb them awkwardly.
2026-06-03 14:56:37 -04:00
f1b7dc4330
zebra-spaces: relocate role-transition notice banner below 'your hand'
The role-change notices (you-are-now-a-speaker / stepped-down / moved-
to-listener / promoted-to-cohost / now-the-host) sat above the room
list — top-of-page real estate for what's actually a personal status
update. Move sec-notice to sit BELOW sec-listener-actions so the
message reads in the same gaze as the controls it affects.
2026-06-03 14:47:18 -04:00
6c93549685
zebra-spaces: preserve mute state across explicit leave/rejoin
Was: leave button cleared MUTE_STATE_KEY, so a rejoin via bless-reclaim
came back unmuted regardless of prior choice. Fox: 'when I leave and
rejoin the mic state is unmuted.'

Now: keep MUTE_STATE_KEY in sessionStorage on leave (same tab, same
identity → same preference). Next promotion or bless-reclaim restores
via applyMuteState, matching the hard-refresh path's behavior. The
local in-page `muted` variable still syncs to whatever's in storage
so the dormant-tab state is consistent.
2026-06-03 14:43:50 -04:00
cb642238ed
zebra-spaces: leave + step-down chimes
Two more Web Audio tones, completing the doorman set:
- playToneLeave: E5 -> C5 descending major third (mirror of join's
  C5 -> E5 ascending). Fires on peer-left (any role leaves the
  room post hiccup-grace) and peer-booted (mod kicks someone),
  skipping self on the boot case.
- playToneStepDown: single G4 low sine blip — softer than the
  arrival/departure pair. Fires on role-change for OTHER peers
  when canSpeak(prev) && !canSpeak(next): they're still in the
  room, just lost mic privileges.

Full notification ladder:
  C5 -> E5     guest arrived
  A5 (triangle) hand raised
  G4            speaker stepped down (still here)
  E5 -> C5     guest left / was kicked
2026-06-03 14:42:57 -04:00
fb9cfbbd55
zebra-spaces: chimes ring in background tabs too (doorman use case)
Was: tones skipped when document.hidden. That's wrong for the doorman
cohost use case fox flagged — the whole point is to know a guest just
arrived without watching the tab.

Remove the document.hidden gate. Also defensively resume() the
AudioContext if it slipped to 'suspended' (some mobile browsers
suspend on tab-hide) — keeps the timeline alive so the scheduled tone
actually fires.
2026-06-03 13:21:16 -04:00
1920cf6a55
zebra-spaces: bump playoutDelayHint 400ms -> 700ms for cross-Wi-Fi voice
Reports of robot-voice artifacts from a peer on a different Wi-Fi —
classic symptom of jitter-buffer under-runs (Opus PLC kicks in,
generates synthetic samples that sound vocoder-y). 400ms was tight
enough that bursty inter-arrival on cross-AP / cellular hand-off
networks would exhaust the cushion.

700ms costs barely-audible end-to-end latency (well under the 1s
people perceive as 'walkie talkie'), well above typical Wi-Fi peak
jitter, and matches both the SFU subscribe path and mesh peer path
so the room sounds consistent regardless of how the audio is being
delivered.

Bitrate / FEC / DTX settings unchanged — adding bandwidth would
make a lossy link worse, not better.
2026-06-03 13:19:38 -04:00
79ee317ccf
zebra-spaces: classic chimes — guest-joined + hand-raised
Two Web Audio synthesised tones, no asset shipping required:
- playToneJoin: C5 -> E5 ascending major-third chime (~340ms total),
  fires on every peer-joined. Server broadcasts peer-joined to
  everyone except the joiner via broadcastExcept, so existing members
  always hear it for new arrivals only.
- playToneRaise: single A5 triangle blip (~260ms), fires on
  hand-raised UNLESS it's the raiser's own hand (server broadcasts
  hand-raised to all so we filter on m.uuid !== myUUID).

Tones share a lazily-instantiated AudioContext (the user already
gestured to enter the room, autoplay policy is satisfied). Skipped
when document.hidden so background tabs stay quiet. Short
exponential attack + decay envelope avoids click artifacts.
2026-06-03 13:18:19 -04:00
441c08631a
zebra-spaces: log mesh-vs-SFU mic decision so telemetry shows the branch
Two new logLine calls in the SFU mic ontrack handler:
- 'sfu mic skipped for <uuid> — mesh peer connected' when the mesh is
  serving (expected silence)
- 'sfu mic taking over for <uuid> — mesh state=<failed|disconnected|
  closed|...>' when SFU stream attaches over a dying mesh PC

Makes the 'why don't I hear them' question one grep away in CLIENT_LOG
next time the cascade happens.
2026-06-03 12:57:28 -04:00
2f1e4813d2
zebra-spaces: SFU mic fallback when mesh PC is present-but-not-connected
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'.
2026-06-03 12:50:50 -04:00
1e8464e7af
zebra-spaces: don't auto-rebuild screen publish PC — needs user gesture
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.
2026-06-03 12:45:38 -04:00
e01755d9e8
zebra-spaces: drop tiles on peer-booted, same path as role-demote
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.
2026-06-03 12:44:21 -04:00
1e94fa6812
zebra-spaces: drop tiles of demoted-to-listener peers immediately
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.
2026-06-03 12:38:55 -04:00
c76cb397ba
zebra-spaces: sign + verify mic-state, anti-replay window
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.
2026-06-03 12:36:14 -04:00
236661b573
zebra-spaces: ask room to re-announce mic-state on welcome + new peer-joined
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.
2026-06-03 12:33:35 -04:00
b4e0c3d2d6
zebra-spaces: drop screen-resume banner; richer picker hints on share
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.
2026-06-03 12:29:49 -04:00
35908cf898
zebra-spaces: remember screen-share across refresh, one-click resume banner
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.
2026-06-03 12:24:50 -04:00
8750f938d2
zebra-spaces: explain 'you join muted' next to raise-hand UI 2026-06-03 12:15:56 -04:00
063233f53c
zebra-spaces: new promotions start muted by default
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.
2026-06-03 12:14:45 -04:00
07fcec7775
zebra-spaces: refactor sub PC ontrack into handleRemoteSfuTrack + multi-peer mesh tests
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.
2026-06-03 11:24:38 -04:00
27fea30680
zebra-spaces: log clicks on share/stop buttons + their state
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.
2026-06-03 11:19:04 -04:00
ce6242fff7
zebra-spaces: forward logLine to signal as 'client-log' for backdoor telemetry
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.
2026-06-03 11:02:42 -04:00
d5e9e4eb02
zebra-spaces: wrap each remote video track in a fresh MediaStream
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.
2026-06-03 11:01:03 -04:00
d8c8dae4ba
zebra-spaces: bump camera mute window 15s -> 60s for phone-network jitter
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.
2026-06-03 10:57:05 -04:00
56b7bfb187
zebra-spaces: send 'bye' on explicit leave so server skips hiccup grace
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.
2026-06-03 10:43:15 -04:00
0878996e96
zebra-spaces: 120s mute window for screen + game (static content), 15s stays for camera
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.
2026-06-03 10:33:28 -04:00
9419a415bf
test: state-machine tests for watchVideoTrackForRemoval
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.
2026-06-03 10:15:16 -04:00
bb1bf56527
zebra-spaces: bump video-track mute-removal window 3s -> 15s
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.
2026-06-03 10:11:23 -04:00
415e6e27c9
zebra-spaces: full pubkey row under handle, above backup/logout
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.
2026-06-03 09:59:06 -04:00
8e301070a1
zebra-spaces: hide-panel on mobile keeps the single-column stack
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.
2026-06-03 08:16:09 -04:00
21a6abe082
zebra-spaces: lighten the 'viewing' thumb overlay so the stream stays legible
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.
2026-06-03 07:20:03 -04:00
4e2ce97e50
zebra-spaces: mobile thumbs span full viewport row instead of 2-up columns 2026-06-02 19:28:15 -04:00
52efb5db38
zebra-spaces: hide-panel — thumbnails fill the freed horizontal width
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.
2026-06-02 19:25:14 -04:00
fc11aee569
zebra-spaces: re-acquire mobile camera on device orientation change
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.
2026-06-02 19:01:10 -04:00
6af83eb896
zebra-spaces: request landscape camera + letterbox portrait frames
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.
2026-06-02 18:35:31 -04:00
666ba8e88a
zebra-spaces: don't truncate handles — wrap instead of ellipsis 2026-06-02 18:02:40 -04:00
cd98bc6603
zebra-spaces: log-out confirm spells out the irrecoverable consequences
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.
2026-06-02 17:51:55 -04:00
68dd6e333c
zebra-spaces: drop 'input' label; prefix dropdown options with 'input ' 2026-06-02 17:51:10 -04:00
40cc4a5598
zebra-spaces: mute state survives hard refresh
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.
2026-06-02 17:49:34 -04:00
8631c106d2
zebra-spaces: auto-rebuild publish PCs on ICE failure (mic/screen/camera)
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.
2026-06-02 17:37:14 -04:00
3a6b80e3f4
zebra-spaces: drop cohost-succession soft warning — room dies on host-grace expiry
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.
2026-06-02 16:16:25 -04:00
f45e9563aa
zebra-spaces: room list — handles don't wrap, full pubkey on its own row(s)
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.
2026-06-02 16:02:39 -04:00
3041a1391a
zebra-spaces: don't let an old camera/screen/game track's removal evict the new tile
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.
2026-06-02 15:34:56 -04:00
438e886627
zebra-spaces: surface 'host away — cohost succession in Xs' vs 'space closing'
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.
2026-06-02 15:31:21 -04:00
72d5208493
zebra-spaces: log full pubkey on every role / spotlight / boot / join / leave event
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.
2026-06-02 15:06:42 -04:00