Maximize screen-share resolution on desktop/laptop:
- drop body max-width cap (was 1440px) so layout breathes to full viewport
- shrink right column from 520px to 360px; left column gets the surplus
- bump screen-tile video max-height from 80vh to 92vh
- tighter padding + gap
Cut four prose blocks to roughly a third:
- identity note: full key-management paragraph → 'key lives in this browser only'
- input note: full role/encryption paragraph → 'join as listener; host promotes to mic. end-to-end encrypted.'
- share note: full URL-embedding paragraph → 'share only with people you trust to hear the room.'
- screen-share note: full picker explanation → 'window or tab; tick share audio if offered.'
Mobile browsers refuse to autoplay a <video> carrying an unmuted audio
track without a user gesture; the whole element stays paused, so the
video pixels never render either. The phone listener saw a black tile
even with a healthy track flowing.
Start the remote tile muted so it autoplays everywhere; expose an
'unmute audio' button in the meta bar that toggles. The button click
counts as the gesture, so audio kicks in on demand. Local preview never
offers unmute (would feed back into the publisher's own mic).
The SFU does not echo a publisher's stream back, so anyone sharing alone
in a room had no visual confirmation — game iframe stayed up, no tile
appeared. Render a muted local preview keyed by myKeys.pubHex on publish,
remove on unpublish. Subscribe path skips own pubHex to defend against
SFUs that do echo publishers.
Also refreshes integrity stamps on the other web pages (date drift).
Previously sec-screens lived inside the controls aside, capped at 520px
wide — useless for any real screen view. Move it into the timeline
column at the top, where there's actual horizontal space. CSS :has()
on .timeline hides the game tabs + iframe whenever a screen tile is
visible, so the screen owns the full left column. As soon as the last
sharer stops, the game switcher returns.
UI:
- sec-screen-share: 'share screen' button visible only when canSpeak
(host/cohost/speaker). On click: getDisplayMedia({video, audio}).
- sec-screens: video tile per active screen share; auto-shows when
the first tile arrives, auto-hides when the last leaves.
- Each tile has a 'fullscreen' button.
Publish:
- sfuPublishScreen opens a SEPARATE sfuScreenPC, adds the
display-media tracks (video + optional audio), POSTs
/publish?kind=screen so the SFU's TrackLocal streamID gets a
':screen' suffix.
- Browser-native 'stop sharing' bar fires the video track's 'ended'
event — we trap it to do a clean sfuUnpublishScreen.
Subscribe:
- sfuSubscribe ontrack checks streams[0].id for the ':screen' suffix
— screen streams route to renderScreenTile (creates / updates a
<video> element). Mic streams continue down the existing audio
path. The label uses the publisher's handle from the room state.
- Cleanup paths: peer-left (drops the matching pubHex's tile),
self-boot, self-blocked, leave button — all call
sfuUnpublishScreen + removeScreenTile.
CSS: screen-tile has its own 1fr grid, max-height 70vh so a screen
share never bullies the controls column off the page on a small
display.
Pairs with the multi-track + kind=screen support that just landed in
zebra-spaces-sfu (b7c5a8a).
- Block is now terminal: any 'blocked' signal-server error or 403 from
the SFU calls handleBlocked() which shows a clear notice, stops the
WS reconnect loop, tears mesh + SFU + mic, and unlocks the enter
button. Stale UI no longer spins trying to rejoin a room the user
was kicked from. Reset on a fresh joinSpace.
- Share URL: after joining, sec-share shows a copyable link with
?code=… embedded; opening that link autofills the rendezvous code
field. QR canvas placeholder is in place; encoder lands in the next
push (defer to keep this commit small).
- host-your-own.html gains section 8: zebra-spaces (multi-party rooms)
describing both Go services that make spaces work — the
zebra-spaces-signal authority-chain rendezvous and the Pion-based
zebra-spaces-sfu audio fan-out. Includes the two Caddyfile routes
(with flush_interval -1 for SSE), the NAT1To1 env var, and the
single UDP mux port — so someone wanting to host their own community
can stand the whole stack up. Sections 9-11 renumbered.
Three issues fixed together:
1. Mobile horizontal scroll: dynamically-appended <audio> sinks render
their default ~300px control strip on Firefox mobile, pushing the
page wider than the viewport. Hide them globally with
'audio { display: none }' — they're just sinks for the WebRTC track,
no UI required.
2. Don't use literal 50/50 split: page grid is now
'minmax(0, 1fr) minmax(0, 520px)' so the controls cap at 520px and
the timeline soaks the remainder. min-width:0 on both children so
they shrink without forcing an outer scrollbar.
3. Mobile order: on phones the controls were stacked ABOVE the
timeline placeholder; fox wants them first. Use 'order: 1' on
.controls and 'order: 2' on .timeline inside the 800px breakpoint.
4. Host rejoin audio: when a host leaves and rejoins, Pion frequently
reuses the same transceiver on the SFU side, so 'ontrack' doesn't
fire a second time on the listener — but the listener already tore
down the audio element on peer-left. The audio plays into nothing.
Replace the pending-tracks queue with sfuStreamsByPubHex (keyed by
the publisher's pubkey, which is the streamID). The cached
MediaStream survives the host's session uuid changing across
leave/rejoin. On peer-joined / host-promoted / state we look up the
cached stream by pubkey and attach to the fresh audio element.
Also call a.play() after the srcObject swap so the autoplay policy
doesn't quietly drop the resumed playback.
Race when the host rejoined during their 30s grace window: the SFU
renegotiation that delivered the new mic track arrived over SSE BEFORE
the signal-server's peer-joined for the rejoined host. ontrack's
streams[0].id (= host's pubkey hex) had no matching member yet, so
the track was silently discarded — the listener saw no audio.
Cache the unmatched stream by pubkey hex and flush whenever the
roster updates (peer-joined, host-promoted, state). When the rejoined
host's peer-joined arrives, the queued stream pops out and attaches
to their row. Cleared on sfuUnsubscribe so stale streams don't carry
across rejoins.
space-closing flips the top status line to a red 'host left — space
closing in Ns' warning. Nothing was clearing it when the host actually
came back during grace (peer-joined with role=host) or when a senior
cohost auto-promoted (host-promoted). Both paths now reset the status
line to 'connected as <my role>' so the warning doesn't stick after
the rescue.
mic-state broadcasts only inform OTHER peers, so the local view of our
own member row stayed muted=false forever — the green mic-on icon
never flipped to the red cross when the host (or any speaker) muted.
Set members.get(myUUID).muted before sendMicState so renderRoom
reflects it immediately on our own row.
The 'give the mic' button (visible only when the listener has raised
their hand) now sends grant-mic, which the server promotes directly
without waiting for accept-mic. The 'invite mic' button (cold invite)
still triggers the existing mic-invite + accept dance.
Also fold in the earlier sfuPublish diagnostic logging so silent
returns surface in the page log.
Listeners now hear all speakers via a Pion-based audio-only SFU on
proxy.uncloseai.com. Speakers publish to it; listeners subscribe and
get one PC carrying every speaker's track. Mesh remains the
low-latency speaker<->speaker path; SFU is the broadcast fan-out.
Wiring:
- sfuPublish/Unpublish for speaker role transitions
- sfuSubscribe/Unsubscribe for listener role transitions
- onRoleEntered + onRoleChanged + leave hooks
- ontrack on the subscribe PC maps streams[0].id (= publisher pubkey
hex, set as the SFU TrackLocal StreamID) -> room member uuid; audio
element + meter attach to the matched member row
- SSE renegotiation: SFU pushes offer when speakers come/go; browser
answers via POST /answer
- applyMicMode now hot-swaps the SFU publish sender's track too, so
voice/music mode toggles apply over the SFU just like the mesh
Notice banner — fox flagged that booted/demoted users had no visible
signal. Added a #sec-notice section with .notice-banner (warn for
boot/demote-to-listener, info for promote). Boot also sets
wantConnected=false to prevent the WS auto-reconnect loop from
rejoining into a boot loop.
Two real defects in the meter wiring:
- the local mic was never fed to its meter — only remote streams were
- renderRoom replaces every member's meter DOM element, so the old
meterFor tick loop (which captured the OLD fill via closure) exited
on the next frame and the bar froze forever
Replace with a per-uuid meterCtl Map that holds the analyser + buffer
once and re-reads members.get(uuid)._meterEl fresh every animation
frame. Cleanup is automatic when the uuid drops from members (peer-
left, boot) or explicitly via stopMeter (dropMic, applyMicMode).
applyMicMode re-wires the local meter against the freshly acquired
stream since the old MediaStreamSource dies with the old track.
The grid was packing badge | handle | pubkey | mic | meter | buttons on
one line, which got crowded with three or four actions visible. Drop
mod-actions to a second grid row spanning the full width, indented
under the handle column. Hides itself entirely when empty so rows with
nothing to act on don't reserve dead space.
The control panel (identity, space, room, log) moves to a right column
occupying 50% of the page width. Left column is reserved for a timeline
to come; for now it shows a one-line placeholder. Stacks to single
column below 800px so mobile stays usable. Footer spans full width.
They were hidden behind the speaker role, so listeners (and the host
during the brief window before mic permission resolves) could not
disable echo/noise cancellation. Make both rows always visible; the
preference is picked up by micConstraints() the next time getMic runs,
so toggling before becoming a speaker is honored.
New page: web/zebra-spaces.html. Extends the rendezvous + WebRTC mesh
model from zebra-audio (1:1) to a room of up to ~8 speakers (host + 2
co-hosts + speakers) with listeners (deferred to v0.2 for SFU fan-out).
Identity: per-browser persistent Ed25519 keypair in localStorage,
plus a per-session UUID for in-room "whose who". Password-vault
backup/restore (PBKDF2 600k + AES-GCM, matches zebra-audio's crypto)
emits a self-contained 'zspc-id-v1|...' blob.
Roles: everyone joins as listener; mods (host + co-hosts) extend
mic-invites that listeners accept/decline; host promotes to co-host;
mods demote and boot. Co-hosts cannot remove the host. Every role
transition is signed by the acting mod's Ed25519 over a canonical
input bound to room_id + epoch, so a compromised relay cannot forge
promotions, only refuse to relay them.
Pairs with cmd/zebra-spaces-signal in proxy.unturf.com.
Nav: zebra-audio and host-your-own cross-link to spaces.
Makefile: stamp target now covers zebra-spaces.html (web/chat.html
and web/how-it-works.html stamps refreshed today as a side effect).