mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
726c0d26ed
commit
4404728410
17 files changed
+1722
-13
No files matched your search
@@ -1,5 +1,8 @@
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { PLAYBACK_START_TIMEOUT } from "../constants.js";
|
||||
import {
|
||||
HLS_PLAYBACK_START_TIMEOUT,
|
||||
PLAYBACK_START_TIMEOUT,
|
||||
} from "../constants.js";
|
||||
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
|
||||
import {
|
||||
consumeAutoplayFallbackMute,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
SimpleEventEmitter,
|
||||
extractWebSeedsFromMagnet,
|
||||
} from "./playbackHelpers.js";
|
||||
import { attachHlsSource, detachHls, isHlsUrl } from "./hlsPlayback.js";
|
||||
// js/services/playbackService.js
|
||||
|
||||
/**
|
||||
@@ -855,6 +859,10 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
|
||||
const resetVideoElement = () => {
|
||||
if (!activeVideoEl) return;
|
||||
// Kill any hls.js instance BEFORE touching src/load(): a live instance
|
||||
// keeps appending to the MediaSource and would fight the reset (and
|
||||
// leak its worker) when we move to the next mirror or to WebTorrent.
|
||||
detachHls(activeVideoEl);
|
||||
try {
|
||||
activeVideoEl.pause();
|
||||
} catch (err) {
|
||||
@@ -1055,6 +1063,11 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
};
|
||||
});
|
||||
|
||||
// Whatever MediaError the element already carries from a previous
|
||||
// load. Identity comparison works because starting a new load clears
|
||||
// `error` to null and a fresh failure allocates a new MediaError.
|
||||
const staleMediaError = activeVideoEl.error || null;
|
||||
|
||||
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
|
||||
this.registerWatchdogs(activeVideoEl, {
|
||||
stallMs,
|
||||
@@ -1071,7 +1084,17 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
return;
|
||||
}
|
||||
const errorMessage = getHostedVideoErrorMessage(activeVideoEl);
|
||||
// Only report a MediaError this attempt actually produced.
|
||||
// teardownVideoElement()/resetVideoElement() clear `src` and
|
||||
// call load(), which leaves a stale MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
// ("Empty src attribute") on the element. Reading it blindly
|
||||
// reported "Hosted playback failed: source not supported or
|
||||
// blocked." for a stream that was playing perfectly — a
|
||||
// failure message attributed to the wrong load entirely.
|
||||
const errorMessage =
|
||||
activeVideoEl.error && activeVideoEl.error !== staleMediaError
|
||||
? getHostedVideoErrorMessage(activeVideoEl)
|
||||
: "";
|
||||
if (errorMessage) {
|
||||
this.emit("status", { message: errorMessage });
|
||||
}
|
||||
@@ -1080,9 +1103,6 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
});
|
||||
};
|
||||
|
||||
// Use default stall timeout here; the initial start timeout is handled by the race wrapper
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
|
||||
const handleFatalPlaybackError = (err) => {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Direct URL playback threw:",
|
||||
@@ -1091,6 +1111,14 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
outcomeResolver({ status: "fallback", reason: "play-error" });
|
||||
};
|
||||
|
||||
// Bind the source BEFORE arming the watchdogs. hls.js's attachMedia()
|
||||
// points `src` at a MediaSource blob URL, which aborts any load still
|
||||
// pending on the reused modal element and fires `abort`/`emptied`.
|
||||
// With watchdogs already live those read as a playback failure and
|
||||
// abandon a stream that is about to play fine. A plain `src =` is
|
||||
// safe either way: media events are dispatched asynchronously, so
|
||||
// listeners registered later in the same task still observe them.
|
||||
let hlsAttached = true;
|
||||
try {
|
||||
// Guarantee the direct load is CORS-free regardless of how we got
|
||||
// here (see resetVideoElement) so any hosted URL plays reliably.
|
||||
@@ -1100,8 +1128,57 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
// ignore
|
||||
}
|
||||
activeVideoEl.crossOrigin = null;
|
||||
activeVideoEl.src = candidateUrl;
|
||||
const playPromise = activeVideoEl.play();
|
||||
|
||||
// HLS cannot be assigned to `src` outside Safari — it needs hls.js
|
||||
// + MSE. attachHlsSource() handles both paths and resolves once the
|
||||
// manifest is parsed. A null result means HLS is unplayable here
|
||||
// (or already failed fatally), and the outcome promise is settled,
|
||||
// so skip play() and let the normal fallback plumbing take over.
|
||||
if (isHlsUrl(candidateUrl)) {
|
||||
const hlsHandle = await attachHlsSource(
|
||||
activeVideoEl,
|
||||
candidateUrl,
|
||||
{
|
||||
log: (...args) => this.service.log(...args),
|
||||
onFatalError: (message) => {
|
||||
this.emit("status", { message });
|
||||
outcomeResolver({
|
||||
status: "fallback",
|
||||
reason: "hls-error",
|
||||
errorMessage: message,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!hlsHandle) {
|
||||
hlsAttached = false;
|
||||
// A fatal HLS error already resolved the outcome with a
|
||||
// specific message; this only covers "no HLS support at all".
|
||||
const message =
|
||||
"Hosted playback failed: this browser cannot play HLS streams.";
|
||||
this.emit("status", { message });
|
||||
outcomeResolver({
|
||||
status: "fallback",
|
||||
reason: "hls-unsupported",
|
||||
errorMessage: message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
activeVideoEl.src = candidateUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
hlsAttached = false;
|
||||
handleFatalPlaybackError(err);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use default stall timeout here; the initial start timeout is
|
||||
// handled by the race wrapper.
|
||||
if (hlsAttached) {
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
}
|
||||
|
||||
const playPromise = hlsAttached ? activeVideoEl.play() : null;
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
playPromise.catch((err) => {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
@@ -1215,9 +1292,15 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
let lastUrlResult = null;
|
||||
for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) {
|
||||
const candidateUrl = this.hostedSourceCandidates[i];
|
||||
// HLS starts slower by construction (see HLS_PLAYBACK_START_TIMEOUT).
|
||||
// `forcedSource` still means "wait indefinitely", so keep 0 as 0.
|
||||
const candidateTimeout =
|
||||
effectiveTimeout && isHlsUrl(candidateUrl)
|
||||
? Math.max(effectiveTimeout, HLS_PLAYBACK_START_TIMEOUT)
|
||||
: effectiveTimeout;
|
||||
const urlResult = await withTimeout(
|
||||
attemptHostedPlayback(candidateUrl),
|
||||
effectiveTimeout,
|
||||
candidateTimeout,
|
||||
"URL Playback"
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user