Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 152 additions & 2 deletions apps/web/__tests__/unit/hls-recording-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"],
Expand Down
27 changes: 22 additions & 5 deletions apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
) {
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;

hls.on(Hls.Events.ERROR, (event, data) => {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading