Skip to content

feat(slides): Live broadcast - #293

Open
niemes wants to merge 38 commits into
nyblnet:mainfrom
niemes:live-broadcast
Open

feat(slides): Live broadcast#293
niemes wants to merge 38 commits into
nyblnet:mainfrom
niemes:live-broadcast

Conversation

@niemes

@niemes niemes commented Aug 10, 2026

Copy link
Copy Markdown

Live slide broadcast - present any deck online, no accounts for viewers

This implements live broadcasting for bento/slides: the presenter's current slide syncs to any number of viewers in near-real-time over the existing collab relay. Viewers need no account, no write access, no collaboration keys. The broadcast copy of the file is the whole capability.

What it adds

  • Share menu → "Save broadcast copy…" — exports a standalone viewer file of the current deck. It boots into a locked present-follow mode and runs the real renderer on its own copy of the document, so viewers see the same slides, transitions, and morphs as the presenter.
  • Speaker view → Broadcast toggle — arms the broadcast (off by default, every show), shows the live viewer count, and syncs navigation, laser pointer, and black screen.
  • The relay stays blind — the nav channel carries only a slide number (plus laser/black control), never content. The relay never holds a document.
  • No secrets in files or URLs — the room name and the connect token are both derived (sha256) from the presenter's signing key, so a broadcast copy carries no keys and a shareable link carries no token.
  • Write is impossible — control frames are ECDSA-signed and verified by the relay (trust-on-first-use key pinning per broadcast room); broadcast copies carry no keys at all.
  • The format stays additivecollab.broadcast is an optional field; existing files open unchanged.

The two sharing cases

Not hosted — the file is the link. A deck without a hosting URL exports a pure snapshot copy embedding only the room name and relay. Give the file to viewers: when you start presenting and arm the broadcast, every open copy connects straight to the room derived from your signing key and follows — nothing to share, no URL involved.

Hosted — a URL instead of the file. A deck with a hosting URL (Document properties → meta.hostClient) exports a copy that additionally carries reader credentials and joins the deck's collab room as a live reader replica, so slide content updates in real time as the deck is edited mid-show. The speaker view shows a full viewer URL (host?room=<name>) with a Copy button, and the same hosted copy can be re-pointed at any presenter's room via ?room= — a replacement presenter takes over without re-exporting files.

Laser pointer

The presenter laser got some work in this pass:

  • Draws only while the mouse is held down -> moving the pointer without a click never draws; a stroke starts on pointerdown over the slide and ends with a clean off frame on pointerup. For performance with the sync worker and it is also a more natural behavior.
  • A longer, smoother trail -> an ~0.8s fading sweep built from tapered quadratic segments (halo + core), so the tail reads as a sweep rather than a smear.
  • No text selection while drawing -> with the laser armed, dragging on the slide can never select text (user-select: none on the slide subtree). Text selection is not broadcasted, so endup as visual noise for the speaker.
  • Synced to viewers -> stroke points travel as slide-fraction coordinates (~30fps), so the audience sees the same dot and trail and including a fix for ghost strokes between mouse releases.
  • Added an svg based icon, because U+1F7D2 char dont render on macOS.

Relay

server/sync-worker gains broadcast control-frame handling (nav/laser/black with signature verification, per-room TOFU key pinning, lastNav/lastBlack replay for late joiners, presence counts).
The relay must be deployed (wrangler deploy).

Verification

  • node scripts/test-nav.mjs — 15/15: unsigned, forged, and member-key frames dropped; valid frames fanned; lastNav replayed ahead of live frames; presence rises on connect and falls on close; rate limiter intact; laser/black security.
  • tsc -b clean, npm run build:single OK; no CRDT changes (scripts/test-sync.ts untouched).
  • Manual two-tab session: the copy follows slide-for-slide with transitions, a mid-show joiner lands on the current slide, disarm quiets the feed, stale copies clamp instead of crashing.
  • New UI strings in all 8 locale catalogs.

Checklist

  • npm run build:single succeeds (from slides/)
  • Ran node scripts/test-sync.ts if I touched slides/src/sync/
  • New UI strings added to every catalog in slides/src/i18n/
  • Document format changes are additive and backward-compatible
  • Did not bump the version or cut a release (maintainers sign releases)

niemes added 30 commits August 9, 2026 01:43
…t 400/10s

