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.
534 lines
22 KiB
TypeScript
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();
|
|
});
|
|
});
|