feat: prefetch next episode and hover-preload in player

This commit is contained in:
2026-07-05 23:09:04 +02:00
parent 7d59674649
commit 3c82c32e87
4 changed files with 271 additions and 27 deletions

View File

@@ -115,6 +115,7 @@ type FakeVideoElement = FakeElement & {
type PlayerModules = {
completeAnime: (episodeNumber: number) => Promise<void>;
handleEpisodeNavigationClick: (event: MouseEvent) => boolean;
prefetchNextEpisode: () => void;
refreshCurrentModeSource: (signal?: AbortSignal) => Promise<boolean>;
saveProgress: (force?: boolean, progressSeconds?: number) => Promise<void>;
setupProgress: () => void;
@@ -331,6 +332,7 @@ const loadPlayerModules = async (): Promise<PlayerModules> => {
return {
completeAnime: completeModule.completeAnime,
handleEpisodeNavigationClick: navModule.handleEpisodeNavigationClick,
prefetchNextEpisode: navModule.prefetchNextEpisode,
refreshCurrentModeSource: sourceModule.refreshCurrentModeSource,
saveProgress: progressModule.saveProgress,
setupProgress: progressModule.setupProgress,
@@ -368,6 +370,7 @@ beforeEach(() => {
};
globalThis.setTimeout = windowStub.setTimeout.bind(windowStub) as typeof setTimeout;
resetPlayerState();
delete (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
});
const episodePayload = (
@@ -433,7 +436,7 @@ describe("browser player flow", () => {
assert.equal(refreshed, true);
assert.deepEqual(
fetchCalls.map((call) => call.url),
["/api/watch/episode/42/3?mode=sub"],
["/api/watch/episode/42/3?mode=sub&refresh=1"],
);
assert.equal(modules.state.playback.modeSources.sub?.token, "fresh-token");
assert.equal(
@@ -524,6 +527,38 @@ describe("browser player flow", () => {
assert.equal(modules.state.episode.transitionEpisode, null);
});
test("uses a prefetched next-episode payload for the transition", async () => {
const controller = new AbortController();
modules.setupEpisodeNavigation(controller.signal);
globalThis.fetch = ((url: string) => {
fetchCalls.push({ url });
return Promise.resolve(episodeResponse(4));
}) as typeof fetch;
modules.prefetchNextEpisode();
await flushPromises();
assert.equal(await modules.transitionToEpisode(4), true);
assert.deepEqual(
fetchCalls.map((call) => call.url),
["/api/watch/episode/42/4?mode=sub"],
);
controller.abort();
});
test("does not automatically prefetch when Save-Data is enabled", () => {
const controller = new AbortController();
modules.setupEpisodeNavigation(controller.signal);
(navigator as Navigator & { connection?: { saveData?: boolean } }).connection = {
saveData: true,
};
modules.prefetchNextEpisode();
assert.equal(fetchCalls.length, 0);
controller.abort();
});
test("uses the same async path for episode links and leaves modified clicks native", async () => {
const modifiedLink = new FakeAnchorElement();
modifiedLink.href = "http://localhost/anime/42/watch?ep=2";
@@ -646,6 +681,7 @@ describe("browser player flow", () => {
modules.state.elements.video.dispatchEvent(new Event("error"));
await flushPromises();
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-2");
assert.equal(fetchCalls[1]?.url, "/api/watch/episode/42/4?mode=sub&refresh=1");
modules.state.elements.video.dispatchEvent(new Event("error"));
assert.equal(windowStub.location.href, fallbackHref);