diff --git a/apps/web/__tests__/unit/hls-recording-readiness.test.ts b/apps/web/__tests__/unit/hls-recording-readiness.test.ts index 45091da89b8..6843a2e6f88 100644 --- a/apps/web/__tests__/unit/hls-recording-readiness.test.ts +++ b/apps/web/__tests__/unit/hls-recording-readiness.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ pause: vi.fn(), stopLoad: vi.fn(), refresh: vi.fn(), + loadSource: vi.fn(), + hlsConfig: {} as { startPosition?: number }, })); vi.mock("next/navigation", () => ({ @@ -76,8 +78,12 @@ vi.mock("hls.js", () => ({ FRAG_LOADED: "fragLoaded", }; static ErrorTypes = { NETWORK_ERROR: "network", MEDIA_ERROR: "media" }; - static ErrorDetails = {}; - loadSource() {} + static ErrorDetails = { + FRAG_LOAD_ERROR: "fragLoadError", + KEY_LOAD_ERROR: "keyLoadError", + }; + config = mocks.hlsConfig; + loadSource = mocks.loadSource; attachMedia() {} startLoad() {} stopLoad = mocks.stopLoad; @@ -141,6 +147,8 @@ describe("Instant player readiness and failure UX", () => { }; mocks.status = 204; mocks.handlers.clear(); + mocks.loadSource.mockReset(); + mocks.hlsConfig = {}; container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -206,6 +214,148 @@ describe("Instant player readiness and failure UX", () => { expect(mocks.pause).toHaveBeenCalled(); }); + describe("expired segment URLs", () => { + const videoSrc = "/api/playlist?videoId=recording&videoType=video"; + const failFragment = (sn: number, code = 403) => + act(async () => + mocks.handlers.get("error")?.("hlsError", { + details: "fragLoadError", + fatal: false, + frag: { type: "main", sn }, + response: { code }, + }), + ); + const loadFragment = (sn: number | "initSegment", type = "main") => + act(async () => + mocks.handlers.get("fragLoaded")?.("fragLoaded", { + frag: { type, sn }, + }), + ); + const renderPlaying = async ({ + currentTime, + playbackRate, + paused = false, + }: { + currentTime: number; + playbackRate: number; + paused?: boolean; + }) => { + await act(async () => { + root.render( + createElement(HLSVideoPlayer, { + videoId: "recording" as Parameters< + typeof HLSVideoPlayer + >[0]["videoId"], + videoSrc, + videoRef, + chaptersSrc: "", + captionsSrc: "", + }), + ); + }); + const video = videoRef.current as HTMLVideoElement; + let isPaused = paused; + Object.defineProperty(video, "paused", { get: () => isPaused }); + Object.defineProperty(video, "currentTime", { + value: currentTime, + writable: true, + }); + video.playbackRate = playbackRate; + const play = vi.fn(async () => { + isPaused = false; + }); + video.play = play; + mocks.loadSource.mockClear(); + mocks.loadSource.mockImplementation(() => { + isPaused = true; + video.currentTime = 0; + video.playbackRate = 1; + }); + return { video, play }; + }; + + it("resumes from the same position, speed and play state", async () => { + const { video, play } = await renderPlaying({ + currentTime: 754.5, + playbackRate: 1.5, + }); + + await failFragment(251); + + expect(mocks.loadSource).toHaveBeenCalledTimes(1); + expect(mocks.loadSource).toHaveBeenCalledWith(videoSrc); + expect(mocks.hlsConfig.startPosition).toBe(754.5); + expect(video.playbackRate).toBe(1.5); + expect(play).toHaveBeenCalledTimes(1); + }); + + it("stays paused when the viewer had paused", async () => { + const { play } = await renderPlaying({ + currentTime: 30, + playbackRate: 1, + paused: true, + }); + + await failFragment(10, 410); + + expect(mocks.loadSource).toHaveBeenCalledTimes(1); + expect(mocks.hlsConfig.startPosition).toBe(30); + expect(play).not.toHaveBeenCalled(); + }); + + it("does not reload again for a fragment fresh URLs cannot load", async () => { + await renderPlaying({ currentTime: 12, playbackRate: 1 }); + + await failFragment(4); + await act(async () => mocks.handlers.get("manifestLoaded")?.()); + await failFragment(4); + await act(async () => mocks.handlers.get("manifestLoaded")?.()); + await failFragment(4); + + expect(mocks.loadSource).toHaveBeenCalledTimes(1); + }); + + it("refreshes again when a later fragment expires", async () => { + await renderPlaying({ currentTime: 12, playbackRate: 1 }); + + await failFragment(4); + await act(async () => mocks.handlers.get("manifestLoaded")?.()); + await failFragment(1200); + + expect(mocks.loadSource).toHaveBeenCalledTimes(2); + }); + + it("refreshes again when a recovered fragment expires later", async () => { + await renderPlaying({ currentTime: 12, playbackRate: 1 }); + + await failFragment(4); + await loadFragment("initSegment"); + await failFragment(4); + await loadFragment(4); + await failFragment(4); + + expect(mocks.loadSource).toHaveBeenCalledTimes(2); + }); + + it("keeps the guard when only the audio fragment with that number loads", async () => { + await renderPlaying({ currentTime: 12, playbackRate: 1 }); + + await failFragment(4); + await loadFragment(4, "audio"); + await failFragment(4); + + expect(mocks.loadSource).toHaveBeenCalledTimes(1); + }); + + it("does not treat other fragment failures as expiry", async () => { + await renderPlaying({ currentTime: 12, playbackRate: 1 }); + + await failFragment(4, 404); + + expect(mocks.loadSource).not.toHaveBeenCalled(); + }); + }); + it.each([ [false, "requireComplete=1"], [true, "earlyPlayback=1"], diff --git a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx index b7ad5414f0c..80987d96507 100644 --- a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx @@ -7,7 +7,7 @@ import { faPlay } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; import clsx from "clsx"; -import Hls from "hls.js"; +import Hls, { type Fragment } from "hls.js"; import { AlertTriangleIcon } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import dynamic from "next/dynamic"; @@ -65,6 +65,11 @@ const { circumference } = getProgressCircleConfig(); // every render and re-run everything that depends on the prop. const NO_CAPTIONS: CaptionOption[] = []; +// Audio and video playlists each number their fragments from zero. +function getFragmentKey(frag: Fragment | undefined) { + return frag && `${frag.type}:${frag.sn}`; +} + function getProgressStatusText( status: "uploading" | "processing" | "generating_thumbnail", ) { @@ -415,7 +420,7 @@ export function HLSVideoPlayer({ let networkRetryCount = 0; let mediaRetryCount = 0; const maxNetworkRetries = isLiveSegments ? 30 : 6; - let hasTriedPlaylistReload = false; + let reloadedForFragment: string | undefined | null = null; let retryTimer: ReturnType | null = null; hls.on(Hls.Events.ERROR, (event, data) => { @@ -434,13 +439,21 @@ export function HLSVideoPlayer({ isExpiredUrl && (data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR || data.details === Hls.ErrorDetails.KEY_LOAD_ERROR) && - !hasTriedPlaylistReload + // S3 also answers 403 for a missing object, so a fragment refused + // again with fresh URLs has not expired and must not reload forever. + getFragmentKey(data.frag) !== reloadedForFragment ) { - hasTriedPlaylistReload = true; + reloadedForFragment = getFragmentKey(data.frag); console.log( "HLSVideoPlayer: Presigned URL expired, reloading playlist for fresh URLs", ); + // Reloading the same URL rebuilds the MediaSource, which resets the + // element to 0:00, paused, at normal speed. + const { currentTime, paused, playbackRate } = video; + hls.config.startPosition = currentTime; hls.loadSource(playbackSrc); + video.playbackRate = playbackRate; + if (!paused) void video.play().catch(() => {}); return; } @@ -494,7 +507,11 @@ export function HLSVideoPlayer({ hls.on(Hls.Events.MANIFEST_LOADED, () => { networkRetryCount = 0; - hasTriedPlaylistReload = false; + }); + + hls.on(Hls.Events.FRAG_LOADED, (_event, data) => { + if (getFragmentKey(data.frag) === reloadedForFragment) + reloadedForFragment = null; }); if (isEarlyPlayback) {