mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-09 06:12:56 +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
@@ -0,0 +1,185 @@
|
||||
// SCN-stale-media-error
|
||||
//
|
||||
// Given the modal <video> is reused across videos, teardownVideoElement() /
|
||||
// resetVideoElement() clear `src` and call load(). Browsers respond by leaving a
|
||||
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute") MediaError on the element.
|
||||
//
|
||||
// When a watchdog fallback later fires for an unrelated reason (a stall, or an
|
||||
// `abort` raised by our own source swap), then the failure message must NOT be
|
||||
// derived from that leftover MediaError — doing so reported
|
||||
// "Hosted playback failed: source not supported or blocked."
|
||||
// over a stream that was playing correctly, blaming the current load for a
|
||||
// previous one's failure.
|
||||
//
|
||||
// Then: only a MediaError produced by THIS attempt may be surfaced.
|
||||
|
||||
import { describe, test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PlaybackService } from "../../js/services/playbackService.js";
|
||||
|
||||
// Minimal <video> double: records listeners so the test can dispatch the exact
|
||||
// media events the real watchdogs bind to.
|
||||
function makeVideoStub({ initialError = null } = {}) {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
error: initialError,
|
||||
src: "",
|
||||
srcObject: null,
|
||||
currentTime: 0,
|
||||
readyState: 0,
|
||||
networkState: 0,
|
||||
muted: false,
|
||||
dataset: {},
|
||||
listeners,
|
||||
addEventListener(type, handler) {
|
||||
if (!listeners.has(type)) listeners.set(type, []);
|
||||
listeners.get(type).push(handler);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
const arr = listeners.get(type) || [];
|
||||
const i = arr.indexOf(handler);
|
||||
if (i >= 0) arr.splice(i, 1);
|
||||
},
|
||||
dispatch(type) {
|
||||
for (const h of [...(listeners.get(type) || [])]) h({ type });
|
||||
},
|
||||
hasAttribute: () => false,
|
||||
removeAttribute() {},
|
||||
setAttribute() {},
|
||||
play: () => Promise.resolve(),
|
||||
pause() {},
|
||||
load() {},
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_SRC_ERROR = Object.freeze({
|
||||
code: 4, // MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
message: "MEDIA_ELEMENT_ERROR: Empty src attribute",
|
||||
});
|
||||
|
||||
describe("PlaybackService stale MediaError handling", () => {
|
||||
before(() => {
|
||||
global.window = {
|
||||
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||
};
|
||||
global.document = { createElement: () => makeVideoStub() };
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete global.window;
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
const runWatchdogFallback = async ({ initialError, errorAtFallback }) => {
|
||||
const video = makeVideoStub({ initialError });
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => false,
|
||||
});
|
||||
|
||||
const statuses = [];
|
||||
let fallbackReason = null;
|
||||
|
||||
// Drive the watchdog contract directly: this is the boundary where the
|
||||
// message is chosen, independent of network or codec support.
|
||||
const cleanup = service.registerUrlPlaybackWatchdogs(video, {
|
||||
stallMs: 0,
|
||||
onSuccess: () => {},
|
||||
onFallback: (reason) => {
|
||||
fallbackReason = reason;
|
||||
},
|
||||
});
|
||||
|
||||
video.error = errorAtFallback;
|
||||
video.dispatch("stalled");
|
||||
cleanup();
|
||||
|
||||
return { statuses, fallbackReason, video };
|
||||
};
|
||||
|
||||
test("a fallback still reports its reason to the caller", async () => {
|
||||
const { fallbackReason } = await runWatchdogFallback({
|
||||
initialError: EMPTY_SRC_ERROR,
|
||||
errorAtFallback: EMPTY_SRC_ERROR,
|
||||
});
|
||||
assert.equal(fallbackReason, "stalled");
|
||||
});
|
||||
|
||||
test("the leftover empty-src MediaError is identity-distinguishable", () => {
|
||||
// The fix hinges on identity: starting a new load clears `error` to null,
|
||||
// and a genuine new failure allocates a DIFFERENT MediaError object. If a
|
||||
// browser ever reused the object this guard would silently stop working.
|
||||
const stale = EMPTY_SRC_ERROR;
|
||||
const fresh = { code: 4, message: "MEDIA_ELEMENT_ERROR: Format error" };
|
||||
assert.notEqual(stale, fresh);
|
||||
assert.equal(stale === EMPTY_SRC_ERROR, true);
|
||||
});
|
||||
|
||||
// Drive a real session to the watchdog-fallback branch with a stale error
|
||||
// already on the element, and capture what the viewer would be shown.
|
||||
const runSessionWithStall = async ({ initialError, errorAtFallback }) => {
|
||||
const video = makeVideoStub({ initialError });
|
||||
|
||||
// Fire `stalled` as soon as the session arms its watchdogs — this is the
|
||||
// spurious fallback the viewer hits while the stream is actually fine.
|
||||
const originalAdd = video.addEventListener.bind(video);
|
||||
video.addEventListener = (type, handler) => {
|
||||
originalAdd(type, handler);
|
||||
if (type === "stalled") {
|
||||
setTimeout(() => {
|
||||
video.error = errorAtFallback;
|
||||
video.dispatch("stalled");
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const service = new PlaybackService({
|
||||
logger: () => {},
|
||||
urlFirstEnabled: true,
|
||||
isValidMagnetUri: () => false,
|
||||
});
|
||||
|
||||
const statuses = [];
|
||||
const session = service.createSession({
|
||||
url: "https://cdn.example/video.mp4",
|
||||
magnet: "",
|
||||
videoElement: video,
|
||||
autoplay: true,
|
||||
playbackStartTimeout: 200,
|
||||
});
|
||||
session.on?.("status", (p) => statuses.push(p.message));
|
||||
|
||||
await session.execute();
|
||||
return statuses;
|
||||
};
|
||||
|
||||
test("a stale empty-src error is NOT blamed on the current load", async () => {
|
||||
const statuses = await runSessionWithStall({
|
||||
initialError: EMPTY_SRC_ERROR,
|
||||
errorAtFallback: EMPTY_SRC_ERROR, // unchanged => stale
|
||||
});
|
||||
assert.equal(
|
||||
statuses.some((m) => /source not supported or blocked/i.test(m)),
|
||||
false,
|
||||
`stale error must not be reported; got: ${JSON.stringify(statuses)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("a MediaError raised by THIS load is still reported", async () => {
|
||||
// The guard must not silence genuine failures — a new MediaError object
|
||||
// appearing during the attempt is real and must reach the viewer.
|
||||
const statuses = await runSessionWithStall({
|
||||
initialError: null,
|
||||
errorAtFallback: {
|
||||
code: 4,
|
||||
message: "MEDIA_ELEMENT_ERROR: Format error",
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
statuses.some((m) => /source not supported or blocked/i.test(m)),
|
||||
true,
|
||||
`genuine error must be reported; got: ${JSON.stringify(statuses)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user