Skip to content
Merged
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
52 changes: 52 additions & 0 deletions web/src/player/hooks/useASSSubtitles.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,47 @@ describe("useASSSubtitles windowed ASS extraction", () => {
expect(instances[1]!.destroy).not.toHaveBeenCalled();
});

it("measures timeupdate against the refreshed window and only prefetches at its end", async () => {
const state = vi.fn();
const videoRef = makeVideoRef(1);

renderHook(() => useASSSubtitles(videoRef, [germanTrack], 6, false, 0, 0, state));
await waitFor(() => expect(constructorOpts).toHaveLength(1));
expect(String(vi.mocked(fetch).mock.calls[0]![0])).toContain("position=0&duration=600");

// Cross into the 60s prefetch lead of window [0, 600].
videoRef.current!.currentTime = 590;
videoRef.current!.dispatchEvent(new Event("timeupdate"));

await waitFor(() => expect(constructorOpts).toHaveLength(2));
expect(String(vi.mocked(fetch).mock.calls[1]![0])).toContain("position=570&duration=600");
// windowStart/windowEnd are committed only after the renderer is ready, and
// the refresh moved the load state through "loading". Wait for it to flip
// back to "ready" so the checks below measure against [570, 1170], not the
// outgoing [0, 600].
await waitFor(() => expect(state).toHaveBeenLastCalledWith("ready"));

// Playback inside the refreshed window must not schedule another fetch.
for (const t of [600, 650, 700]) {
videoRef.current!.currentTime = t;
videoRef.current!.dispatchEvent(new Event("timeupdate"));
}
await act(async () => {});
expect(fetch).toHaveBeenCalledTimes(2);
expect(constructorOpts).toHaveLength(2);
expect(instances).toHaveLength(2);

// Entering the 60s prefetch lead of [570, 1170] fetches exactly one next
// window and swaps in exactly one renderer.
videoRef.current!.currentTime = 1120;
videoRef.current!.dispatchEvent(new Event("timeupdate"));

await waitFor(() => expect(constructorOpts).toHaveLength(3));
expect(fetch).toHaveBeenCalledTimes(3);
expect(String(vi.mocked(fetch).mock.calls[2]![0])).toContain("position=1100&duration=600");
expect(instances).toHaveLength(3);
});

it("falls back to the whole-track URL after bounded windowed retries", async () => {
vi.useFakeTimers();
const error = vi.spyOn(console, "error").mockImplementation(() => {});
Expand Down Expand Up @@ -1049,6 +1090,17 @@ describe("useASSSubtitles bounded retry/watchdog policy", () => {
expect(String(vi.mocked(fetch).mock.calls[2]![0])).toContain("position=2980&duration=600");
expect(constructorOpts).toHaveLength(2);
expect(state).toHaveBeenLastCalledWith("ready");
// Continued playback inside the replacement window [2980, 3580] must not
// kick off another fetch.
await act(async () => {
videoRef.current!.currentTime = 3010;
videoRef.current!.dispatchEvent(new Event("timeupdate"));
videoRef.current!.currentTime = 3050;
videoRef.current!.dispatchEvent(new Event("timeupdate"));
await vi.advanceTimersByTimeAsync(0);
});
expect(calls).toBe(3);
expect(constructorOpts).toHaveLength(2);
} finally {
unmount();
error.mockRestore();
Expand Down
12 changes: 8 additions & 4 deletions web/src/player/hooks/useASSSubtitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,13 @@ export function useASSSubtitles(
// Infinity once the whole-track fallback is in use, so boundary refresh
// never schedules.
let usingWholeTrack = false;
const windowStart = Math.max(
let windowStart = Math.max(
0,
(video.readyState > 0 ? video.currentTime : 0) +
sourceOriginRef.current -
ASS_WINDOW_LEAD_SECONDS,
);
const windowEnd = windowStart + ASS_WINDOW_DURATION_SECONDS;
let windowEnd = windowStart + ASS_WINDOW_DURATION_SECONDS;
// Consecutive windowed failures since the last successful window, shared by
// the initial load and boundary refreshes so the 3→1→terminal budget is one
// finite policy. `terminal` latches after the whole-track fallback fails:
Expand Down Expand Up @@ -582,8 +582,12 @@ export function useASSSubtitles(
() => jassubRef.current === instance,
);
if (!cancelled && !signal.aborted && jassubRef.current === instance) {
// A successful load clears the failure budgets so a later unrelated
// error starts fresh.
// Advance to the window this instance renders so the next
// timeupdate check measures against the loaded track, then clear
// the failure budgets so a later unrelated error starts fresh.
usingWholeTrack = wholeTrack;
windowStart = start;
windowEnd = wholeTrack ? Infinity : start + ASS_WINDOW_DURATION_SECONDS;
windowFailures = 0;
textFailures = 0;
retryableTextFailures = 0;
Expand Down
Loading