From 20aadd36f8fd12ceb05f8a812d0ebaaa7247b1cd Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 14 Jun 2026 21:19:59 +0200 Subject: [PATCH] feat: preload alternate mode source on episode load --- static/player/episodes/nav.ts | 3 +- static/player/main.ts | 3 +- static/player/mode.ts | 79 +++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index 1e6d7c3..4165c45 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -3,7 +3,7 @@ import type { SkipSegment } from "../types"; import { resolveActiveSegments, renderSegments } from "../skip/segments"; import { updateSubtitleOptions } from "../subtitles"; import { updateQualityOptions } from "../quality"; -import { updateModeButtons } from "../mode"; +import { hydrateAlternateMode, updateModeButtons } from "../mode"; import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from "./ui"; import { markEpisodeTransition } from "../progress"; import { safeLocalStorage } from "../storage"; @@ -105,6 +105,7 @@ export const goToNextEpisode = async (): Promise => { updateQualityOptions(); updateModeButtons(); updateOverlay(state.currentEpisode, data.episode_title ?? ""); + void hydrateAlternateMode(); // update skip segments if (data.segments?.length) { diff --git a/static/player/main.ts b/static/player/main.ts index f93300f..c8b154f 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -5,7 +5,7 @@ import { setupKeyboard } from "./keyboard"; import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles"; import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip"; import { setupQuality, updateQualityOptions } from "./quality"; -import { setupMode, updateModeButtons } from "./mode"; +import { hydrateAlternateMode, setupMode, updateModeButtons } from "./mode"; import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from "./episodes/ui"; import { goToNextEpisode } from "./episodes/nav"; import { resolveActiveSegments, renderSegments } from "./skip/segments"; @@ -385,6 +385,7 @@ const initPlayer = (): void => { } setupThumbnails(); + void hydrateAlternateMode(signal); }; onReady(initPlayer); diff --git a/static/player/mode.ts b/static/player/mode.ts index ecf37f4..0bcaca1 100644 --- a/static/player/mode.ts +++ b/static/player/mode.ts @@ -5,6 +5,85 @@ import { updateQualityOptions } from "./quality"; import { safeLocalStorage } from "./storage"; import { streamUrlForMode } from "./source"; import { loadVideoSource } from "./video"; +import type { ModeSource } from "./types"; + +const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.every((item) => typeof item === "string"); + +const isSubtitleItemArray = (v: unknown): v is { lang: string; token: string }[] => + Array.isArray(v) && + v.every( + (item) => isRecord(item) && typeof item.lang === "string" && typeof item.token === "string", + ); + +const parseModeSources = (v: unknown): Record => { + if (!isRecord(v)) return {}; + + const out: Record = {}; + for (const [key, value] of Object.entries(v)) { + if (!isRecord(value)) continue; + if (typeof value.token !== "string" || value.token === "") continue; + + const subtitles = value.subtitles == null ? [] : value.subtitles; + if (!isSubtitleItemArray(subtitles)) continue; + + const qualities = value.qualities; + out[key] = { + token: value.token, + subtitles, + qualities: isStringArray(qualities) ? qualities : undefined, + }; + } + + return out; +}; + +const alternateModeFor = (mode: string): "sub" | "dub" | null => { + if (mode === "sub") return "dub"; + if (mode === "dub") return "sub"; + return null; +}; + +const mergeAvailableMode = (mode: string): void => { + if (state.availableModes.includes(mode)) return; + state.availableModes = [...state.availableModes, mode].sort(); +}; + +export const hydrateAlternateMode = async (signal?: AbortSignal): Promise => { + const alternateMode = alternateModeFor(state.currentMode); + if (!alternateMode) return; + if (state.modeSources[alternateMode]?.token) return; + + try { + const res = await fetch( + `/api/watch/episode/${state.malID}/${encodeURIComponent(state.currentEpisode)}?mode=${encodeURIComponent(alternateMode)}`, + { signal }, + ); + if (!res.ok) return; + + const data: unknown = await res.json(); + if (!isRecord(data)) return; + + const sources = parseModeSources(data.mode_sources); + const alternateSource = sources[alternateMode]; + if (!alternateSource?.token) return; + + state.modeSources = { + ...state.modeSources, + [alternateMode]: alternateSource, + }; + mergeAvailableMode(alternateMode); + + updateSubtitleOptions(); + updateQualityOptions(); + updateModeButtons(); + } catch (error: unknown) { + if (error instanceof DOMException && error.name === "AbortError") return; + } +}; /** * Switches between sub/dub mode.