Files
siftlode/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx
T
peter fadb4f17a9 fix(plex): subtitles draw only the player's own track, lifted in the player's own pixels
A direct-played file's own subtitle stream (WebKit lists an mp4's mov_text) was switched to hidden and drawn under any choice, Off included: only the player's <track> is kept hidden and read, other subtitle tracks are switched off. The lift was measured in screen pixels and applied inside the x1.25 windowed player, and re-measured off a box carrying its own (or a mid-slide) transform: an untransformed outer box is measured, the distance divided by the scale, and a cue that just appeared is placed, not slid from where the last one was left.
2026-10-08 21:18:48 +02:00

534 lines
22 KiB
TypeScript

// @vitest-environment jsdom
//
// A subtitle <track> must not be mounted before the server has something to put in it.
//
// Bug hunt 2026-09, item 8: extracting one track out of a big MKV is a full read of the container
// (154 s for a 17 GB film, measured on prod), and it used to happen inside the <track>'s own
// request. The browser gives a <track> no visible state, so the player showed nothing at all —
// which reads as "switching subtitles does nothing", and the reported workaround was to stop/start
// until the cache happened to be warm. The extraction now runs in the background, publishing a
// window around the playhead first, and this hook's poll is what decides when there is something to
// mount and what to say while there isn't.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRef, type ReactNode, type RefObject } from "react";
import { usePlexTracks } from "./plexPlayerHooks";
import { api, type PlexItemDetail } from "./api";
import { dismissError, getError } from "./errorDialog";
const RK = "98614";
const detail = (): PlexItemDetail =>
({
id: RK,
duration_seconds: 8580,
audio_streams: [{ ord: 0, label: "Magyar AC3 5.1", language: "hu", default: true }],
subtitle_streams: [
{ ord: 0, label: "Magyar", language: "hu", text: true },
{ ord: 1, label: "English", language: "en", text: true },
],
markers: [],
}) as unknown as PlexItemDetail;
function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return renderHook(
({ id }: { id: string }) =>
usePlexTracks({
id,
detail: detail(),
sessionK,
subOffset: 0,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
playhead: () => (abs > 0 ? abs : loadStart),
}),
{ wrapper, initialProps: { id: RK } },
);
}
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
describe("usePlexTracks — the <track> waits for the extraction", () => {
// vitest runs with globals off, so testing-library's auto-cleanup never registers: unmount by
// hand or the hook's 250 ms sub-offset debounce fires into a torn-down environment.
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("is not showable while the track is still being extracted", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "preparing",
code: null,
gen: 0,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(api.plexSubtitleStatus).toHaveBeenCalled());
await waitFor(() => expect(result.current.selectedSub?.ord).toBe(0));
// The selected track exists and is offered in the picker — it just cannot be rendered yet, and
// the caller must be told so rather than mounting an empty <track>.
expect(result.current.subShowable).toBe(false);
expect(result.current.subNotice).toBe("preparing");
});
it("sends the viewer's position, so the extraction windows around where they are", async () => {
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const { result } = mount(client());
result.current.setSubOrd(1);
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
});
it("sends where the session was asked to start before playback has ticked, not the head of the film", async () => {
// A resumed session starts minutes in, but `abs` stays 0 until the first timeupdate — and every
// poll used to say at=0, so the first window was cut around the head of the film (measured on
// localdev: a resume at 20:00 polled at=0 for as long as it stayed paused). `sessionK` is no
// substitute: it is still 0 here, because the session request has not answered yet.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const { result } = mount(client(), { sessionK: 0, abs: 0, loadStart: 1198 });
result.current.setSubOrd(1);
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 1198));
});
it("does not carry the track choice to the next item — that item's detail decides it", async () => {
// Carried over, the old ordinal polled the NEW item (where it may be another language) at the
// OLD item's position, queueing an extraction of the wrong stretch of the next file.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const { result, rerender } = mount(client());
result.current.setSubOrd(1);
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
rerender({ id: "NEXT" });
expect(result.current.subOrd).toBeNull();
expect(result.current.subNotice).toBeNull();
await act(async () => {});
expect(status.mock.calls.some(([id]) => id === "NEXT")).toBe(false);
});
it("becomes showable on a PARTIAL track — a window around the playhead is what is needed now", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "partial",
code: null,
gen: 1757500000000000000,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subShowable).toBe(true));
// The generation goes into the <track> key/URL: without it the browser would keep serving the
// first, short window from its own cache once the background pass widens the track.
expect(result.current.subGen).toBe(1757500000000000000);
});
it("reports a track that can never be shown, in the bucket the player has a sentence for", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "failed",
code: "bitmap",
gen: 0,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("bitmap"));
expect(result.current.subShowable).toBe(false);
});
it("folds the server's finer failure codes into 'missing'", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "failed",
code: "no_file",
gen: 0,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("missing"));
});
it("asks nothing at all while subtitles are off", async () => {
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "ready", code: null, gen: 1 });
const { result } = mount(client());
await waitFor(() => expect(result.current.textSubs).toHaveLength(2));
expect(status).not.toHaveBeenCalled();
expect(result.current.subShowable).toBe(false);
});
});
// The poll's refetch rules ARE the feature: nothing else turns "preparing" into subtitles on screen,
// and nothing else stops a settled track from being asked about for the length of the film. Every
// test above resolves one fixed answer, so none of them ever reached the refetch callback.
describe("usePlexTracks — when the status poll asks again, and when it stops", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("keeps asking until a window lands, then shows it without the viewer doing anything", async () => {
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValueOnce({ state: "preparing", code: null, gen: 0 })
.mockResolvedValue({ state: "partial", code: null, gen: 7 });
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
expect(result.current.subShowable).toBe(false);
await vi.advanceTimersByTimeAsync(1100);
await waitFor(() => expect(result.current.subShowable).toBe(true));
});
it.each([
["the whole track is there", { state: "ready" as const, code: null, gen: 3 }],
["it can never be shown", { state: "failed" as const, code: "bitmap", gen: 0 }],
])("stops asking once the answer is settled — %s", async (_, answer) => {
const status = vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue(answer);
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(5000);
expect(status).toHaveBeenCalledTimes(1);
});
it("slows down on a partial track that will not grow on its own, and keeps asking", async () => {
// The full pass failed and is backed off for an hour: `partial` never turns `ready` by itself,
// and the 2 s cadence ran for the rest of the film. It still carries the position — reading
// ahead of an edge depends on it — so it slows rather than stops.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "partial", code: null, gen: 4, growing: false });
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(10_000);
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(6000);
expect(status).toHaveBeenCalledTimes(2);
});
it("backs off after a failed request, and still asks again so a blip heals on its own", async () => {
// A failed request leaves `data` empty; it used to read as "still preparing" and re-ask every
// second for as long as the player stayed open.
const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5000);
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(6000);
expect(status).toHaveBeenCalledTimes(2);
});
it("does not raise the global error dialog when it fails — the player says so inline", async () => {
dismissError();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: "boom" }), {
status: 500,
headers: { "content-type": "application/json" },
}),
);
await expect(api.plexSubtitleStatus(RK, 0, 0)).rejects.toThrow();
expect(getError()).toBeNull();
});
it("says a failing poll even while an earlier window is still on screen", async () => {
// react-query keeps the last good answer through a failed refetch, so a `partial` track stayed
// "showable" and the failure had nowhere to appear: past the window, blank subtitles and nothing
// on screen saying why — with the request `quiet`, not even the global dialog.
vi.spyOn(api, "plexSubtitleStatus")
.mockResolvedValueOnce({ state: "partial", code: null, gen: 5 })
.mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subShowable).toBe(true));
expect(result.current.subNotice).toBeNull();
await vi.advanceTimersByTimeAsync(2100);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(result.current.subShowable).toBe(true); // the window it has is still worth showing
});
it("says `unavailable` but keeps asking — and keeps the cues it has on screen", async () => {
// A Plex hiccup, or a stretch ffmpeg could not read, is the server's answer about NOW. Taken as
// final it stopped the poll and unmounted the track: no subtitles for the rest of the session,
// at cached positions too, after the full pass had completed behind it.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValueOnce({ state: "unavailable", code: "plex_error", gen: 5 })
.mockResolvedValue({ state: "partial", code: null, gen: 6 });
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(result.current.subShowable).toBe(true);
await vi.advanceTimersByTimeAsync(5100);
expect(status).toHaveBeenCalledTimes(2);
await waitFor(() => expect(result.current.subNotice).toBeNull());
});
it("does not mount an `unavailable` track that has no cues anywhere", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "unavailable",
code: "plex_error",
gen: 0,
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(result.current.subShowable).toBe(false);
});
it("re-asks the server at once when the viewer picks the track that is already selected", async () => {
// Same track, same query key: without an explicit refetch "picking it again" did nothing for a
// poll that was backing off after a failed request.
const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(status).toHaveBeenCalledTimes(1);
status.mockResolvedValue({ state: "partial", code: null, gen: 2 });
act(() => result.current.changeSubtitle(0));
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.subShowable).toBe(true));
});
});
// Rendered for real, not by calling the hook's callback: the failure arrives as the <track>'s own
// `error` event, which does not bubble — and React 18 attaches no listener of its own to a <track>,
// so the `onError` this used to be wired through was never called in a browser at all.
function TrackHarness() {
const tracks = usePlexTracks({
id: RK,
detail: detail(),
sessionK: 0,
subOffset: 0,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
playhead: () => 3600,
});
// eslint-disable-next-line react-hooks/immutability -- a TEST probe: the hook's return has to reach the assertions, and a render-phase ref write is the only way to read it from outside without a wrapper that would change what is being measured.
harness.current = tracks;
return (
<video>
{tracks.subShowable && (
<track
key={tracks.subTrackKey}
data-key={tracks.subTrackKey}
kind="subtitles"
{...tracks.subTrackListeners}
/>
)}
</video>
);
}
const harness: { current: ReturnType<typeof usePlexTracks> | null } = { current: null };
function renderTrack(qc: QueryClient) {
const view = render(
<QueryClientProvider client={qc}>
<TrackHarness />
</QueryClientProvider>,
);
const track = () => view.container.querySelector("track");
// What the browser does when the VTT fetch fails: a plain, NON-bubbling `error` on the element.
const fail = () => act(() => void track()!.dispatchEvent(new Event("error")));
return { track, fail };
}
describe("usePlexTracks — a <track> whose own VTT fetch failed", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "ready", code: null, gen: 5 });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("is replaced by a fresh element, after a pause, a couple of times — then reported instead of left blank", async () => {
// A failed <track> never fetches again, and while the server's answer is unchanged its key was
// unchanged too — so React kept the dead element and the subtitles stayed silently blank. The
// pause matters as much: remounting at once spent the whole budget inside a one-second outage.
const { track, fail } = renderTrack(client());
act(() => harness.current!.setSubOrd(0));
await waitFor(() => expect(track()).not.toBeNull());
for (const pause of [2000, 4000]) {
const before = track()!.dataset.key;
fail();
await vi.advanceTimersByTimeAsync(pause - 100);
expect(track()!.dataset.key).toBe(before); // not yet: still waiting out the pause
await vi.advanceTimersByTimeAsync(200);
await waitFor(() => expect(track()!.dataset.key).not.toBe(before));
}
fail();
await waitFor(() => expect(track()).toBeNull());
expect(harness.current!.subNotice).toBe("failed");
});
it("gets a fresh budget when the viewer picks the track again", async () => {
// The give-up belongs to one track generation, and a `ready` track is no longer polled, so no new
// generation would ever come: turning subtitles off and on used to leave "couldn't be loaded".
const { track, fail } = renderTrack(client());
act(() => harness.current!.setSubOrd(0));
await waitFor(() => expect(track()).not.toBeNull());
for (let i = 0; i < 3; i++) {
fail();
await vi.advanceTimersByTimeAsync(4100);
}
await waitFor(() => expect(track()).toBeNull());
act(() => harness.current!.changeSubtitle(null));
act(() => harness.current!.changeSubtitle(0));
await waitFor(() => expect(track()).not.toBeNull());
expect(harness.current!.subNotice).toBeNull();
});
});
describe("usePlexTracks — the debounced A/V-offset restart", () => {
afterEach(() => vi.useRealTimers());
it("is dropped when the item changes before it fires — it would restart the previous item", () => {
vi.useFakeTimers();
const restart = vi.fn();
const qc = client();
const { result, rerender } = renderHook(
({ id }: { id: string }) =>
usePlexTracks({
id,
detail: detail(),
sessionK: 0,
subOffset: 0,
restartSession: (at) => restart(id, at),
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
playhead: () => 600,
}),
{
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
),
initialProps: { id: RK },
},
);
act(() => result.current.changeAudioOffset(0.2));
rerender({ id: "next-episode" });
act(() => void vi.advanceTimersByTime(1000));
expect(restart).not.toHaveBeenCalled();
});
});
// The browser no longer draws the cues: its rendering sat under the control bar on an iPad, and a cue
// placed by `line` cannot move out of the way. The track stays loaded, as `hidden`, for PlexSubtitles.
describe("usePlexTracks — who draws the cues", () => {
/** A <video> whose text-track list fires `addtrack` / `change` like a browser's. Each mode is a
* track of the player's own <track>; `mediaAdds` adds one from the media itself instead. */
function videoWithTracks(...modes: TextTrackMode[]) {
const list: { kind: string; mode: TextTrackMode }[] = [];
const tracks = Object.assign(new EventTarget(), {
get length() {
return list.length;
},
[Symbol.iterator]: () => list[Symbol.iterator](),
});
const video = document.createElement("video");
Object.defineProperty(video, "textTracks", { value: tracks });
const add = (mode: TextTrackMode, own: boolean) => {
const track = { kind: "subtitles", mode };
if (own) {
const el = document.createElement("track");
Object.defineProperty(el, "track", { value: track });
video.appendChild(el);
}
list.push(track);
};
for (const mode of modes) add(mode, true);
// What a browser does with a freshly mounted `default` <track>: it switches it on by itself.
const browserAddsShowing = () =>
act(() => {
add("showing", true);
tracks.dispatchEvent(new Event("addtrack"));
});
const browserShows = () =>
act(() => {
list[0]!.mode = "showing";
tracks.dispatchEvent(new Event("change"));
});
// A direct-played file's own subtitle stream, which WebKit lists — and may switch on.
const mediaAdds = (mode: TextTrackMode) =>
act(() => {
add(mode, false);
tracks.dispatchEvent(new Event("addtrack"));
});
return { video, list, browserAddsShowing, browserShows, mediaAdds };
}
function mountOn(video: HTMLVideoElement) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client()}>{children}</QueryClientProvider>
);
return renderHook(
() =>
usePlexTracks({
id: RK,
detail: detail(),
sessionK: 0,
subOffset: 0,
restartSession: () => {},
setAudioTrack: () => false,
patchPrefs: () => {},
videoRef: { current: video },
audioRef: { current: 0 } as RefObject<number | null>,
aoffRef: { current: 0 },
playhead: () => 0,
}),
{ wrapper },
);
}
it("keeps the mounted track hidden: loaded and tracked, not drawn by the browser", () => {
const { video, list } = videoWithTracks("disabled");
const { unmount } = mountOn(video);
expect(list[0]!.mode).toBe("hidden");
unmount();
});
it("hides a remounted track the browser switched on by itself", () => {
const { video, list, browserAddsShowing, browserShows } = videoWithTracks("disabled");
const { unmount } = mountOn(video);
browserAddsShowing();
expect(list.map((t) => t.mode)).toEqual(["hidden", "hidden"]);
browserShows();
expect(list[0]!.mode).toBe("hidden");
unmount();
});
it("switches off a subtitle track that is not the player's own, and leaves its own hidden", () => {
const { video, list, mediaAdds } = videoWithTracks("disabled");
const { unmount } = mountOn(video);
mediaAdds("hidden");
mediaAdds("showing");
expect(list.map((t) => t.mode)).toEqual(["hidden", "disabled", "disabled"]);
unmount();
});
});