From eb90710dc67364f019365ab9bb86ca591d7bef07 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 6 Jul 2026 07:37:14 +0200 Subject: [PATCH] feat: add client-side episode title hydration --- static/player/episodes/titles.ts | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 static/player/episodes/titles.ts diff --git a/static/player/episodes/titles.ts b/static/player/episodes/titles.ts new file mode 100644 index 00000000..0a8f84c5 --- /dev/null +++ b/static/player/episodes/titles.ts @@ -0,0 +1,69 @@ +import { state } from "../state"; + +type EpisodeTitle = { number: number; title: string }; + +const episodeElements = (): HTMLElement[] => { + const elements: HTMLElement[] = []; + for (const container of [state.elements.episodeGrid, state.elements.episodeList]) { + container?.querySelectorAll("[data-episode-id]").forEach((element) => { + elements.push(element); + }); + } + return elements; +}; + +const hasMissingTitles = (): boolean => + episodeElements().some((element) => { + const number = Number.parseInt(element.dataset.episodeId ?? "", 10); + return number > 0 && element.dataset.episodeTitle?.trim() === `Episode ${number}`; + }); + +export const parseEpisodeTitles = (value: unknown): EpisodeTitle[] => { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((item): EpisodeTitle[] => { + if (typeof item !== "object" || item === null) { + return []; + } + const candidate = item as { number?: unknown; title?: unknown }; + if ( + typeof candidate.number !== "number" || + !Number.isInteger(candidate.number) || + candidate.number <= 0 || + typeof candidate.title !== "string" || + candidate.title.trim() === "" + ) { + return []; + } + return [{ number: candidate.number, title: candidate.title.trim() }]; + }); +}; + +export const applyEpisodeTitles = (titles: EpisodeTitle[]): void => { + const byNumber = new Map(titles.map((episode) => [episode.number, episode.title])); + episodeElements().forEach((element) => { + const number = Number.parseInt(element.dataset.episodeId ?? "", 10); + const title = byNumber.get(number); + if (!title) { + return; + } + element.dataset.episodeTitle = title; + const label = element.querySelector("[data-episode-title]"); + if (label) { + label.textContent = title; + } + }); +}; + +export const hydrateEpisodeTitles = async (signal: AbortSignal): Promise => { + if (!hasMissingTitles() || state.episode.malID <= 0) { + return; + } + + const response = await fetch(`/api/watch/episodes/${state.episode.malID}/titles`, { signal }); + if (!response.ok) { + return; + } + applyEpisodeTitles(parseEpisodeTitles(await response.json())); +};