diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index 1c5d7a3a..08de9835 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -1,153 +1,472 @@ -import type { SkipSegment } from "../types"; - import { hydrateAlternateMode, updateModeButtons } from "../mode"; import { markEpisodeTransition } from "../progress"; import { updateQualityOptions } from "../quality"; import { resolveActiveSegments, renderSegments } from "../skip/segments"; +import { streamUrlForMode } from "../source"; import { state, showEndState, hideEndState } from "../state"; import { safeLocalStorage } from "../storage"; import { updateSubtitleOptions } from "../subtitles"; +import { isRecord, parseModeSources, parseSegments } from "../validate"; import { loadVideoSource } from "../video"; import { completeAnime } from "./complete"; -import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from "./ui"; +import { + isAutoplayEnabled, + switchEpisodeRange, + updateEpisodeHighlight, + updateEpisodeNavigation, + updateOverlay, +} from "./ui"; -/** - * Handles video end: either marks complete or loads next episode. Fetches episode data from API, - * updates player state and URL. - */ -export const goToNextEpisode = async (): Promise => { - const currentEp = Number.parseInt(state.episode.current, 10); - if (!currentEp) { +type EpisodePayload = { + episodeTitle: string; + initialMode: string; + modeSources: ReturnType; + modeSwitchedFrom: string; + segments: ReturnType; + startTimeSeconds: number; +}; + +type TransitionHistory = "none" | "push" | "replace"; + +type TransitionOptions = { + autoplay?: boolean; + fallbackHref?: string; + history?: TransitionHistory; + restoreFocus?: boolean; +}; + +type ActiveTransition = { + autoplay: boolean; + controller: AbortController; + fallbackHref: string; + id: number; + startedAt: number; +}; + +type EpisodeTransitionProfile = { + aborted: number; + fallback: number; + lastMediaReadyMs: number; + lastPayloadMs: number; + lastTotalMs: number; + stale: number; + succeeded: number; +}; + +declare global { + interface Window { + __malEpisodeTransitionProfile?: EpisodeTransitionProfile; + } +} + +const profile = (): EpisodeTransitionProfile => { + window.__malEpisodeTransitionProfile ??= { + aborted: 0, + fallback: 0, + lastMediaReadyMs: 0, + lastPayloadMs: 0, + lastTotalMs: 0, + stale: 0, + succeeded: 0, + }; + return window.__malEpisodeTransitionProfile; +}; + +const measure = (name: string, startedAt: number, duration: number): void => { + try { + performance.measure(name, { start: startedAt, duration }); + } catch (error) { + console.debug("failed to measure episode transition:", error); + } +}; + +let activeTransition: ActiveTransition | null = null; +let modeHydrationController: AbortController | null = null; +let transitionID = 0; + +const episodeHref = (episode: number): string => { + const url = new URL(window.location.href); + url.searchParams.set("ep", String(episode)); + return url.toString(); +}; + +const fallbackToEpisodeNavigation = (href: string, autoplay: boolean): void => { + profile().fallback += 1; + if (autoplay) { + sessionStorage.setItem("mal:autoplay-next", "true"); + } + window.location.href = href; +}; + +const parseEpisodePayload = (value: unknown): EpisodePayload | null => { + if (!isRecord(value)) { + return null; + } + const modeSources = parseModeSources(value.mode_sources); + if (Object.keys(modeSources).length === 0) { + return null; + } + const parsedStartTime = Number(value.start_time_seconds); + return { + episodeTitle: typeof value.episode_title === "string" ? value.episode_title : "", + initialMode: typeof value.initial_mode === "string" ? value.initial_mode : "", + modeSources, + modeSwitchedFrom: typeof value.mode_switched_from === "string" ? value.mode_switched_from : "", + segments: parseSegments(value.segments), + startTimeSeconds: Number.isFinite(parsedStartTime) && parsedStartTime > 0 ? parsedStartTime : 0, + }; +}; + +const selectedMode = (payload: EpisodePayload, requestedMode: string): string | null => { + if (payload.modeSources[requestedMode]?.token) { + return requestedMode; + } + if (payload.modeSources[payload.initialMode]?.token) { + return payload.initialMode; + } + return Object.keys(payload.modeSources).find((mode) => payload.modeSources[mode]?.token) ?? null; +}; + +const updateHistory = (episode: number, mode: TransitionHistory): void => { + if (mode === "none") { return; } + const href = episodeHref(episode); + if (mode === "replace") { + history.replaceState(null, "", href); + return; + } + history.pushState(null, "", href); +}; - const navigateToEpisode = (episode: number): void => { - const url = new URL(window.location.href); - url.searchParams.set("ep", String(episode)); - window.location.href = url.toString(); +const showLoader = (): void => { + const loading = state.elements.container.querySelector("[data-loading]") as HTMLElement | null; + if (loading) { + loading.style.display = "flex"; + } +}; + +const finishTransition = (transition: ActiveTransition): void => { + if (activeTransition?.id !== transition.id) { + return; + } + const elapsed = performance.now() - transition.startedAt; + const metrics = profile(); + metrics.lastMediaReadyMs = elapsed; + metrics.lastTotalMs = elapsed; + metrics.succeeded += 1; + measure("episode_transition_media_ready_ms", transition.startedAt, elapsed); + measure("episode_transition_total_ms", transition.startedAt, elapsed); + state.episode.transitionEpisode = null; + activeTransition = null; + transition.controller.abort(); +}; + +const monitorMediaReady = (transition: ActiveTransition): void => { + const { signal } = transition.controller; + let retried = false; + + const onLoadedMetadata = (): void => { + finishTransition(transition); + }; + const onError = (): void => { + if (signal.aborted || activeTransition?.id !== transition.id) { + return; + } + if (retried) { + fallbackToEpisodeNavigation(transition.fallbackHref, transition.autoplay); + return; + } + retried = true; + fetch( + `/api/watch/episode/${state.episode.malID}/${encodeURIComponent(state.episode.current)}?mode=${encodeURIComponent(state.playback.currentMode)}`, + { signal }, + ) + .then((response) => { + if (!response.ok) { + throw new Error(`episode source refresh failed with status ${response.status}`); + } + return response.json(); + }) + .then((value: unknown) => { + if (signal.aborted || activeTransition?.id !== transition.id) { + return; + } + const payload = parseEpisodePayload(value); + const mode = payload ? selectedMode(payload, state.playback.currentMode) : null; + if (!payload || !mode) { + throw new Error("episode source refresh returned no playable source"); + } + state.playback.modeSources = payload.modeSources; + state.playback.currentMode = mode; + const source = payload.modeSources[mode]; + const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best"; + loadVideoSource( + streamUrlForMode(mode, preferredQuality), + source.type, + payload.startTimeSeconds, + false, + ); + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + console.error("failed to refresh episode source:", error); + fallbackToEpisodeNavigation(transition.fallbackHref, transition.autoplay); + }); }; - const fallbackToEpisodeNavigation = (episode: number): void => { - sessionStorage.setItem("mal:autoplay-next", "true"); - navigateToEpisode(episode); - }; + state.elements.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal }); + state.elements.video.addEventListener("error", onError, { signal }); +}; - // final episode: trigger completion flow or just stop if airing - if (state.episode.total > 0 && currentEp >= state.episode.total) { +const hydrateEpisodeModes = (): void => { + modeHydrationController?.abort(); + const controller = new AbortController(); + modeHydrationController = controller; + hydrateAlternateMode(controller.signal) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + console.error("failed to hydrate alternate mode after episode change:", error); + }) + .finally(() => { + if (modeHydrationController === controller) { + modeHydrationController = null; + } + }); +}; + +const commitEpisode = ( + episode: number, + payload: EpisodePayload, + mode: string, + transition: ActiveTransition, + options: TransitionOptions, +): void => { + state.playback.modeSources = payload.modeSources; + state.playback.currentMode = mode; + state.playback.modeSwitchedFrom = payload.modeSwitchedFrom; + state.playback.startTimeSeconds = payload.startTimeSeconds; + state.playback.pendingSeekTime = null; + state.episode.current = String(episode); + state.episode.endedProgressSaved = false; + state.episode.completionSent = false; + state.episode.completionAttempts = 0; + state.subtitles.activeCues = []; + state.skip.parsedSegments = payload.segments; + + state.elements.container.dataset.currentEpisode = state.episode.current; + state.elements.container.dataset.startTimeSeconds = String(payload.startTimeSeconds); + hideEndState(); + + const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best"; + const source = payload.modeSources[mode]; + monitorMediaReady(transition); + loadVideoSource( + streamUrlForMode(mode, preferredQuality), + source.type, + payload.startTimeSeconds, + false, + ); + + updateSubtitleOptions(); + updateQualityOptions(); + updateModeButtons(); + updateOverlay(state.episode.current, payload.episodeTitle); + resolveActiveSegments(); + renderSegments(); + switchEpisodeRange(Math.floor((episode - 1) / 100)); + updateEpisodeHighlight(episode, options.restoreFocus ?? false); + updateEpisodeNavigation(episode); + updateHistory(episode, options.history ?? "push"); + + if (payload.modeSwitchedFrom === "dub" && mode === "sub") { + window.showToast?.({ + message: `Episode ${episode} is only available in sub, switched from dub.`, + }); + } + hydrateEpisodeModes(); +}; + +export const transitionToEpisode = async ( + episode: number, + options: TransitionOptions = {}, +): Promise => { + if ( + !Number.isInteger(episode) || + episode < 1 || + (state.episode.total > 0 && episode > state.episode.total) || + episode === Number.parseInt(state.episode.current, 10) || + episode === state.episode.transitionEpisode + ) { + return false; + } + + if (activeTransition) { + activeTransition.controller.abort(); + profile().aborted += 1; + } + modeHydrationController?.abort(); + modeHydrationController = null; + const transition: ActiveTransition = { + autoplay: options.autoplay ?? false, + controller: new AbortController(), + fallbackHref: options.fallbackHref ?? episodeHref(episode), + id: ++transitionID, + startedAt: performance.now(), + }; + activeTransition = transition; + markEpisodeTransition(episode); + showLoader(); + + 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"); + } + if (activeTransition?.id !== transition.id) { + profile().stale += 1; + return false; + } + const mode = selectedMode(payload, requestedMode); + if (!mode) { + throw new Error("episode transition returned no playable mode"); + } + const payloadElapsed = performance.now() - transition.startedAt; + profile().lastPayloadMs = payloadElapsed; + measure("episode_transition_payload_ms", transition.startedAt, payloadElapsed); + commitEpisode(episode, payload, mode, transition, options); + return true; + } catch (error: unknown) { + if (error instanceof DOMException && error.name === "AbortError") { + return false; + } + if (activeTransition?.id !== transition.id) { + profile().stale += 1; + return false; + } + console.error("failed to transition episode:", error); + fallbackToEpisodeNavigation(transition.fallbackHref, options.autoplay ?? false); + return false; + } +}; + +const episodeFromLink = (anchor: HTMLAnchorElement): { episode: number; href: string } | null => { + if (anchor.target && anchor.target !== "_self") { + return null; + } + const url = new URL(anchor.href, window.location.href); + const parts = url.pathname.split("/").filter(Boolean); + const episode = Number.parseInt(anchor.dataset.episodeId ?? url.searchParams.get("ep") ?? "", 10); + if ( + url.origin !== window.location.origin || + parts[0] !== "anime" || + parts[2] !== "watch" || + Number.parseInt(parts[1] ?? "", 10) !== state.episode.malID || + !Number.isInteger(episode) || + episode < 1 + ) { + return null; + } + return { episode, href: url.toString() }; +}; + +export const handleEpisodeNavigationClick = (event: MouseEvent): boolean => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return false; + } + const target = event.target; + if (!(target instanceof Element)) { + return false; + } + const anchor = target.closest("a[data-episode-id]"); + if (!(anchor instanceof HTMLAnchorElement)) { + return false; + } + const destination = episodeFromLink(anchor); + if (!destination || destination.episode === Number.parseInt(state.episode.current, 10)) { + return false; + } + event.preventDefault(); + void transitionToEpisode(destination.episode, { + fallbackHref: destination.href, + history: "push", + restoreFocus: true, + }); + return true; +}; + +export const setupEpisodeNavigation = (signal: AbortSignal): void => { + const root = document.querySelector("[data-episode-navigation]"); + root?.addEventListener( + "click", + (event) => { + if (event instanceof MouseEvent) { + handleEpisodeNavigationClick(event); + } + }, + { signal }, + ); + + window.addEventListener( + "popstate", + () => { + const url = new URL(window.location.href); + const episode = Number.parseInt(url.searchParams.get("ep") ?? "1", 10); + if (!episode || episode === Number.parseInt(state.episode.current, 10)) { + return; + } + void transitionToEpisode(episode, { fallbackHref: url.toString(), history: "none" }); + }, + { signal }, + ); + + signal.addEventListener( + "abort", + () => { + activeTransition?.controller.abort(); + activeTransition = null; + modeHydrationController?.abort(); + modeHydrationController = null; + }, + { once: true }, + ); +}; + +/** Handles video end: completes the series or advances when autoplay is enabled. */ +export const goToNextEpisode = async (): Promise => { + const currentEpisode = Number.parseInt(state.episode.current, 10); + if (!currentEpisode) { + return; + } + if (state.episode.total > 0 && currentEpisode >= state.episode.total) { if (!state.episode.isAiring) { - completeAnime(currentEp).catch((error) => { + completeAnime(currentEpisode).catch((error: unknown) => { console.error("failed to complete final episode:", error); }); } showEndState(); return; } - - // skip if autoplay disabled if (!isAutoplayEnabled()) { showEndState(); return; } - - const nextEp = currentEp + 1; - markEpisodeTransition(nextEp); - - try { - const res = await fetch( - `/api/watch/episode/${state.episode.malID}/${nextEp}?mode=${encodeURIComponent(state.playback.currentMode)}`, - ); - if (!res.ok) { - // fallback: full page navigation - fallbackToEpisodeNavigation(nextEp); - return; - } - - const data = await res.json(); - - // update state with new episode data - state.playback.modeSources = data.mode_sources ?? {}; - - const backendMode = typeof data.initial_mode === "string" ? data.initial_mode : ""; - const fallback = state.playback.modeSources[backendMode]?.token - ? backendMode - : state.playback.availableModes.find((m) => state.playback.modeSources[m]?.token); - if (!fallback) { - fallbackToEpisodeNavigation(nextEp); - return; - } - - state.episode.current = String(nextEp); - state.playback.currentMode = fallback; - state.episode.endedProgressSaved = false; - - hideEndState(); - - if (data.mode_switched_from === "dub" && fallback === "sub") { - window.showToast?.({ - message: `Episode ${nextEp} is only available in sub, switched from dub.`, - }); - } - // The progress reset is sent asynchronously, so do not trust the fetch to observe it first. - state.playback.startTimeSeconds = 0; - state.elements.container.dataset.currentEpisode = state.episode.current; - state.elements.container.dataset.startTimeSeconds = String(state.playback.startTimeSeconds); - - // load new video (keep preferences) - const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best"; - const source = state.playback.modeSources[fallback]; - const nextSourceURL = `${state.playback.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(source.token)}${source.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`; - loadVideoSource(nextSourceURL, source.type); - if (!state.elements.video.paused) { - state.elements.video.play().catch((error) => { - console.debug("failed to play video:", error); - }); - } - - state.playback.pendingSeekTime = null; - state.episode.completionSent = false; - state.episode.completionAttempts = 0; - state.subtitles.activeCues = []; - - // update UI - updateSubtitleOptions(); - updateQualityOptions(); - updateModeButtons(); - updateOverlay(state.episode.current, data.episode_title ?? ""); - hydrateAlternateMode().catch((error) => { - console.error("failed to hydrate alternate mode after episode change:", error); - }); - - // update skip segments - if (data.segments?.length) { - state.skip.parsedSegments = data.segments - .map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 })) - .filter((s: SkipSegment) => s.end > s.start); - resolveActiveSegments(); - renderSegments(); - } - - // highlight new episode in list/grid - state.elements.episodeList?.querySelectorAll("[data-episode-id]").forEach((el) => { - el.classList.remove("bg-accent/20"); - }); - const newListEl = state.elements.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`); - newListEl?.classList.add("bg-accent/20"); - - if (state.elements.episodeGrid) { - state.elements.episodeGrid.querySelectorAll("[data-episode-id]").forEach((el) => { - el.classList.remove("bg-accent/20", "ring-2", "ring-accent", "text-accent"); - }); - switchEpisodeRange(Math.floor((nextEp - 1) / 100)); - const newGridEl = state.elements.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`); - newGridEl?.classList.add("bg-accent/20", "ring-2", "ring-accent", "text-accent"); - } - - // update URL without reload - const url = new URL(window.location.href); - url.searchParams.set("ep", String(nextEp)); - history.pushState(null, "", url.toString()); - } catch (error) { - console.error("failed to update url:", error); - fallbackToEpisodeNavigation(nextEp); - } + const nextEpisode = currentEpisode + 1; + await transitionToEpisode(nextEpisode, { + autoplay: true, + fallbackHref: episodeHref(nextEpisode), + history: "push", + }); };