From 3c82c32e8790bdfb9231bac820325899df39570a Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 5 Jul 2026 23:09:04 +0200 Subject: [PATCH] feat: prefetch next episode and hover-preload in player --- static/player/browser-flow.test.ts | 38 ++++- static/player/episodes/nav.ts | 255 ++++++++++++++++++++++++++--- static/player/main.ts | 3 +- static/player/source.ts | 2 +- 4 files changed, 271 insertions(+), 27 deletions(-) diff --git a/static/player/browser-flow.test.ts b/static/player/browser-flow.test.ts index 8f8b87e6..37f771a6 100644 --- a/static/player/browser-flow.test.ts +++ b/static/player/browser-flow.test.ts @@ -115,6 +115,7 @@ type FakeVideoElement = FakeElement & { type PlayerModules = { completeAnime: (episodeNumber: number) => Promise; handleEpisodeNavigationClick: (event: MouseEvent) => boolean; + prefetchNextEpisode: () => void; refreshCurrentModeSource: (signal?: AbortSignal) => Promise; saveProgress: (force?: boolean, progressSeconds?: number) => Promise; setupProgress: () => void; @@ -331,6 +332,7 @@ const loadPlayerModules = async (): Promise => { 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); diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index 08de9835..ee09078d 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -51,6 +51,11 @@ type EpisodeTransitionProfile = { lastTotalMs: number; stale: number; succeeded: number; + prefetchCancelled: number; + prefetchExpired: number; + prefetchFailed: number; + prefetchStarted: number; + prefetchUsed: number; }; declare global { @@ -68,6 +73,11 @@ const profile = (): EpisodeTransitionProfile => { lastTotalMs: 0, stale: 0, succeeded: 0, + prefetchCancelled: 0, + prefetchExpired: 0, + prefetchFailed: 0, + prefetchStarted: 0, + prefetchUsed: 0, }; return window.__malEpisodeTransitionProfile; }; @@ -84,6 +94,25 @@ let activeTransition: ActiveTransition | null = null; let modeHydrationController: AbortController | null = null; let transitionID = 0; +const prefetchTTL = 2 * 60 * 1000; +const prefetchLimit = 2; + +type PrefetchReason = "next" | "hover"; + +type PrefetchEntry = { + controller: AbortController; + episode: number; + expiresAt: number; + mode: string; + promise: Promise; + reason: PrefetchReason; +}; + +const prefetchedEpisodes = new Map(); +let hoverPrefetchKey: string | null = null; +let hoverTimer: ReturnType | undefined; +let prefetchEnabled = false; + const episodeHref = (episode: number): string => { const url = new URL(window.location.href); url.searchParams.set("ep", String(episode)); @@ -117,6 +146,135 @@ const parseEpisodePayload = (value: unknown): EpisodePayload | null => { }; }; +const episodePayloadURL = (episode: number, mode: string, forceRefresh = false): string => + `/api/watch/episode/${state.episode.malID}/${encodeURIComponent(String(episode))}?mode=${encodeURIComponent(mode)}${forceRefresh ? "&refresh=1" : ""}`; + +const fetchEpisodePayload = async ( + episode: number, + mode: string, + signal: AbortSignal, + lowPriority = false, + forceRefresh = false, +): Promise => { + const init: RequestInit & { priority?: "low" } = { signal }; + if (lowPriority) { + init.priority = "low"; + } + const response = await fetch(episodePayloadURL(episode, mode, forceRefresh), init); + if (!response.ok) { + throw new Error(`episode payload failed with status ${response.status}`); + } + const payload = parseEpisodePayload(await response.json()); + if (!payload) { + throw new Error("episode payload returned no playable source"); + } + return payload; +}; + +const prefetchKey = (episode: number, mode: string): string => + `${state.episode.malID}|${episode}|${mode}`; + +const removePrefetch = (key: string, cancel: boolean): void => { + const entry = prefetchedEpisodes.get(key); + if (!entry) { + return; + } + prefetchedEpisodes.delete(key); + if (cancel) { + entry.controller.abort(); + } +}; + +const startEpisodePrefetch = ( + episode: number, + mode: string, + reason: PrefetchReason, +): PrefetchEntry | null => { + if (!Number.isInteger(episode) || episode < 1 || episode === Number(state.episode.current)) { + return null; + } + const key = prefetchKey(episode, mode); + const existing = prefetchedEpisodes.get(key); + if (existing && existing.expiresAt > Date.now()) { + return existing; + } + if (existing) { + removePrefetch(key, true); + } + + const controller = new AbortController(); + const promise = fetchEpisodePayload(episode, mode, controller.signal, true) + .catch((error: unknown) => { + if (!(error instanceof DOMException && error.name === "AbortError")) { + profile().prefetchFailed += 1; + } + return null; + }) + .then((payload) => { + if (!payload && prefetchedEpisodes.get(key)?.controller === controller) { + prefetchedEpisodes.delete(key); + } + return payload; + }); + const entry: PrefetchEntry = { + controller, + episode, + expiresAt: Date.now() + prefetchTTL, + mode, + promise, + reason, + }; + prefetchedEpisodes.set(key, entry); + profile().prefetchStarted += 1; + + while (prefetchedEpisodes.size > prefetchLimit) { + const oldest = prefetchedEpisodes.keys().next().value as string | undefined; + if (!oldest) { + break; + } + removePrefetch(oldest, true); + } + return entry; +}; + +const takePrefetchedEpisode = ( + episode: number, + mode: string, +): Promise | null => { + const key = prefetchKey(episode, mode); + const entry = prefetchedEpisodes.get(key); + if (!entry) { + return null; + } + prefetchedEpisodes.delete(key); + if (entry.expiresAt <= Date.now()) { + entry.controller.abort(); + profile().prefetchExpired += 1; + return null; + } + return entry.promise.then((payload) => { + if (payload) { + profile().prefetchUsed += 1; + } + return payload; + }); +}; + +const saveDataEnabled = (): boolean => + Boolean((navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData); + +export const prefetchNextEpisode = (): void => { + if (!prefetchEnabled || saveDataEnabled()) { + return; + } + const current = Number.parseInt(state.episode.current, 10); + const next = current + 1; + if (!current || (state.episode.total > 0 && next > state.episode.total)) { + return; + } + startEpisodePrefetch(next, state.playback.currentMode, "next"); +}; + const selectedMode = (payload: EpisodePayload, requestedMode: string): string | null => { if (payload.modeSources[requestedMode]?.token) { return requestedMode; @@ -178,23 +336,19 @@ const monitorMediaReady = (transition: ActiveTransition): void => { return; } retried = true; - fetch( - `/api/watch/episode/${state.episode.malID}/${encodeURIComponent(state.episode.current)}?mode=${encodeURIComponent(state.playback.currentMode)}`, - { signal }, + fetchEpisodePayload( + Number.parseInt(state.episode.current, 10), + state.playback.currentMode, + signal, + false, + true, ) - .then((response) => { - if (!response.ok) { - throw new Error(`episode source refresh failed with status ${response.status}`); - } - return response.json(); - }) - .then((value: unknown) => { + .then((payload) => { if (signal.aborted || activeTransition?.id !== transition.id) { return; } - const payload = parseEpisodePayload(value); - const mode = payload ? selectedMode(payload, state.playback.currentMode) : null; - if (!payload || !mode) { + const mode = selectedMode(payload, state.playback.currentMode); + if (!mode) { throw new Error("episode source refresh returned no playable source"); } state.playback.modeSources = payload.modeSources; @@ -324,17 +478,11 @@ export const transitionToEpisode = async ( try { const requestedMode = state.playback.currentMode; - const response = await fetch( - `/api/watch/episode/${state.episode.malID}/${episode}?mode=${encodeURIComponent(requestedMode)}`, - { signal: transition.controller.signal }, - ); - if (!response.ok) { - throw new Error(`episode transition failed with status ${response.status}`); - } - const payload = parseEpisodePayload(await response.json()); - if (!payload) { - throw new Error("episode transition returned no playable source"); - } + const prefetched = takePrefetchedEpisode(episode, requestedMode); + const payload = prefetched + ? ((await prefetched) ?? + (await fetchEpisodePayload(episode, requestedMode, transition.controller.signal))) + : await fetchEpisodePayload(episode, requestedMode, transition.controller.signal); if (activeTransition?.id !== transition.id) { profile().stale += 1; return false; @@ -409,6 +557,7 @@ export const handleEpisodeNavigationClick = (event: MouseEvent): boolean => { export const setupEpisodeNavigation = (signal: AbortSignal): void => { const root = document.querySelector("[data-episode-navigation]"); + prefetchEnabled = true; root?.addEventListener( "click", (event) => { @@ -419,6 +568,59 @@ export const setupEpisodeNavigation = (signal: AbortSignal): void => { { signal }, ); + const cancelHoverPrefetch = (): void => { + if (hoverTimer !== undefined) { + clearTimeout(hoverTimer); + hoverTimer = undefined; + } + if (hoverPrefetchKey) { + const entry = prefetchedEpisodes.get(hoverPrefetchKey); + if (entry?.reason === "hover") { + removePrefetch(hoverPrefetchKey, true); + profile().prefetchCancelled += 1; + } + hoverPrefetchKey = null; + } + }; + + const scheduleHoverPrefetch = (target: EventTarget | null): void => { + if (!(target instanceof Element)) { + return; + } + const anchor = target.closest("a[data-episode-id]"); + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + const destination = episodeFromLink(anchor); + if (!destination || destination.episode === Number(state.episode.current)) { + return; + } + cancelHoverPrefetch(); + const key = prefetchKey(destination.episode, state.playback.currentMode); + hoverTimer = setTimeout(() => { + hoverTimer = undefined; + const entry = startEpisodePrefetch(destination.episode, state.playback.currentMode, "hover"); + hoverPrefetchKey = entry?.reason === "hover" ? key : null; + }, 120); + }; + + root?.addEventListener( + "pointerover", + (event) => { + scheduleHoverPrefetch(event.target); + }, + { signal }, + ); + root?.addEventListener( + "focusin", + (event) => { + scheduleHoverPrefetch(event.target); + }, + { signal }, + ); + root?.addEventListener("pointerleave", cancelHoverPrefetch, { signal }); + root?.addEventListener("focusout", cancelHoverPrefetch, { signal }); + window.addEventListener( "popstate", () => { @@ -439,6 +641,11 @@ export const setupEpisodeNavigation = (signal: AbortSignal): void => { activeTransition = null; modeHydrationController?.abort(); modeHydrationController = null; + prefetchEnabled = false; + cancelHoverPrefetch(); + for (const key of prefetchedEpisodes.keys()) { + removePrefetch(key, true); + } }, { once: true }, ); diff --git a/static/player/main.ts b/static/player/main.ts index 520762b4..de1d97fd 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -2,7 +2,7 @@ import { onHtmxLoad, onReady } from "../utils"; import { setupControls, showControls } from "./controls"; import { formatTime } from "./controls"; import { setupEpisodeMetadata } from "./episodes/metadata"; -import { goToNextEpisode, setupEpisodeNavigation } from "./episodes/nav"; +import { goToNextEpisode, prefetchNextEpisode, setupEpisodeNavigation } from "./episodes/nav"; import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from "./episodes/ui"; import { setupKeyboard } from "./keyboard"; import { @@ -134,6 +134,7 @@ const initPlayer = async (): Promise => { ); updateTimeline(state.elements.video.currentTime); updateSkipButton(state.elements.video.currentTime); + prefetchNextEpisode(); if (shouldShowControls) { showControls(); } diff --git a/static/player/source.ts b/static/player/source.ts index 4cabca7d..8bd21458 100644 --- a/static/player/source.ts +++ b/static/player/source.ts @@ -23,7 +23,7 @@ export const streamUrlForMode = (mode: string, quality?: string): string => { export const refreshCurrentModeSource = async (signal?: AbortSignal): Promise => { const mode = state.playback.currentMode; const res = await fetch( - `/api/watch/episode/${state.episode.malID}/${encodeURIComponent(state.episode.current)}?mode=${encodeURIComponent(mode)}`, + `/api/watch/episode/${state.episode.malID}/${encodeURIComponent(state.episode.current)}?mode=${encodeURIComponent(mode)}&refresh=1`, { signal }, ); if (!res.ok) {