From 528af5bf8b02ee3ccbcd8e26a5c948c45f6682b1 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 6 Jul 2026 20:47:07 +0200 Subject: [PATCH] feat: add frontend episode classification client --- .../player/episodes/classifications.test.ts | 26 ++++++ static/player/episodes/classifications.ts | 91 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 static/player/episodes/classifications.test.ts create mode 100644 static/player/episodes/classifications.ts diff --git a/static/player/episodes/classifications.test.ts b/static/player/episodes/classifications.test.ts new file mode 100644 index 00000000..cc1968ee --- /dev/null +++ b/static/player/episodes/classifications.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { before, test } from "node:test"; + +before(() => { + Object.assign(globalThis, { + document: { + createElement: () => ({ classList: { add: () => undefined, remove: () => undefined } }), + }, + }); +}); + +test("parses only valid episode classifications", async () => { + const { parseEpisodeClassifications } = await import("./classifications"); + assert.deepEqual( + parseEpisodeClassifications([ + { filler: true, number: 2, recap: false }, + { filler: false, number: 3, recap: true }, + { filler: "true", number: 4, recap: false }, + { filler: false, number: 0, recap: false }, + ]), + [ + { filler: true, number: 2, recap: false }, + { filler: false, number: 3, recap: true }, + ], + ); +}); diff --git a/static/player/episodes/classifications.ts b/static/player/episodes/classifications.ts new file mode 100644 index 00000000..96c60277 --- /dev/null +++ b/static/player/episodes/classifications.ts @@ -0,0 +1,91 @@ +import { state } from "../state"; +import { episodeElements } from "./titles"; + +type EpisodeClassification = { filler: boolean; number: number; recap: boolean }; + +export const parseEpisodeClassifications = (value: unknown): EpisodeClassification[] => { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((item): EpisodeClassification[] => { + if (typeof item !== "object" || item === null) { + return []; + } + const candidate = item as { filler?: unknown; number?: unknown; recap?: unknown }; + if ( + typeof candidate.number !== "number" || + !Number.isInteger(candidate.number) || + candidate.number <= 0 || + typeof candidate.filler !== "boolean" || + typeof candidate.recap !== "boolean" + ) { + return []; + } + return [{ filler: candidate.filler, number: candidate.number, recap: candidate.recap }]; + }); +}; + +const updateKindStyles = (element: HTMLElement, classification: EpisodeClassification): void => { + const filler = classification.filler; + const recap = !filler && classification.recap; + element.dataset.filler = String(filler); + element.dataset.recap = String(recap); + + if (element.dataset.episodeIndex) { + element.classList.remove( + "bg-yellow-500/20", + "text-yellow-400", + "bg-blue-500/20", + "text-blue-400", + "text-foreground-muted", + "hover:bg-foreground/5", + ); + if (filler) { + element.classList.add("bg-yellow-500/20", "text-yellow-400"); + } else if (recap) { + element.classList.add("bg-blue-500/20", "text-blue-400"); + } else { + element.classList.add("text-foreground-muted", "hover:bg-foreground/5"); + } + return; + } + + const marker = element.querySelector("[data-episode-kind-marker]"); + marker?.classList.toggle("hidden", !filler && !recap); + marker?.classList.toggle("bg-yellow-400", filler); + marker?.classList.toggle("bg-blue-400", recap); + + const title = element.querySelector("[data-episode-title]"); + title?.classList.remove("text-yellow-400", "text-blue-400", "text-foreground-muted"); + if (filler) { + title?.classList.add("text-yellow-400"); + } else if (recap) { + title?.classList.add("text-blue-400"); + } else { + title?.classList.add("text-foreground-muted"); + } +}; + +export const applyEpisodeClassifications = (classifications: EpisodeClassification[]): void => { + const byNumber = new Map(classifications.map((episode) => [episode.number, episode])); + episodeElements().forEach((element) => { + const number = Number.parseInt(element.dataset.episodeId ?? "", 10); + const classification = byNumber.get(number); + if (classification) { + updateKindStyles(element, classification); + } + }); +}; + +export const hydrateEpisodeClassifications = async (signal: AbortSignal): Promise => { + if (state.episode.malID <= 0) { + return; + } + const response = await fetch(`/api/watch/episodes/${state.episode.malID}/classifications`, { + signal, + }); + if (!response.ok) { + return; + } + applyEpisodeClassifications(parseEpisodeClassifications(await response.json())); +};