[playback] Add HLS support and stop blaming stale MediaErrors

bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.

Vendor hls.js (npm run build:hls) and route HLS sources through it:

- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
  lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
  returns "maybe" for every HLS type, so a native-first branch would hang.
  Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
  audio stream controller and real ladders demux audio into an EXT-X-MEDIA
  group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
  MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
  the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
  bare-<video> probe could not load it and on Chrome emitted no events at all,
  burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
  mid-startup: the bundle, master playlist, variant playlist, init segment and
  first media segment are four-plus serial round trips.

Also fixes two bugs found while verifying this:

- A stale MediaError was reported as the current failure. Teardown clears `src`
  and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
  on the element; a later watchdog fallback rendered that as "Hosted playback
  failed: source not supported or blocked." over a stream that was playing
  fine. Only a MediaError produced by the current attempt is surfaced now.
  Sources are also bound before the watchdogs are armed, so hls.js's own
  attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
  audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
  published with it as podcasts and dropped them from every video feed.

Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-07-28 13:58:55 -04:00
1 parent 726c0d26ed
commit 4404728410
17 files changed
+1722 -13

No files matched your search

+125
View File
@@ -94,8 +94,133 @@ sequenceDiagram
end
```
## HLS (.m3u8) sources
Hosted sources whose path ends in `.m3u8` are HLS playlists and cannot be
assigned to `videoEl.src` on every browser. `js/services/hlsPlayback.js` owns
the decision and the hls.js lifecycle.
**Engine choice.** hls.js (MSE) wins whenever `MediaSource` can back it; native
playback is used only when MSE is unavailable. Do NOT branch on `canPlayType`
alone — measured behavior differs sharply:
| Browser | `canPlayType("application/vnd.apple.mpegurl")` | bare `<video src=…m3u8>` |
| --- | --- | --- |
| Firefox 146 | `""` | `MEDIA_ERR_SRC_NOT_SUPPORTED` (code 4) |
| Chrome 149 | `"maybe"` | plays natively |
Before this existed, Firefox reported `No playable source found.` for every HLS
note (and the URL-health badge showed `❌ CDN`), while Chrome happened to work.
**Wiring.**
- `js/services/playbackService.js` — `attemptHostedPlayback` routes HLS
candidates through `attachHlsSource()`; `resetVideoElement()` calls
`detachHls()` before clearing `src`.
- `js/app/modalCoordinator.js` — `teardownVideoElement` calls `detachHls()`
first; otherwise the transmuxer worker and segment fetches outlive the modal.
- `js/ui/urlHealthController.js` — probes HLS by fetching the playlist and
checking for `#EXTM3U` instead of using a bare `<video>` (which cannot load it
and, on Chrome, emits no events at all — burning the whole probe timeout).
- `HLS_PLAYBACK_START_TIMEOUT` (`js/constants.js`) widens the 3s start window,
which fires mid-startup for HLS: the bundle, master playlist, variant
playlist, init segment and first media segment are serial round trips.
**Vendoring.** `npm run build:hls` regenerates `vendor/hls.bundle.min.js` from
the pinned devDependency. It is the FULL hls.js build, not `hls.js/light` —
light drops the alternate-audio stream controller, and real ladders (e.g.
nostube/slidestr) demux audio into its own `EXT-X-MEDIA` rendition group. The
bundle is lazy-imported only when an HLS URL is encountered, so ordinary
MP4/WebTorrent playback never pays for it.
**Rollback.** Stop calling `attachHlsSource()` from `playbackService.js` /
`playbackCoordinator.js` and the previous `videoEl.src = url` behavior returns;
revert the `urlHealthController.js` branch to restore the old element probe.
Nothing else imports `hlsPlayback.js`, and the vendored bundle is inert once
unreferenced.
## Implementation notes
- The playback session is responsible for cleanup and emits events (`status`, `sourcechange`, `error`, `finished`) that `bitvidApp` listens to for UI updates.
- `bitvidApp.playVideoWithFallback` keeps the current video card and modal state in sync with the normalized magnet values so copy actions and stats reflect the actual playback payload.
## Link previews (NIP-92 imeta + per-video OpenGraph)
Two independent surfaces make a shared bitvid video render richly.
**1. Inside nostr clients — `js/nostrEventSchemas.js`.**
`buildShareEvent()` appends a NIP-92 `imeta` entry describing the thumbnail URL
that the note body ends with. Many clients only inline such a URL when an imeta
entry backs it; without it the viewer sees a bare link. The media type is
inferred from the extension (`inferImageMimeTypeFromUrl`, `js/utils/mime.js`)
and OMITTED when unknown — a wrong `m` is worse than a missing one, because
clients use it to decide whether to render inline at all. Extensionless
Blossom-style URLs therefore ship `url` + `alt` only.
**2. Everywhere else — `api/og.js` + `api/_lib/ogRenderer.mjs`.**
`index.html` carries one set of site-wide OG tags, so every `/?v=<nevent>` link
previewed as the same generic card. Crawlers don't run JS, so this is rendered
server-side on demand.
- **Nothing is pre-generated or stored.** One stateless function serves all
videos; the video is a query param, not a path. `dist/` is byte-identical
with or without it. The only caching is the CDN edge (`s-maxage=300`) plus a
per-container in-memory memo of the admin lists.
- **Never load-bearing.** Only crawler user-agents are rewritten to it
(`vercel.json`), humans get the untouched SPA, and a static/nsite deploy with
no functions keeps working — it just serves the site-wide card. Rollback is
deleting the `rewrites` block.
- **Player card, not just an image.** It emits `twitter:player` / `og:video:url`
pointing at `embed.html?pointer=<nevent>`, so a shared link plays inline on
Twitter/Discord/Telegram. `frame-ancestors *` (`_headers`) already permits it.
- **Fails closed.** A crawler never authenticates, so anything emitted is public
forever in third-party caches. `isPubliclyShareable()` requires an
affirmatively public video: `deleted`, `isPrivate`, `isNsfw`/`content-warning`,
blacklisted authors, and — while whitelist mode is on — an unavailable or
empty whitelist all fall back to the generic card.
- **Both event shapes.** `parseVideoEvent()` reads bitvid's kind-30078 v3 JSON
AND the NIP-71 tag shape (kinds 21/22/34235/34236) that nostube/slidestr
publish, where the title lives in a `title` tag and the thumbnail in an
`imeta image` entry.
- **Timeouts.** A cold pool's first relay read measured ~3s, so the budget is
4s (Twitter allows ~5s); warm containers answer in <600ms because the admin
lists are memoized for 5 minutes.
Config is imported from `js/config.js`, `js/nostr/toolkit.js` and
`js/nostrEventSchemas.js` rather than duplicated, and `tests/og-renderer.test.mjs`
fails if that regresses to hardcoded literals.
### Quoting the NIP-71 mirror
When a video has a NIP-71 mirror, the share note embeds `nostr:naddr1…` for it.
Nostr clients render that as a **native video quote card** — the closest thing
to a rich preview that does not depend on any client fetching OpenGraph tags
(most never do; the OG endpoint above mainly serves Twitter/Discord/Telegram).
The mirror is addressable (kind 34235/34236, author = video pubkey,
`d` = `videoRootId`), so `js/nostr/mirrorPointer.js` derives the coordinate with
no relay lookup, and the pointer survives the author editing the video — a plain
`nevent` would rot on the next replace.
Rules that keep this from looking worse than the plain link:
- **Verify before pointing.** `ShareNostrController.resolveMirrorPointer()`
confirms the mirror exists via `nip71MirrorService.findMirror()` before
embedding anything. A pointer to a missing event renders as a broken/empty
quote box. Every failure path — service disabled, lookup throws, lookup times
out (2.5s), no `videoRootId` — falls back to the thumbnail-URL note.
- **Use the observed kind.** `resolveMirrorKind()` prefers the kinds actually
seen on relays over the portrait heuristic. Only when nothing was observed does
it fall back to the SAME rule the mirror builder uses (`nip71Mirror.js`:
`hasDims && height > width`), so a derived pointer matches what would be
published rather than guessing independently.
- **Never emit both images.** While quoting, the raw thumbnail URL is dropped
from the body and the `imeta` tag is skipped — the quote card already shows the
thumbnail, and keeping both rendered the same image twice.
- An `a` tag (`kind:pubkey:d`) links the note to the quoted coordinate for
quote-aware clients and indexers.
Known limitation: a client rendering the quote card still needs to play the
video, and mirrors whose `imeta url` is an `.m3u8` hit the same HLS wall bitvid
did — the thumbnail and title render, playback depends on the client.