Answering "can live use WebTorrent?": classic WebTorrent can't carry live (its infohash needs the complete file), but P2P *live* is real via HLS + peer segment sharing. - live-ingest-plan: new "P2P-assisted playback" section — P2P Media Loader (hls.js-compatible, historically WebTorrent-tracker-based) shares HLS segments over WebRTC to offload origin bandwidth. Gated on real concurrency (DECISION 1's whitelist scope makes live sparse), so v1 = plain hls.js, P2P later. VOD keeps full WebTorrent. - live-publish-plan: new "Why BitVid Live — advantages" section (P2P offload, full-WebTorrent VODs, Nostr-native/no-lock-in, built-in zaps, BYO storage/relays/ self-host, one-canonical-stream+mirrors, archive = first-class video, self- sovereign identity). Playback section cross-references the P2P offload + VOD WebTorrent. Co-Authored-By: Claude Opus 4.8 <[email protected]>
19 KiB
Live Streams — INGEST / watch-only — Dev Plan
TODO ref: #16 in todo/TODO_2026-06-20_pre-launch.md (watch-only).
Publishing / "Go Live" is #16c — a separate, larger effort with its own
config flag; it is explicitly out of scope for this doc.
Status: DECISIONS LOCKED (D1–D5) — Phase 0 research spike first (confirm tag
shapes + hls.js/CORS + default relays), then build. Not started.
Live streams are NIP-53 Live Activities — a different NIP from short-form
video (#16b / NIP-71). Kind 30311 is the live event (addressable, carries the
streaming URL — usually HLS .m3u8 — plus status, title, host p tags);
kind 1311 is live chat. zap.stream and shosho.live publish these.
Scope of this feature: discover live streams, play the HLS stream, chat (read → post, phased), and zap the host. No broadcasting, no media hosting. Everything ships behind a config flag that leaves no trace when off.
Decisions needed
DECISION 1 — Listing scope. ✅ LOCKED: Option A (whitelisted hosts only). The Live tab lists only kind-30311 events whose host is a whitelisted/trusted author, reusing the existing whitelist/WoT filter — no new moderation surface, consistent with the main feed + Shorts. Accepted trade-off (maintainer): live is sparse, so the tab may be empty when no whitelisted creator is streaming; that's expected with a whitelist (ship a clear "no one you follow is live right now" empty state). A broader "discover live" surface can come later.
DECISION 2 — Chat level. ✅ LOCKED: read + post + zaps as the end state, reached in phases. Target is full participation (read chat, post chat, zap the host). Phased so playback ships first and posting (a write path) lands cleanly:
- Phase 3 — read-only kind-1311 chat + host zaps (zaps via the existing NWC/zap system; active participation from day one of chat).
- Phase 3b — post chat: sign/publish kind-1311 into the stream, behind its own sub-flag (
FEATURE_LIVE_CHAT_POSTor similar), with a moderation stance (rate-limit; you're publishing into someone else's stream — decide how blocked/muted authors and your own outbound spam are handled). Rationale (maintainer): end up at read+post+zaps; phasing keeps the write path and its moderation questions out of the initial playback release.
DECISION 3 — HLS playback dependency. ✅ LOCKED: Option A (hls.js, lazy-loaded behind the flag). Native
<video>HLS only works in Safari, so a library is required for Chrome/Firefox/Edge; hls.js is the mature standard. Maintainer deferred to recommendation. Implementation guardrails:
- Vendored, not a runtime CDN import (bitvid ships assets locally; keeps it working offline/self-hosted and avoids a third-party origin dependency).
- Dynamically imported only when the live player opens — never loaded when
FEATURE_LIVE_INGESTis off (honors "off = no trace"), and not on the critical path for non-live pages.- Prefer native HLS where available (Safari) and fall back to hls.js elsewhere, so Safari users don't pay for the library.
- Precedent: bitvid already ships WebTorrent (a much heavier dep), so one small well-established HLS lib is proportionate.
DECISION 4 — Past streams / VOD presentation. ✅ LOCKED: Option A (one Live tab: live first, then recent ended). Currently-live streams sort to the top; recently-ended streams follow, clearly badged "Ended" and playing their
recordingVOD (normal scrubber) through the same HLS path. Bonus: this mitigates the sparse-live empty-tab concern from DECISION 1 — a whitelisted creator's recent past streams keep the tab populated between live sessions. A separate "Past streams" section only if the single list gets crowded.
DECISION 5 — Discovery relays. ✅ LOCKED: Option B (user read relays + a configurable set of live-discovery relays). Query kind-30311 on the user's read relays PLUS a small instance-config list of known stream relays (default a couple of well-known ones, e.g. zap.stream's relay; editable per deployment). Widens where we look without widening who — DECISION 1's whitelist still gates the hosts shown. Extra relays are only connected when
FEATURE_LIVE_INGESTis on, capped/deferred per the relay-storm invariants. Phase 0 confirms which stream relays to ship as defaults.
Config flags (off = no trace)
Flags introduced by this doc (both default false, threaded through
js/config.js → js/constants.js like FEATURE_NIP71_INGEST):
FEATURE_LIVE_INGEST— the whole watch-only feature (tab, discovery, playback, read chat, host zaps).FEATURE_LIVE_CHAT_POST— the Phase 3b write path (posting chat); only meaningful when ingest is also on. Kept separate so an instance can allow watching + reading chat without enabling outbound posting.
(The FEATURE_LIVE_PUBLISH "Go Live" flag belongs to #16c, a separate doc.)
When FEATURE_LIVE_INGEST is off, all of the following must be absent:
- Sidebar tab — the "Live" link is not rendered in
components/sidebar.html. - Feed/view registration —
FEED_TYPES.LIVEis not registered. - Subscriptions — no kind-30311 discovery subscription and no kind-1311 chat subscription are ever created.
- Route —
#view=livefalls back to the default view. - HLS library — hls.js is dynamically imported only inside the live player, so it never loads when the flag is off (keeps the bundle/network clean).
- Chat-post UI — the compose box (Phase 3b) is absent unless BOTH
FEATURE_LIVE_INGESTandFEATURE_LIVE_CHAT_POSTare on. - Config surface — the flags above are the only footprint.
Management UI note: a "Live" sub-tab in profile → My Videos (alongside Videos / Shorts, reusing the admin sub-tab pattern) is for managing the user's OWN streams — so it belongs to publishing (#16c /
FEATURE_LIVE_PUBLISH), not this ingest feature. With ingest-only enabled there is nothing of the user's to manage, so no My-Videos Live sub-tab appears. (See the shorts plan's "Publish & management UI" section for the shared sub-tab treatment.)
Field mapping — kind 30311 (to VERIFY in Phase 0)
Expected tag shape (confirm against captured real events before building):
| Tag | Purpose |
|---|---|
d |
unique identifier (addressable key with pubkey+kind) |
title / summary |
stream title / description |
streaming |
HLS .m3u8 playback URL (may appear more than once) |
recording |
VOD URL when ended |
status |
planned / live / ended |
starts / ends |
unix timestamps |
image |
thumbnail/poster |
p (role host/participant) |
host + participants (pubkeys) |
service |
provider hint (zap.stream etc.) |
current_participants / total_participants |
viewer counts (optional) |
Kind 1311 (live chat): a note tagged with the 30311 a-address; content is the
chat message. Read-only ingest = subscribe by #a, render newest.
These are the documented shapes; Phase 0 captures real events from zap.stream and shosho.live to confirm exact keys, multiplicity of
streaming, and how each provider marks live vs ended.
Architecture
- Flag:
FEATURE_LIVE_INGEST(instance-config → config → constants). - Feed type: add
LIVE: "live"toFEED_TYPES(js/constants.js). - Discovery source: a new live service (e.g.
js/services/liveIngestService.js) subscribing to kind 30311 (status=live, scoped per DECISION 1), normalizing events into a lightweight "live card" model (title, host, image, streaming URL, status, viewer count). Mirrors the deferred/throttled pattern ofnip71IngestServiceso it stays off the cold-start critical path. - Sidebar tab + view: "Live" link in
components/sidebar.html(flag-gated) +views/live.html(grid of live cards). - Card design (new): a live card distinct from
VideoCard.js— a LIVE badge, viewer count, host name/avatar, and an "ended" state for past streams (Phase 4). Likely a dedicatedLiveCard(the live/ended metadata differs enough from a video card that a variant is awkward). Newdata-testidfor e2e. - Player: a live player path. The current player
(
js/services/playbackService.js) setsvideoEl.srcfor URL/WebTorrent; HLS needs hls.js attached to the<video>element when the source is.m3u8(native only on Safari). Lazy-load hls.js on first live play (DECISION 3). - Chat (DECISION 2): read-only kind-1311 subscription by
#a(Phase 3), rendered beside the player with WoT-mute filtering; a compose/post path in Phase 3b behindFEATURE_LIVE_CHAT_POST; teardown on close. - Liveness/staleness: an event with
status=livebut no recent update / dead.m3u8should be de-emphasized or dropped (see Risks).
P2P-assisted playback (future — bitvid's bandwidth edge)
Q: "Can live use WebTorrent?" Classic WebTorrent can't carry a live stream — a torrent's infohash is computed from the complete file, and live content doesn't exist yet. bitvid's WebTorrent is progressive playback of a finished VOD, not live. But P2P live is real via a different mechanism: HLS + peer-to-peer segment sharing. hls.js loads segments while a WebRTC swarm shares them among concurrent viewers, so the origin serves each segment fewer times.
- Concrete stack: P2P Media Loader — hls.js-compatible, and historically used WebTorrent trackers for peer discovery. It's a drop-in segment engine for the Phase-2 hls.js player: the "WebTorrent-flavored" path for live.
- bitvid advantage: every extra concurrent viewer helps serve the stream instead of only consuming origin bandwidth — cutting the Media Node's egress (the biggest cost/risk of #16c) and matching bitvid's P2P ethos.
- Why NOT v1: P2P offload only pays off with enough concurrent viewers of the same stream. DECISION 1 scopes live to whitelisted hosts → likely low-concurrency/sparse, where a swarm adds complexity + a little latency (on top of HLS's 6–30s) for little gain. v1 = plain hls.js; add P2P-assisted HLS later, gated on real concurrency.
- VOD keeps full WebTorrent: once a stream ends, its recording is an ordinary bitvid video (complete file → infohash) and gets the normal seed/webseed treatment (see the publish plan's archive→VOD flow).
Live watch UX best practices (researched July 2026)
Unlike shorts, live is a landscape 16:9, "cinematic" experience, and the watch page is a well-established pattern (Twitch / YouTube Live). Sources at the bottom.
Desktop presentation (cinematic)
- Player + chat side rail. 16:9 player on the left/center, a collapsible chat rail on the right. Below the player: title, host (avatar + name + follow/ subscribe), LIVE badge + viewer count, elapsed time, description/tags.
- Theater mode (player widens, chat stays docked) and fullscreen (chat becomes an optional overlay or hides) — both standard viewer expectations.
- Keyboard: Space pause, F fullscreen, T theater, M mute.
Mobile presentation
- Player on top (16:9), chat below in a scrollable panel. In landscape / fullscreen, chat becomes a resizable/draggable overlay (the current Twitch/ YouTube mobile pattern) rather than eating the video. Picture-in-picture "keep playing while I browse" is a nice-to-have, not v1.
Live-specific affordances
- Prominent red LIVE badge + viewer count (from 30311
current_participants) and elapsed-since-starts. These are retention cues, keep them visible. - Latency is real and must be expected: standard HLS is ~6–30s behind real-time (LL-HLS 1–5s, WebRTC <500ms). zap.stream serves HLS, so chat will run ahead of the video — don't promise low-latency, and don't try to hard-sync chat to the frame.
- Auto-scrolling chat that pauses when the user scrolls up, with a "jump to latest" affordance (Phase 3).
- Live vs ended seeking: a live stream has only edge/limited seeking; an
ended stream plays its
recordingas a normal VOD with a full scrubber (Phase 4).
bitvid-specific notes
- Zaps fit live perfectly — zap.stream's whole model is zapping streamers, and
bitvid already has the NWC/zap system. Live zaps to the host are a natural
headline action (surface them alongside chat). Even if chat is read-only in
Phase 3, zapping the host can ship earlier. Zap target: resolve from the
host's profile
lud16/LNURL, or a zap tag on the 30311 event if present (confirm in Phase 0); reuse the existing zap flow — no new payment code. - Reuse the existing player shell where practical; HLS via lazy hls.js (DECISION 3).
- Respect
prefers-reduced-motionand the relay-cap / circuit-breaker invariants for the discovery + chat subscriptions.
Phases (each flag-gated from day one)
- Phase 0 — Research spike (do FIRST, small but essential).
- Capture real kind-30311 events from zap.stream + shosho.live; confirm tags,
streamingmultiplicity, live/ended marking, hostproles. - Prototype HLS playback in a throwaway page: hls.js attaching to
<video>with a real.m3u8; confirm CORS behavior from the static origin. - Pick the default live-discovery relays (DECISION 5) from where real
30311 events actually land; resolve the host zap target (lud16/LNURL vs a
zaptag on the event). - Output: confirm the field mapping + defaults (decisions are already locked); flag any surprises back into this doc.
- Capture real kind-30311 events from zap.stream + shosho.live; confirm tags,
- Phase 1 — Discovery + Live tab (medium). Flag,
FEED_TYPES.LIVE, live service (30311 discovery, scoped), sidebar link,views/live.html, grid of live cards, route gated by flag. Clicking a card opens… (Phase 2). - Phase 2 — HLS playback + cinematic layout (medium). Live player path + lazy
hls.js; play the
streamingURL; desktop player + collapsible chat rail (rail empty until Phase 3), theater/fullscreen, LIVE badge + viewer count; mobile player-top / chat-below; keyboard controls; graceful error when the stream is down. Set latency expectations (HLS ~6–30s). - Phase 3 — Read chat + host zaps (medium, DECISION 2). Read-only kind-1311
subscription by
#a, auto-scroll with pause-on-scroll-up + jump-to-latest, teardown on close. Host zaps via the existing NWC/zap system (active participation from the start). - Phase 3b — Post chat (medium, DECISION 2). Sign/publish kind-1311 into the stream, behind its own sub-flag, with a moderation stance (rate-limit; how blocked/muted authors + outbound spam are handled). This is the committed end state (read + post + zaps).
- Phase 4 — Past streams / VOD (medium, DECISION 4). List
status=endedevents and play theirrecordingURL through the same HLS player; mark ended vs live in the UI. This is the "past zap.stream streams" capability. - Phase 5 — Polish. Poster/skeleton states, liveness de-emphasis for stale
events, high-volume-chat buffering, and (optional) surfacing
planned/upcoming streams.
Moderation, whitelist & NSFW
Per DECISION 1 (locked: whitelisted hosts): the live list is scoped to whitelisted authors and reuses the existing trust filter, so the admin whitelist/blacklist governs which streams appear.
Chat moderation (two design items, before their phases):
- Read chat (Phase 3): kind-1311 messages come from arbitrary viewers, not just whitelisted authors. Decide whether the read view applies the existing WoT mute/blacklist (hide messages from muted/blocked pubkeys) — recommend yes, reuse the same author filter so a globally-blocked spammer is hidden in live chat too. NSFW/text moderation of chat is out of scope for v1.
- Post chat (Phase 3b): you're publishing into someone else's stream — add rate-limiting and respect the host/relay's own rules; decide the outbound stance (e.g. block posting for lockdown/blacklisted local users).
Risks / watch-items
- HLS is new to the player. Native
.m3u8works only in Safari; Chrome/ Firefox need hls.js. This is the main build risk — de-risk in Phase 0. - CORS on external streaming servers. The
.m3u8+ segments are served by a third-party origin; playback in a static site depends on their CORS. Verify in Phase 0 (same class of constraint as the storage/edge CORS work). - Stale "live" events. Streams end without always updating
status; a dead.m3u8must fail gracefully and ideally be filtered. Needs a liveness signal (recent update time / probe). - Relay fan-out. A live-discovery subscription adds REQ load — respect the cold-login relay-cap / circuit-breaker invariants (AGENTS.md §17); keep it deferred and capped like nip71 ingest.
- Scope creep into #16c. Keep this strictly watch-only; broadcasting is a separate flag + plan.
- High-volume chat. A busy stream's kind-1311 can arrive fast; the read view must cap/virtualize the message buffer (drop-oldest) so it can't balloon memory or jank the UI. Tie into the auto-scroll/pause behavior.
status=planned(scheduled) streams. 30311 supports a not-yet-live state. v1 shows only live + ended (DECISION 4); decide later whether to surface "upcoming" (withstarts). Just don't let aplannedevent render as if live.- Multiple
streamingrenditions.streamingmay appear more than once (quality variants) or point at a master playlist. Pick one sensibly (hls.js handles master playlists); confirm shapes in Phase 0. - Private/token-gated streams. Only public HLS is in scope — a stream whose
.m3u8requires auth/tokens is treated as "can't play," gracefully.
Sources
- NIP-53 (Live Activities; kind 30311 live event, kind 1311 live chat).
- Reference implementations: zap.stream, shosho.live.
- Existing code:
js/services/nip71IngestService.js(deferred/throttled ingest pattern to mirror),js/services/playbackService.js(player entry for HLS),js/app/feedCoordinator.js,components/sidebar.html,js/constants.js(FEED_TYPES),js/ui/components/VideoCard.js(contrast for the new LiveCard). - hls.js (candidate HLS playback dependency, DECISION 3).
- Live watch-UX research (July 2026):
- Streaming App UX Best Practices: 7 Pillars — Fora Soft (player+chat layout, live vs VOD, social overlays as retention)
- Top Live Streaming Platforms 2026 — VdoCipher (latency: HLS/DASH 6–30s, LL-HLS 1–5s, WebRTC <500ms)
- Twitch mobile viewer update, resizable chat overlay + PiP (Stream-Rise) (mobile chat-below / draggable overlay pattern)