From b400f8bf46fb9131e71a923c573c999aead4952b Mon Sep 17 00:00:00 2001 From: Pushpinder Singh Date: Wed, 30 Sep 2026 18:03:43 -0400 Subject: [PATCH] Restore HLS.js-first playback and wake seek readiness checks --- web/src/App.tsx | 82 ++++++++++++-------- web/tests/real-playback.mjs | 67 +++++++---------- web/tests/workflow.spec.ts | 144 +++++++++++++++++++++++++++++------- 3 files changed, 196 insertions(+), 97 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 8de3f82..5709733 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -68,6 +68,14 @@ const SettingsPage = lazy(() => })), ); +function hasMediaSource() { + return ( + "MediaSource" in window || + "ManagedMediaSource" in window || + "WebKitMediaSource" in window + ); +} + type Tab = "live" | "movie" | "series"; type PlaybackSelection = { item: Item; title: Item }; type EpisodeDestination = { @@ -1564,6 +1572,7 @@ function PlaybackPlayer({ const seekVersion = useRef(0); const seekingRef = useRef(false); const pendingPlayUrlRef = useRef(null); + const pollSessionRef = useRef<(() => void) | null>(null); const attachedUrlRef = useRef(null); const hlsRef = useRef(null); const clearMediaRef = useRef<(() => void) | null>(null); @@ -1601,6 +1610,8 @@ function PlaybackPlayer({ let alive = true; let timer: number | undefined; let heartbeat: number | undefined; + let pollBusy = false; + let pollRequested = false; let currentId: string | null = null; let postStarted = false; let postDone = false; @@ -1623,6 +1634,7 @@ function PlaybackPlayer({ stopped = true; releaseKeepalive = keepalive; alive = false; + pollSessionRef.current = null; clearMediaRef.current?.(); if (timer) clearTimeout(timer); if (heartbeat) clearInterval(heartbeat); @@ -1638,6 +1650,9 @@ function PlaybackPlayer({ }; releasedRef.current = false; pendingPlayUrlRef.current = null; + // Download the decoder while the provider and FFmpeg prepare the stream. + // Browsing alone does not load it; native-only browsers do not need it. + if (hasMediaSource()) void import("hls.js").catch(() => {}); setSession(null); setPlaybackUrl(""); setError(""); @@ -1673,11 +1688,14 @@ function PlaybackPlayer({ return; } const poll = () => { + if (timer) clearTimeout(timer); if (!alive || releasedRef.current) return; if (seekingRef.current) { timer = window.setTimeout(poll, 300); return; } + if (pollBusy) return; + pollBusy = true; const version = seekVersion.current; void request( `/api/sessions/${encodeURIComponent(value.id)}`, @@ -1704,13 +1722,22 @@ function PlaybackPlayer({ if (alive) setError(message(cause)); }) .finally(() => { + pollBusy = false; + const delay = pollRequested + ? 0 + : latestSession.current?.state === "starting" + ? 300 + : 2000; + pollRequested = false; if (alive && !releasedRef.current) - timer = window.setTimeout( - poll, - latestSession.current?.state === "starting" ? 300 : 2000, - ); + timer = window.setTimeout(poll, delay); }); }; + pollSessionRef.current = () => { + if (timer) clearTimeout(timer); + if (pollBusy) pollRequested = true; + else timer = window.setTimeout(poll, 0); + }; timer = window.setTimeout(poll, 300); heartbeat = window.setInterval(() => { if (!releasedRef.current) @@ -1758,7 +1785,6 @@ function PlaybackPlayer({ if (!element || !playbackUrl || !isPresent || viewerFinished) return; let disposed = false; let hls: Hls | undefined; - let nativeHls = !!element.canPlayType("application/vnd.apple.mpegurl"); const clearMedia = () => { if (disposed) return; disposed = true; @@ -1777,20 +1803,28 @@ function PlaybackPlayer({ element.pause(); element.removeAttribute("src"); element.load(); - const startHls = (position = activeItem.kind === "live" ? -1 : 0) => { + const startNative = () => { + if (element.canPlayType("application/vnd.apple.mpegurl")) + element.src = playbackUrl; + else { + setBuffering(false); + setError("This browser does not support HLS playback."); + } + }; + const startHls = () => { void import("hls.js") .then(({ default: Hls }) => { // The player may close or switch sources while the chunk is loading. if (disposed) return; if (!Hls.isSupported()) { - setError("This browser does not support HLS playback."); + startNative(); return; } hls = new Hls({ enableWorker: true, // The ESM distribution requires an explicit worker URL. workerPath: hlsWorkerUrl, - startPosition: position, + startPosition: activeItem.kind === "live" ? -1 : 0, maxBufferLength: 30, maxMaxBufferLength: 30, backBufferLength: 30, @@ -1819,30 +1853,16 @@ function PlaybackPlayer({ }); }; const onMediaError = () => { - if (disposed) return; - if (nativeHls) { - // canPlayType reports general support, not support for this stream. - // Chrome's native HLS rejects some streams that HLS.js can decode. - nativeHls = false; - const position = element.currentTime; - const shouldPlay = - pendingPlayUrlRef.current === playbackUrl || !element.paused; - element.pause(); - element.removeAttribute("src"); - element.load(); - setPlaying(false); - setBuffering(true); - setError(""); - if (shouldPlay) pendingPlayUrlRef.current = playbackUrl; - startHls(activeItem.kind === "live" ? -1 : position); - } else if (latestSession.current?.state === "ready") { - setError("The video could not be played."); - } + // HLS.js owns its error recovery; native-only playback has no decoder. + if (disposed || hls) return; + queueMicrotask(clearMedia); + setBuffering(false); + setError("The video could not be played."); }; element.addEventListener("error", onMediaError); - // Keep native playback lightweight, with a decoder fallback if it fails. - if (nativeHls) element.src = playbackUrl; - else startHls(); + // Prefer HLS.js. Native support claims do not guarantee stream compatibility. + if (hasMediaSource()) startHls(); + else startNative(); return () => { clearMedia(); if (clearMediaRef.current === clearMedia) clearMediaRef.current = null; @@ -1963,6 +1983,7 @@ function PlaybackPlayer({ } finally { setSeeking(false); seekingRef.current = false; + if (sessionId.current === id) pollSessionRef.current?.(); } }; const togglePlay = async () => { @@ -1981,6 +2002,7 @@ function PlaybackPlayer({ setError("Playback could not start. Try pressing play again."); } } else { + pendingPlayUrlRef.current = null; element.pause(); setPlaybackFeedback({ id: ++feedbackSequence.current, action: "pause" }); } diff --git a/web/tests/real-playback.mjs b/web/tests/real-playback.mjs index 2a537e6..2bbc25b 100644 --- a/web/tests/real-playback.mjs +++ b/web/tests/real-playback.mjs @@ -12,16 +12,6 @@ const browser = await chromium.launch({ }); const page = await browser.newPage(); -// Exercise our JS decoder and worker even on Chromium versions with native HLS. -// Workflow tests cover the native selection and media lifecycle separately. -await page.addInitScript(() => { - const canPlayType = HTMLMediaElement.prototype.canPlayType; - HTMLMediaElement.prototype.canPlayType = function (type) { - return type === "application/vnd.apple.mpegurl" - ? "" - : canPlayType.call(this, type); - }; -}); page.on("console", (message) => { if (message.type() === "error" || message.type() === "warning") console.log(`Browser ${message.type()}: ${message.text()}`); @@ -108,7 +98,7 @@ const progressing = () => { ); }; -async function checkNativePlayback(failNative = false) { +async function checkNativePlayback() { const nativePage = await browser.newPage(); const supported = await nativePage.evaluate( () => @@ -123,6 +113,15 @@ async function checkNativePlayback(failNative = false) { await nativePage.close(); return; } + // Emulate a native-only browser while retaining Chrome's real HLS decoder. + await nativePage.addInitScript(() => { + for (const name of [ + "MediaSource", + "ManagedMediaSource", + "WebKitMediaSource", + ]) + Reflect.deleteProperty(window, name); + }); let nativeSeeks = 0; const nativeErrors = []; const workers = new Set(); @@ -133,10 +132,7 @@ async function checkNativePlayback(failNative = false) { nativePage.on("pageerror", (error) => nativeErrors.push(error.message)); nativePage.on("request", (request) => { if (/\/api\/sessions\/[^/]+\/seek$/.test(request.url())) nativeSeeks++; - if ( - !failNative && - /\/assets\/hls(?:[.-])/.test(new URL(request.url()).pathname) - ) + if (/\/assets\/hls(?:[.-])/.test(new URL(request.url()).pathname)) nativeErrors.push("Native playback loaded the JS decoder"); }); nativePage.on("response", async (response) => { @@ -151,26 +147,10 @@ async function checkNativePlayback(failNative = false) { await nativePage.goto(`${baseURL}/?section=movie`); await nativePage.getByRole("button", { name: "Open Test movie" }).click(); await nativePage.getByRole("button", { name: "Play", exact: true }).click(); - if (failNative) { - await expect(nativePage.locator("video")).toHaveAttribute( - "src", - /\/api\/streams\//, - ); - // Simulate a stream-specific native decode failure, then decode the - // real FFmpeg output through HLS.js in the same session. - await nativePage.locator("video").evaluate((video) => { - video.dispatchEvent(new Event("error")); - }); - } await nativePage.waitForFunction(progressing, undefined, { timeout: 15000, }); const source = (await nativePage.evaluate(videoState)).src; - if (failNative) { - if (!source.startsWith("blob:")) - throw new Error("Native failure did not attach the HLS decoder"); - await expect.poll(() => workers.size).toBe(1); - } const nativeArea = nativePage.locator(".playback-area"); await nativeArea.hover(); await nativePage @@ -246,7 +226,7 @@ async function checkNativePlayback(failNative = false) { ); if (nativeErrors.length) throw new Error(nativeErrors.join("\n")); console.log( - `${failNative ? "Native HLS fallback" : "Native HLS"} decodes, pauses, seeks locally within its media window, resumes after a server seek, and releases playback`, + `Native-only HLS decodes, pauses, seeks locally within its media window, resumes after a server seek, and releases playback`, ); } finally { await nativePage.close(); @@ -276,6 +256,15 @@ try { await play.waitFor({ timeout: 30000 }); const started = Date.now(); await play.click(); + await page.waitForFunction( + () => { + const video = document.querySelector("video"); + return video && video.getVideoPlaybackQuality().totalVideoFrames > 0; + }, + undefined, + { timeout: 15000 }, + ); + const firstFrameMs = Date.now() - started; await page .waitForFunction(progressing, undefined, { timeout: 15000 }) .catch(async (error) => { @@ -291,11 +280,9 @@ try { throw error; }); const decoded = await page.evaluate(videoState); - const nativeHls = await page - .locator("video") - .evaluate((video) => !!video.canPlayType("application/vnd.apple.mpegurl")); + if (!decoded.src.startsWith("blob:")) + throw new Error("HLS.js was not preferred over advertised native support"); if ( - !nativeHls && ![...activeWorkers].some((worker) => /\/assets\/hls\.worker-/.test(worker.url()), ) @@ -305,7 +292,7 @@ try { `Buffered ${await checkBufferWindow()}s; decoding worker active: ${activeWorkers.size}`, ); console.log( - `Decoded HLS ${decoded.width}x${decoded.height}; first progressing frame in ${Date.now() - started}ms`, + `Decoded HLS ${decoded.width}x${decoded.height}; first decoded frame observed in ${firstFrameMs}ms`, ); const persistentVideo = await page.locator("video").elementHandle(); @@ -399,6 +386,7 @@ try { response.request().method() === "POST", { timeout: 5000 }, ); + const seekStarted = Date.now(); await page.mouse.click( bounds.x + bounds.width * 0.9, bounds.y + bounds.height / 2, @@ -415,7 +403,9 @@ try { const afterSeek = await page.evaluate(videoState); if (afterSeek.paused || afterSeek.width === 0) throw new Error("Playback did not resume after timeline seek"); - console.log("Timeline seek decoded new HLS generation"); + console.log( + `Timeline seek resumed decoded playback in ${Date.now() - seekStarted}ms`, + ); await checkBufferWindow(); await page.setViewportSize({ width: 390, height: 844 }); await area.hover({ position: { x: 70, y: 40 } }); @@ -541,7 +531,6 @@ try { if (browserErrors.length) throw new Error(browserErrors.join("\n")); console.log("Leaving player released stream slot; no browser errors"); await checkNativePlayback(); - await checkNativePlayback(true); } finally { for (const id of sessionIds) { await fetch(`${baseURL}/api/sessions/${encodeURIComponent(id)}`, { diff --git a/web/tests/workflow.spec.ts b/web/tests/workflow.spec.ts index 6c141a0..2c9ba18 100644 --- a/web/tests/workflow.spec.ts +++ b/web/tests/workflow.spec.ts @@ -101,7 +101,7 @@ async function mockApi( page: Page, options: { nativeHls?: boolean; - nativePlaybackFailure?: boolean; + mediaSource?: boolean; initialEmpty?: boolean; capacity?: boolean; producerEnded?: boolean; @@ -130,27 +130,40 @@ async function mockApi( ) { // Workflow fixtures exercise controlled media state; real-playback.mjs // covers HLS decoding. Avoid starting an actual decoder on empty playlists. - await page.addInitScript(({ nativeHls, nativePlaybackFailure }) => { - const canPlayType = HTMLMediaElement.prototype.canPlayType; - HTMLMediaElement.prototype.canPlayType = function (type) { - return type === "application/vnd.apple.mpegurl" - ? nativeHls - ? "probably" - : "" - : canPlayType.call(this, type); - }; - if (nativeHls && !nativePlaybackFailure) { - // Empty workflow playlists are not real media. Keep their native errors - // out of tests that explicitly control canplay, pause, and seek state. - document.addEventListener("error", (event) => { - if (event.target instanceof HTMLMediaElement) - event.stopImmediatePropagation(); - }, true); - } - }, { - nativeHls: options.nativeHls !== false, - nativePlaybackFailure: !!options.nativePlaybackFailure, - }); + await page.addInitScript( + ({ nativeHls, mediaSource }) => { + const canPlayType = HTMLMediaElement.prototype.canPlayType; + HTMLMediaElement.prototype.canPlayType = function (type) { + return type === "application/vnd.apple.mpegurl" + ? nativeHls + ? "probably" + : "" + : canPlayType.call(this, type); + }; + if (!mediaSource) { + for (const name of [ + "MediaSource", + "ManagedMediaSource", + "WebKitMediaSource", + ]) + Reflect.deleteProperty(window, name); + // Empty workflow playlists are not real media. Keep their native errors + // out of tests that explicitly control canplay, pause, and seek state. + document.addEventListener( + "error", + (event) => { + if (event.target instanceof HTMLMediaElement) + event.stopImmediatePropagation(); + }, + true, + ); + } + }, + { + nativeHls: options.nativeHls !== false, + mediaSource: options.mediaSource ?? options.nativeHls === false, + }, + ); let catalogReady = !options.initialEmpty; let active = false; let nextId = 0; @@ -2384,10 +2397,83 @@ test("native HLS playback keeps the decoder and worker out of browser requests", expect(decoderRequests).toEqual([]); }); -test("a native HLS failure loads the decoder without allocating another stream", async ({ +test("server seek polls readiness immediately and preserves an explicit pause", async ({ + page, +}) => { + await mockApi(page); + let seekFinished = 0; + let readinessDelay: number | undefined; + const url = "/api/streams/session-1/1/index.m3u8"; + await page.route("**/api/sessions/session-1/seek", async (route) => { + seekFinished = Date.now(); + await route.fulfill({ + json: { + id: "session-1", + url, + state: "starting", + duration: 1800, + offset: 100, + }, + }); + }); + await page.route("**/api/sessions/session-1", async (route) => { + if (route.request().method() !== "GET") return route.fallback(); + if (seekFinished) readinessDelay = Date.now() - seekFinished; + await route.fulfill({ + json: { + id: "session-1", + url: seekFinished ? url : "/api/streams/session-1/0/index.m3u8", + state: "ready", + duration: 1800, + offset: seekFinished ? 100 : 0, + }, + }); + }); + await page.goto("/?section=movie"); + await page.getByRole("button", { name: "Open The Quiet Coast" }).click(); + const firstPoll = page.waitForResponse( + (response) => + response.url().endsWith("/api/sessions/session-1") && + response.request().method() === "GET", + ); + await page.getByRole("button", { name: "Play", exact: true }).click(); + await firstPoll; + await playableVideo(page); + await page.getByRole("button", { name: "Pause", exact: true }).click(); + const timeline = await page.locator(".timeline").boundingBox(); + expect(timeline).not.toBeNull(); + await page.mouse.click( + timeline!.x + timeline!.width * 0.9, + timeline!.y + timeline!.height / 2, + ); + await expect(page.locator("video")).toHaveAttribute("src", url); + expect(readinessDelay).toBeDefined(); + expect(readinessDelay!).toBeLessThan(1000); + await page + .locator("video") + .evaluate((video) => video.dispatchEvent(new Event("canplay"))); + await expect + .poll(() => + page + .locator("video") + .evaluate((video) => (video as HTMLVideoElement).paused), + ) + .toBe(true); + await page.getByRole("button", { name: "Play", exact: true }).click(); + await expect + .poll(() => + page + .locator("video") + .evaluate((video) => (video as HTMLVideoElement).paused), + ) + .toBe(false); + await page.getByRole("button", { name: "Stop playback" }).click(); +}); + +test("HLS.js is preferred even when the browser advertises native HLS", async ({ page, }) => { - const api = await mockApi(page, { nativePlaybackFailure: true }); + const api = await mockApi(page, { mediaSource: true }); await page.route("**/api/streams/**", (route) => route.fulfill({ status: 200, @@ -2396,22 +2482,24 @@ test("a native HLS failure loads the decoder without allocating another stream", }), ); const decoderRequests: string[] = []; + const manifestRequests: string[] = []; const errors: string[] = []; page.on("request", (request) => { if (/\/assets\/hls-[^/]+\.js$/.test(new URL(request.url()).pathname)) decoderRequests.push(request.url()); + if (request.url().endsWith("/index.m3u8")) + manifestRequests.push(request.resourceType()); }); page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/?section=movie"); await page.getByRole("button", { name: "Open The Quiet Coast" }).click(); await page.getByRole("button", { name: "Play", exact: true }).click(); - // A browser can advertise native support and still reject this manifest. + // The invalid fixture reaches HLS.js without a failed native media request. await expect(page.getByRole("alert")).toContainText("Playback stopped"); expect(decoderRequests).toHaveLength(1); + expect(manifestRequests).toEqual(["xhr"]); expect(api.events.filter((event) => event === "POST")).toHaveLength(1); - await expect - .poll(() => page.locator("video").getAttribute("src")) - .toBeNull(); + await expect.poll(() => page.locator("video").getAttribute("src")).toBeNull(); await page.getByRole("button", { name: "Stop playback" }).click(); await expect(page.locator("video")).toHaveCount(0); expect(api.events.filter((event) => event === "DELETE")).toHaveLength(1);