Owner-signed plaintext control frames beside the collab path: {ctl:'nav',n,g}
(sig over nav.${n}), {ctl:'laser',p,g} / {ctl:'laser',off:1,g} (sig over
laser.${p} / laser.off, slide-fraction point, never stored), and
{ctl:'black',on:1|0,g} (sig over black.on/black.off, persisted as lastBlack
and replayed to late joiners). Every block verifies the signature against
the socket's OWN pinned key AND that the key hash-commits to the room name —
a member/chain socket's key never hash-matches, so only the room owner can
broadcast. RATE_BURST 200→400: a 30fps laser stroke is 300 frames/10s; the
count limiter is an abuse guard, laser frames are ~60 bytes so the byte
budget is the real cap.
BroadcastSocket: lightweight WS (viewer ?tok=&since=0, owner ?w=<pub>&tok=
&since=0), backoff 800ms×1.8 cap 30s, ping 25s; ignores everything except
nav/presence/laser/black frames so it safely replays ciphertext noise from
collab rooms. resolveBroadcastCreds: case 1 reuses the EXISTING collab room
and signs with the owner key; case 2 mints a device-local broadcast-only
room (bento-broadcast-<docId> in localStorage, fresh keypair + room key —
the private key never leaves the machine). Transport-agnostic sendNav/
sendLaser/sendBlack helpers + onCtl passthrough on OnlineTransport for the
directOwner path.
…ings

Share menu gains 'Broadcast copy…': exports a standalone .bento.html that
boots straight into present-follow mode, carrying only {room, tok, relay} —
no owner key, no symmetric key, no CRDT state. model.ts: collab.broadcast
field (additive, old shells ignore it) and room/key made optional for
broadcast-only copies. New broadcast icon + 13 UI strings across all 9
catalogs.
…ack screen, remote trail

Speaker view gains a broadcast arm/teardown toggle (📡): on arm, current
slide is sent and every slidechange fans out an owner-signed nav frame;
viewer-count badge + popup clipboard script. Laser became hold-to-draw (L
arms, pointer-down draws) and streams slide-fraction points at ~30fps
(33ms throttle, Excalidraw's CURSOR_SYNC_TIMEOUT) with an off frame on
release; black-screen toggle syncs and replays to late joiners. Broadcast
copies re-synthesize the trail Excalidraw-style: a rAF sampler feeds the
dot's RENDERED position into the shared trail ring buffer so the head stays
glued to the pointer (never pre-baked ahead of it), 0.8s fade, and a
re-shown dot snaps to the new stroke instead of tweening a ghost line
across the gap. B toggles black from the audience document too; text
selection is locked while the laser is armed.
Copies carrying doc.collab.broadcast boot straight into a locked follow-mode
viewer: real present overlay, read-only broadcast socket driving navigation,
status chip (connecting / waiting / live · N viewers). ?b=<viewer url> on
the copy's own URL re-points it at ANOTHER broadcaster's room — e.g. the
owner takes over a presentation the exporter can't do. The relay's auth is
a per-room trust-on-first-use token, so the room alone is not a capability:
the owner's broadcast link (relay + room + tok) must come along; everything
after ?b= is that link, raw. The copy stays a passive viewer — no signing
key, view access only. Malformed overrides fall back to the embedded room.
23 asserts against a local relay: unsigned/forged/member-key drops, valid
owner nav/laser/black fan-out, laser off, lastBlack replay to late joiners,
laser non-replay, rate limiter intact.
Builds two self-contained .bento.html fixtures (owner + broadcast copy)
minted with real case-1 collab credentials for manual testing against a
local relay.
docs/broadcast-design.md: nav-frame + broadcast-room model, threat model,
wire format. docs/broadcast-plan.md: 7-phase implementation plan with
verified anchors. DECISIONS.md: live-broadcast control channel (laser +
black frames) and the ?b= re-point override entries.
…eral

The setHost validation regex was written as /^https?:\/\//i inside the
bcastScript template literal, where \/ is an identity escape that emits
/, so the injected script contained /^https?:///i and failed to parse —
the popup never bound its message listener and the broadcast link row
stayed dead. Escape as \\/ in the template so the emitted script is a
valid regex literal.
Owner decks sign with the owner key, shared editor copies with their
per-copy invite key, legacy copies with the writer key, and non-collab
decks with a device-local key — so every presenter broadcasts into their
own room (the docId-derived room collided across invite copies). The
connect token is derived from the room name, so URLs and files carry no
secret; the relay TOFU-pins the signer key per room and verifies
nav/laser/black frames against it.
The row and viewer-count badge set display:flex/inline-block, which
overrode the UA's [hidden]{display:none} — the empty URL row stayed
visible with the broadcast off. The [hidden] variants now restate
display:none. The broadcast button also reuses the editor's broadcast
SVG icon instead of the 📡 emoji, and openSpeaker re-posts the link so
a popup reopened mid-broadcast shows it.
# Conflicts:
#	slides/src/i18n/de.ts
#	slides/src/i18n/es.ts
#	slides/src/i18n/fr.ts
#	slides/src/i18n/it.ts
#	slides/src/i18n/ja.ts
#	slides/src/i18n/packed.ts
#	slides/src/i18n/pt.ts
#	slides/src/i18n/zh-Hans.ts
#	slides/src/i18n/zh-Hant.ts
@niemes

niemes commented Aug 11, 2026

Copy link
Copy Markdown
Author

Small note for local testing, the sync worker variable DEFAULT_SYNC_HOST needs to be changed, its hardcoded in online.ts (maybe we could use an env variable, would be easier to config for people self hosting the worker)

@niemes niemes changed the title Live broadcast feat(slides): Live broadcast Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant