[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

+20
View File
@@ -1,4 +1,9 @@
import { SHORT_TIMEOUT_MS } from "../constants.js";
import {
isHlsUrl,
prefersNativeHls,
probeHlsManifest,
} from "../services/hlsPlayback.js";
export default class UrlHealthController {
constructor({
@@ -275,6 +280,21 @@ export default class UrlHealthController {
return { outcome: "error" };
}
// A bare <video> cannot load an .m3u8 on the browsers bitvid actually runs
// hls.js on. Worse, Chrome neither plays it NOR errors — it emits no events
// at all, so this probe burned the full timeout on every healthy HLS card.
// Since playback will use hls.js (which fetches the playlist over CORS), a
// readable #EXTM3U response is the accurate signal — and far cheaper than
// pulling in the hls.js bundle just to paint a badge.
if (isHlsUrl(trimmed) && !prefersNativeHls()) {
const defaultTimeout =
this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
const effective =
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
const outcome = await probeHlsManifest(trimmed, effective);
return { outcome };
}
if (this.activeVideoProbes >= this.MAX_CONCURRENT_VIDEO_PROBES) {
await new Promise((resolve) => {
this.videoProbeQueue.push(resolve);