Merge upstream/main

This commit is contained in:
2026-07-07 22:29:06 +02:00
157 changed files with 11390 additions and 1128 deletions

View File

@@ -90,5 +90,115 @@ const initThemesDialog = (): void => {
});
};
const initAnimeSidebarDrawer = (): void => {
onReady(() => {
const drawer = document.querySelector<HTMLElement>("[data-anime-sidebar-drawer]");
const openButton = document.querySelector<HTMLButtonElement>("[data-anime-sidebar-open]");
const closeButton = document.querySelector<HTMLButtonElement>("[data-anime-sidebar-close]");
const backdrop = document.querySelector<HTMLButtonElement>("[data-anime-sidebar-backdrop]");
if (!drawer || !openButton || !closeButton || !backdrop) {
return;
}
let startX = 0;
let startY = 0;
let activeTouchId: number | null = null;
let isOpen = false;
const syncState = (): void => {
drawer.classList.toggle("hidden", !isOpen);
drawer.classList.toggle("translate-x-0", isOpen);
drawer.classList.toggle("translate-x-full", !isOpen);
backdrop.classList.toggle("hidden", !isOpen);
document.body.classList.toggle("overflow-hidden", isOpen);
openButton.setAttribute("aria-expanded", String(isOpen));
};
const open = (): void => {
isOpen = true;
syncState();
};
const close = (): void => {
isOpen = false;
syncState();
};
openButton.addEventListener("click", open);
closeButton.addEventListener("click", close);
backdrop.addEventListener("click", close);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && isOpen) {
close();
}
});
const getTouch = (event: TouchEvent): Touch | null => {
if (activeTouchId === null) {
return event.touches[0] ?? event.changedTouches[0] ?? null;
}
return (
Array.from(event.touches).find((touch) => touch.identifier === activeTouchId) ??
Array.from(event.changedTouches).find((touch) => touch.identifier === activeTouchId) ??
null
);
};
document.addEventListener(
"touchstart",
(event) => {
if (window.innerWidth >= 1024) {
return;
}
const touch = event.touches[0];
if (!touch) {
return;
}
if (!isOpen && touch.clientX < window.innerWidth - 28) {
return;
}
activeTouchId = touch.identifier;
startX = touch.clientX;
startY = touch.clientY;
},
{ passive: true },
);
document.addEventListener(
"touchend",
(event) => {
if (window.innerWidth >= 1024 || activeTouchId === null) {
return;
}
const touch = getTouch(event);
activeTouchId = null;
if (!touch) {
return;
}
const deltaX = touch.clientX - startX;
const deltaY = Math.abs(touch.clientY - startY);
if (deltaY > 60) {
return;
}
if (!isOpen && startX >= window.innerWidth - 28 && deltaX < -48) {
open();
return;
}
if (isOpen && deltaX > 48) {
close();
}
},
{ passive: true },
);
syncState();
});
};
initSynopsisToggle();
initThemesDialog();
initAnimeSidebarDrawer();

View File

@@ -3,6 +3,7 @@ import "./toast";
import "./htmx";
import "./dropdown";
import "./anime";
import "./episode_availability_warning";
import "./search";
import "./dedupe";
import "./watchlist";

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
static/assets/logo-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -8,13 +8,19 @@
"theme_color": "#007a85",
"icons": [
{
"src": "/static/assets/app-icon.png",
"src": "/static/assets/app-icon-192.png?v=1e01454048f3",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/assets/app-icon-512.png?v=f5e01ea5608c",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/assets/app-icon.png",
"src": "/static/assets/app-icon-512.png?v=f5e01ea5608c",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"

View File

@@ -0,0 +1,23 @@
import { closestFocusable, onReady } from "./utils";
const initEpisodeAvailabilityWarning = (): void => {
onReady(() => {
const dialog = document.querySelector<HTMLElement>("[data-episode-availability-warning]");
const continueButton = document.querySelector<HTMLButtonElement>(
"[data-episode-availability-warning-continue]",
);
if (!dialog || !continueButton) {
return;
}
document.body.classList.add("overflow-hidden");
closestFocusable(dialog)?.focus();
continueButton.addEventListener("click", () => {
dialog.remove();
document.body.classList.remove("overflow-hidden");
});
});
};
initEpisodeAvailabilityWarning();

View File

@@ -0,0 +1,821 @@
import assert from "node:assert/strict";
import { before, beforeEach, describe, test } from "node:test";
/* eslint-disable max-classes-per-file */
class FakeElement extends EventTarget {
readonly dataset: Record<string, string> = {};
className = "";
classList = fakeClassList();
disabled = false;
focused = false;
parentElement: FakeElement | null = null;
style: Record<string, string> = {};
textContent = "";
tabIndex = 0;
private readonly attributes = new Map<string, string>();
private readonly children: FakeElement[] = [];
private readonly queries = new Map<string, FakeElement | null>();
append(child: FakeElement): void {
child.parentElement = this;
this.children.push(child);
}
closest(_selector: string): FakeElement | null {
return null;
}
focus(options?: FocusOptions): void {
void options;
this.focused = true;
}
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
querySelector(selector: string): FakeElement | null {
return this.queries.get(selector) ?? null;
}
querySelectorAll(_selector: string): FakeElement[] {
return [];
}
removeAttribute(name: string): void {
this.attributes.delete(name);
}
replaceChildren(...children: FakeElement[]): void {
this.children.length = 0;
children.forEach((child) => {
this.append(child);
});
}
setQuery(selector: string, element: FakeElement | null): void {
this.queries.set(selector, element);
}
setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
}
scrollIntoView(options?: ScrollIntoViewOptions): void {
void options;
}
}
class FakeAnchorElement extends FakeElement {
href = "";
target = "";
override closest(selector: string): FakeAnchorElement | null {
return selector === "a[data-episode-id]" ? this : null;
}
}
class FakeMouseEvent {
readonly altKey: boolean;
readonly button: number;
readonly ctrlKey: boolean;
defaultPrevented = false;
readonly metaKey: boolean;
readonly shiftKey: boolean;
readonly target: FakeElement;
constructor(
target: FakeElement,
options: Partial<
Pick<MouseEvent, "altKey" | "button" | "ctrlKey" | "metaKey" | "shiftKey">
> = {},
) {
this.target = target;
this.altKey = options.altKey ?? false;
this.button = options.button ?? 0;
this.ctrlKey = options.ctrlKey ?? false;
this.metaKey = options.metaKey ?? false;
this.shiftKey = options.shiftKey ?? false;
}
preventDefault(): void {
this.defaultPrevented = true;
}
}
type FakeVideoElement = FakeElement & {
buffered: TimeRanges;
currentTime: number;
duration: number;
load: () => void;
pause: () => void;
paused: boolean;
play: () => Promise<void>;
readyState: number;
removeAttribute: (name: string) => void;
seekable: TimeRanges;
src: string;
};
type PlayerModules = {
completeAnime: (episodeNumber: number) => Promise<void>;
handleEpisodeNavigationClick: (event: MouseEvent) => boolean;
prefetchNextEpisode: () => void;
refreshCurrentModeSource: (signal?: AbortSignal) => Promise<boolean>;
resolveCurrentModeSource: (signal?: AbortSignal) => Promise<boolean>;
saveProgress: (force?: boolean, progressSeconds?: number) => Promise<void>;
setPlayerLoadState: (loadState: import("./loading").PlayerLoadState) => void;
setupPlayerLoading: (retry: () => Promise<boolean>, signal: AbortSignal) => void;
setupProgress: () => void;
state: typeof import("./state").state;
streamUrlForMode: (mode: string, quality?: string) => string;
setupEpisodeNavigation: (signal: AbortSignal) => void;
transitionToEpisode: (
episode: number,
options?: { fallbackHref?: string; history?: "none" | "push" | "replace" },
) => Promise<boolean>;
teardownPlayerLoading: () => void;
};
const emptyRanges = (): TimeRanges => ({ length: 0, end: () => 0, start: () => 0 });
const fakeClassList = (): DOMTokenList => {
const values = new Set<string>();
return {
add: (...tokens: string[]): void => {
tokens.forEach((token) => {
values.add(token);
});
},
remove: (...tokens: string[]): void => {
tokens.forEach((token) => {
values.delete(token);
});
},
toggle: (token: string, force?: boolean): boolean => {
const shouldAdd = force ?? !values.has(token);
if (shouldAdd) {
values.add(token);
} else {
values.delete(token);
}
return shouldAdd;
},
contains: (token: string): boolean => values.has(token),
} as DOMTokenList;
};
const fakeVideoElement = (): FakeVideoElement => {
const video = Object.assign(new FakeElement(), {
buffered: emptyRanges(),
currentTime: 0,
duration: 0,
paused: true,
readyState: 0,
seekable: emptyRanges(),
src: "",
load: (): void => {
void video.src;
},
pause: (): void => {
video.paused = true;
},
play: (): Promise<void> => {
video.paused = false;
return Promise.resolve();
},
removeAttribute: (name: string): void => {
if (name === "src") {
video.src = "";
}
},
});
return video;
};
const fakeDocument = (): Document & {
setDropdownTrigger: (trigger: FakeElement | null) => void;
} => {
let trigger: FakeElement | null = null;
return {
body: new FakeElement(),
createElement: (tag: string): FakeElement =>
tag === "video" ? fakeVideoElement() : new FakeElement(),
querySelector: (selector: string): FakeElement | null =>
selector === "[data-dropdown-trigger]" ? trigger : null,
setDropdownTrigger: (nextTrigger: FakeElement | null): void => {
trigger = nextTrigger;
},
} as unknown as Document & { setDropdownTrigger: (trigger: FakeElement | null) => void };
};
const documentStub = fakeDocument();
const storage = (): Storage => {
const values = new Map<string, string>();
return {
clear: (): void => {
values.clear();
},
getItem: (key: string): string | null => values.get(key) ?? null,
key: (index: number): string | null => [...values.keys()][index] ?? null,
get length(): number {
return values.size;
},
removeItem: (key: string): void => {
values.delete(key);
},
setItem: (key: string, value: string): void => {
values.set(key, value);
},
};
};
const historyCalls: string[] = [];
const localStorageStub = storage();
const sessionStorageStub = storage();
const windowStub = Object.assign(new EventTarget(), {
clearTimeout: (_handle: number | undefined): void => {
void _handle;
},
setTimeout: (_callback: () => void, _delay: number): number => {
void _callback;
void _delay;
return 1;
},
localStorage: localStorageStub,
location: { href: "http://localhost/anime/42/watch?ep=3", origin: "http://localhost" },
showToast: (options: { message: string }): void => {
void options;
},
});
let modules: PlayerModules;
let beaconCalls: string[];
let fetchCalls: Array<{ body?: string; url: string }>;
let timeoutCallbacks: Array<() => void>;
let loadingUI: {
back: FakeElement;
loading: FakeElement;
retry: FakeElement;
spinner: FakeElement;
};
const originalConsoleError = console.error;
const installBrowserGlobals = (): void => {
Object.assign(globalThis, {
document: documentStub,
Element: FakeElement,
HTMLAnchorElement: FakeAnchorElement,
HTMLElement: FakeElement,
HTMLVideoElement: FakeElement,
HTMLMediaElement: { HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2 },
MouseEvent: FakeMouseEvent,
window: windowStub,
});
Object.assign(globalThis, {
history: {
pushState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
const href = String(url);
historyCalls.push(href);
windowStub.location.href = href;
},
replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
const href = String(url);
historyCalls.push(href);
windowStub.location.href = href;
},
},
sessionStorage: sessionStorageStub,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
sendBeacon: (_url: string, data: Blob): boolean => {
void data.text().then((value) => {
beaconCalls.push(value);
});
return true;
},
},
});
};
const asHTMLElement = (element: FakeElement): HTMLElement => element as unknown as HTMLElement;
const resetPlayerState = (): void => {
const { state } = modules;
modules.teardownPlayerLoading();
const video = fakeVideoElement();
video.duration = 120;
const container = new FakeElement();
loadingUI = {
back: new FakeElement(),
loading: new FakeElement(),
retry: new FakeElement(),
spinner: new FakeElement(),
};
container.dataset.animeTitle = "Example Anime";
container.dataset.currentEpisode = "3";
container.setQuery("[data-loading]", loadingUI.loading);
container.setQuery("[data-loading-spinner]", loadingUI.spinner);
container.setQuery("[data-loading-retry]", loadingUI.retry);
container.setQuery("[data-loading-back]", loadingUI.back);
state.elements.container = asHTMLElement(container);
state.elements.video = video as unknown as HTMLVideoElement;
state.elements.progress = asHTMLElement(new FakeElement());
state.elements.scrubber = asHTMLElement(new FakeElement());
state.elements.buffered = asHTMLElement(new FakeElement());
state.elements.timeDisplay = asHTMLElement(new FakeElement());
state.elements.durationDisplay = asHTMLElement(new FakeElement());
state.episode.current = "3";
state.episode.total = 10;
state.episode.malID = 42;
state.episode.transitionEpisode = null;
state.episode.completionSent = false;
state.episode.completionAttempts = 0;
state.episode.endedProgressSaved = false;
state.episode.lastSavedProgress = { episode: "3", seconds: -1 };
state.playback.currentMode = "sub";
state.playback.modeSwitchedFrom = "";
state.playback.startTimeSeconds = 0;
state.playback.streamURL = "/watch/proxy/stream";
state.playback.modeSources = {};
state.elements.episodeGrid = null;
state.elements.episodeList = null;
state.elements.videoOverlay = null;
state.timers.progressSaveTimer = undefined;
documentStub.setDropdownTrigger(null);
};
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
const loadPlayerModules = async (): Promise<PlayerModules> => {
const completeModule = await import("./episodes/complete");
const navModule = await import("./episodes/nav");
const progressModule = await import("./progress");
const stateModule = await import("./state");
const sourceModule = await import("./source");
const loadingModule = await import("./loading");
return {
completeAnime: completeModule.completeAnime,
handleEpisodeNavigationClick: navModule.handleEpisodeNavigationClick,
prefetchNextEpisode: navModule.prefetchNextEpisode,
refreshCurrentModeSource: sourceModule.refreshCurrentModeSource,
resolveCurrentModeSource: sourceModule.resolveCurrentModeSource,
saveProgress: progressModule.saveProgress,
setPlayerLoadState: loadingModule.setPlayerLoadState,
setupPlayerLoading: loadingModule.setupPlayerLoading,
setupProgress: progressModule.setupProgress,
state: stateModule.state,
streamUrlForMode: sourceModule.streamUrlForMode,
setupEpisodeNavigation: navModule.setupEpisodeNavigation,
teardownPlayerLoading: loadingModule.teardownPlayerLoading,
transitionToEpisode: navModule.transitionToEpisode,
};
};
before(async () => {
installBrowserGlobals();
modules = await loadPlayerModules();
});
beforeEach(() => {
console.error = originalConsoleError;
fetchCalls = [];
beaconCalls = [];
historyCalls.length = 0;
localStorageStub.clear();
sessionStorageStub.clear();
windowStub.location.href = "http://localhost/anime/42/watch?ep=3";
windowStub.showToast = (options: { message: string }): void => {
void options;
};
timeoutCallbacks = [];
globalThis.fetch = ((url: string, init?: RequestInit) => {
fetchCalls.push({ url, body: typeof init?.body === "string" ? init.body : undefined });
return Promise.resolve({ ok: true, status: 200 } as Response);
}) as typeof fetch;
windowStub.setTimeout = (callback: () => void, _delay: number): number => {
timeoutCallbacks.push(callback);
return timeoutCallbacks.length;
};
globalThis.setTimeout = windowStub.setTimeout.bind(windowStub) as typeof setTimeout;
resetPlayerState();
delete (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
});
const episodePayload = (
episode: number,
modes?: Record<string, { subtitles: never[]; token: string; type: string }>,
): Record<string, unknown> => {
const modeSources = modes ?? {
dub: { subtitles: [], token: `dub-${episode}`, type: "mp4" },
sub: { subtitles: [], token: `sub-${episode}`, type: "mp4" },
};
return {
episode_title: `Episode ${episode}`,
initial_mode: "sub",
mode_sources: modeSources,
mode_switched_from: "",
segments: [],
start_time_seconds: 12,
};
};
const episodeResponse = (episode: number): Response =>
({ json: () => Promise.resolve(episodePayload(episode)), ok: true, status: 200 }) as Response;
describe("browser player flow", () => {
test("keeps loading to a quiet spinner until ready", () => {
modules.setPlayerLoadState("loading_media");
assert.equal(loadingUI.loading.style.display, "flex");
assert.equal(loadingUI.spinner.classList.contains("hidden"), false);
assert.equal(modules.state.elements.container.getAttribute("aria-busy"), "true");
assert.equal(timeoutCallbacks.length, 0);
assert.equal(loadingUI.retry.classList.contains("hidden"), true);
modules.setPlayerLoadState("ready");
assert.equal(loadingUI.loading.style.display, "none");
assert.equal(modules.state.elements.container.getAttribute("aria-busy"), "false");
});
test("user retry runs only one forced refresh at a time", async () => {
const controller = new AbortController();
let retries = 0;
modules.setupPlayerLoading(() => {
retries += 1;
modules.setPlayerLoadState("loading_media");
return Promise.resolve(true);
}, controller.signal);
modules.setPlayerLoadState("unavailable");
assert.equal(loadingUI.back.classList.contains("hidden"), false);
assert.equal(loadingUI.retry.focused, true);
loadingUI.retry.dispatchEvent(new Event("click"));
loadingUI.retry.dispatchEvent(new Event("click"));
await flushPromises();
assert.equal(retries, 1);
assert.equal(modules.state.elements.container.dataset.loadState, "loading_media");
assert.equal(loadingUI.retry.disabled, false);
controller.abort();
});
test("builds initial stream URLs from mode token, HLS type, and preferred quality", () => {
modules.state.playback.modeSources = {
sub: { token: "sub token", type: "m3u8", qualities: ["1080p"], subtitles: [] },
};
assert.equal(
modules.streamUrlForMode("sub", "720p"),
"/watch/proxy/stream?mode=sub&token=sub%20token&hls=1&quality=720p",
);
});
test("returns no stream URL when the selected mode has no playable token", () => {
modules.state.playback.modeSources = { sub: { token: "", subtitles: [] } };
assert.equal(modules.streamUrlForMode("sub", "best"), "");
});
test("refreshes stale current stream token from episode data", async () => {
modules.state.elements.video.currentTime = 64;
modules.state.playback.modeSources = {
sub: { token: "stale-token", type: "m3u8", qualities: ["720p"], subtitles: [] },
};
globalThis.fetch = ((url: string) => {
fetchCalls.push({ url });
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
mode_sources: {
sub: { token: "fresh-token", type: "m3u8", qualities: ["720p"], subtitles: [] },
},
}),
} as Response);
}) as typeof fetch;
const refreshed = await modules.refreshCurrentModeSource();
assert.equal(refreshed, true);
assert.deepEqual(
fetchCalls.map((call) => call.url),
["/api/watch/episode/42/3?mode=sub&refresh=1"],
);
assert.equal(modules.state.playback.modeSources.sub?.token, "fresh-token");
assert.equal(
modules.state.elements.video.src,
"/watch/proxy/stream?mode=sub&token=fresh-token&hls=1",
);
assert.equal(modules.state.playback.pendingSeekTime, 64);
});
test("resolves the initial stream without forcing a provider refresh", async () => {
globalThis.fetch = ((url: string) => {
fetchCalls.push({ url });
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(episodePayload(3)),
} as Response);
}) as typeof fetch;
const resolved = await modules.resolveCurrentModeSource();
assert.equal(resolved, true);
assert.equal(fetchCalls[0]?.url, "/api/watch/episode/42/3?mode=sub");
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-3");
});
test("saves progress on pause and after scrub mouseup", async () => {
modules.setupProgress();
modules.state.elements.video.currentTime = 30;
modules.state.elements.video.dispatchEvent(new Event("pause"));
await flushPromises();
modules.state.elements.video.currentTime = 47;
window.dispatchEvent(new Event("mouseup"));
await flushPromises();
assert.deepEqual(
fetchCalls.map((call) => JSON.parse(call.body ?? "{}")),
[
{ mal_id: 42, episode: 3, time_seconds: 30 },
{ mal_id: 42, episode: 3, time_seconds: 47 },
],
);
});
test("retries failed completion and updates completion trigger after success", async () => {
const errors: unknown[][] = [];
console.error = (...args: unknown[]): void => {
errors.push(args);
};
const trigger = new FakeElement();
documentStub.setDropdownTrigger(trigger);
globalThis.fetch = ((url: string, init?: RequestInit) => {
fetchCalls.push({ url, body: typeof init?.body === "string" ? init.body : undefined });
return Promise.resolve({ ok: fetchCalls.length > 1, status: 500 } as Response);
}) as typeof fetch;
await modules.completeAnime(3);
assert.equal(modules.state.episode.completionSent, false);
assert.equal(modules.state.episode.completionAttempts, 1);
assert.equal(timeoutCallbacks.length, 1);
timeoutCallbacks[0]?.();
await flushPromises();
assert.equal(fetchCalls.length, 2);
assert.deepEqual(JSON.parse(fetchCalls[1]?.body ?? "{}"), { mal_id: 42, episode: 3 });
assert.equal(modules.state.episode.completionSent, true);
assert.equal(trigger.textContent, "Completed ");
assert.equal(errors.length, 1);
});
test("transitions episodes in place and saves progress for the episode being left", async () => {
const loading = new FakeElement();
(modules.state.elements.container as unknown as FakeElement).setQuery(
"[data-loading]",
loading,
);
modules.state.elements.video.currentTime = 30;
globalThis.fetch = ((url: string) => {
fetchCalls.push({ url });
return Promise.resolve(episodeResponse(4));
}) as typeof fetch;
const transitioned = await modules.transitionToEpisode(4);
await flushPromises();
assert.equal(transitioned, true);
assert.deepEqual(
fetchCalls.map((call) => call.url),
["/api/watch/episode/42/4?mode=sub"],
);
assert.equal(modules.state.episode.current, "4");
assert.equal(modules.state.playback.startTimeSeconds, 12);
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-4");
assert.equal(loading.style.display, "flex");
assert.match(historyCalls[0] ?? "", /[?&]ep=4(?:&|$)/);
assert.deepEqual(JSON.parse(beaconCalls[0] ?? "{}"), {
mal_id: 42,
episode: 3,
time_seconds: 30,
});
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
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";
modifiedLink.dataset.episodeId = "2";
const modifiedClick = new FakeMouseEvent(modifiedLink, { metaKey: true });
assert.equal(
modules.handleEpisodeNavigationClick(modifiedClick as unknown as MouseEvent),
false,
);
assert.equal(modifiedClick.defaultPrevented, false);
assert.equal(fetchCalls.length, 0);
globalThis.fetch = ((url: string) => {
fetchCalls.push({ url });
const episode = Number.parseInt(
new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "",
10,
);
return Promise.resolve(episodeResponse(episode));
}) as typeof fetch;
const previousLink = new FakeAnchorElement();
previousLink.href = "http://localhost/anime/42/watch?ep=2";
previousLink.dataset.episodeId = "2";
const previousClick = new FakeMouseEvent(previousLink);
assert.equal(
modules.handleEpisodeNavigationClick(previousClick as unknown as MouseEvent),
true,
);
assert.equal(previousClick.defaultPrevented, true);
await flushPromises();
assert.equal(modules.state.episode.current, "2");
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
const nextLink = new FakeAnchorElement();
nextLink.href = "http://localhost/anime/42/watch?ep=4";
nextLink.dataset.episodeId = "4";
assert.equal(
modules.handleEpisodeNavigationClick(new FakeMouseEvent(nextLink) as unknown as MouseEvent),
true,
);
await flushPromises();
assert.equal(modules.state.episode.current, "4");
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
});
test("aborts a stale episode request and commits only the latest selection", async () => {
modules.state.episode.current = "1";
windowStub.location.href = "http://localhost/anime/42/watch?ep=1";
const pending = new Map<
number,
{ reject: (reason: unknown) => void; resolve: (response: Response) => void }
>();
globalThis.fetch = ((url: string, init?: RequestInit) => {
fetchCalls.push({ url });
const episode = Number.parseInt(
new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "",
10,
);
return new Promise<Response>((resolve, reject) => {
pending.set(episode, { reject, resolve });
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("aborted", "AbortError"));
});
});
}) as typeof fetch;
const episode2 = modules.transitionToEpisode(2);
const episode3 = modules.transitionToEpisode(3);
pending.get(3)?.resolve(episodeResponse(3));
assert.equal(await episode3, true);
assert.equal(await episode2, false);
assert.equal(modules.state.episode.current, "3");
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-3");
assert.equal(historyCalls.length, 1);
assert.match(historyCalls[0] ?? "", /[?&]ep=3(?:&|$)/);
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
});
test("falls back to the original link when the episode payload fails", async () => {
console.error = (): void => {
void 0;
};
globalThis.fetch = ((url: string) => {
fetchCalls.push({ url });
return Promise.resolve({ ok: false, status: 503 } as Response);
}) as typeof fetch;
const fallbackHref = "http://localhost/anime/42/watch?ep=5";
assert.equal(await modules.transitionToEpisode(5, { fallbackHref }), false);
assert.equal(windowStub.location.href, fallbackHref);
assert.equal(modules.state.episode.current, "3");
});
test("retries one failed media source before exposing recovery", async () => {
console.error = (): void => {
void 0;
};
let request = 0;
globalThis.fetch = ((url: string) => {
fetchCalls.push({ url });
request += 1;
const payload = episodePayload(4);
payload.mode_sources = {
dub: { subtitles: [], token: `dub-${request}`, type: "mp4" },
sub: { subtitles: [], token: `sub-${request}`, type: "mp4" },
};
return Promise.resolve({
json: () => Promise.resolve(payload),
ok: true,
status: 200,
} as Response);
}) as typeof fetch;
const fallbackHref = "http://localhost/anime/42/watch?ep=4";
assert.equal(await modules.transitionToEpisode(4, { fallbackHref }), true);
assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-1");
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(fetchCalls.length, 2);
assert.equal(modules.state.elements.container.dataset.loadState, "unavailable");
assert.equal(loadingUI.retry.classList.contains("hidden"), false);
assert.equal(historyCalls.length, 1);
});
test("uses the returned fallback mode and restores episodes on popstate", async () => {
const notices: string[] = [];
windowStub.showToast = ({ message }: { message: string }): void => {
notices.push(message);
};
modules.state.playback.currentMode = "dub";
globalThis.fetch = ((url: string) => {
fetchCalls.push({ url });
const episode = Number.parseInt(
new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "",
10,
);
const payload = episodePayload(episode, {
sub: { subtitles: [], token: `sub-${episode}`, type: "mp4" },
});
payload.mode_switched_from = "dub";
return Promise.resolve({
json: () => Promise.resolve(payload),
ok: true,
status: 200,
} as Response);
}) as typeof fetch;
assert.equal(await modules.transitionToEpisode(4), true);
assert.equal(modules.state.playback.currentMode, "sub");
assert.deepEqual(notices, ["Episode 4 is only available in sub, switched from dub."]);
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
const controller = new AbortController();
modules.setupEpisodeNavigation(controller.signal);
windowStub.location.href = "http://localhost/anime/42/watch?ep=2";
windowStub.dispatchEvent(new Event("popstate"));
await flushPromises();
assert.equal(modules.state.episode.current, "2");
assert.equal(historyCalls.length, 1);
modules.state.elements.video.dispatchEvent(new Event("loadedmetadata"));
controller.abort();
});
});

View File

@@ -175,15 +175,17 @@ const getControls = (): Controls => {
};
const updatePlayPauseIcons = (isPlaying: boolean): void => {
const { iconPlay, iconPause } = getControls();
const { iconPlay, iconPause, playPause } = getControls();
iconPlay?.classList.toggle("hidden", isPlaying);
iconPause?.classList.toggle("hidden", !isPlaying);
playPause?.setAttribute("aria-label", isPlaying ? "Pause" : "Play");
};
const updateMuteIcons = (isMuted: boolean): void => {
const { iconVolume, iconMuted } = getControls();
const { iconVolume, iconMuted, muteBtn } = getControls();
iconVolume?.classList.toggle("hidden", isMuted);
iconMuted?.classList.toggle("hidden", !isMuted);
muteBtn?.setAttribute("aria-label", isMuted ? "Unmute" : "Mute");
};
/** Binds click handlers to player control buttons. Sets up video event listeners for icon sync. */
@@ -251,6 +253,10 @@ export const setupControls = (): void => {
document.addEventListener("fullscreenchange", () => {
state.ui.isFullscreen = Boolean(document.fullscreenElement);
state.elements.container.classList.toggle("fullscreen", state.ui.isFullscreen);
fullscreenBtn?.setAttribute(
"aria-label",
state.ui.isFullscreen ? "Exit fullscreen" : "Enter fullscreen",
);
if (state.ui.isFullscreen) {
showControls();
}

View File

@@ -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 },
],
);
});

View File

@@ -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<HTMLElement>("[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<HTMLElement>("[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<void> => {
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()));
};

View File

@@ -1,153 +1,672 @@
import type { SkipSegment } from "../types";
import { setPlayerLoadState } from "../loading";
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<void> => {
const currentEp = Number.parseInt(state.episode.current, 10);
if (!currentEp) {
type EpisodePayload = {
episodeTitle: string;
initialMode: string;
modeSources: ReturnType<typeof parseModeSources>;
modeSwitchedFrom: string;
segments: ReturnType<typeof parseSegments>;
startTimeSeconds: number;
};
type TransitionHistory = "none" | "push" | "replace";
type TransitionOptions = {
autoplay?: boolean;
fallbackHref?: string;
history?: TransitionHistory;
restoreFocus?: boolean;
};
type ActiveTransition = {
controller: AbortController;
fallbackHref: string;
id: number;
startedAt: number;
};
type EpisodeTransitionProfile = {
aborted: number;
fallback: number;
lastMediaReadyMs: number;
lastPayloadMs: number;
lastTotalMs: number;
stale: number;
succeeded: number;
prefetchCancelled: number;
prefetchExpired: number;
prefetchFailed: number;
prefetchStarted: number;
prefetchUsed: 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,
prefetchCancelled: 0,
prefetchExpired: 0,
prefetchFailed: 0,
prefetchStarted: 0,
prefetchUsed: 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 prefetchTTL = 2 * 60 * 1000;
const prefetchLimit = 2;
type PrefetchReason = "next" | "hover";
type PrefetchEntry = {
controller: AbortController;
episode: number;
expiresAt: number;
mode: string;
promise: Promise<EpisodePayload | null>;
reason: PrefetchReason;
};
const prefetchedEpisodes = new Map<string, PrefetchEntry>();
let hoverPrefetchKey: string | null = null;
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
let prefetchEnabled = false;
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 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<EpisodePayload> => {
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 navigateToEpisode = (episode: number): void => {
const url = new URL(window.location.href);
url.searchParams.set("ep", String(episode));
window.location.href = url.toString();
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<EpisodePayload | null> | 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;
}
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 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) {
setPlayerLoadState("unavailable");
return;
}
retried = true;
setPlayerLoadState("retrying");
fetchEpisodePayload(
Number.parseInt(state.episode.current, 10),
state.playback.currentMode,
signal,
false,
true,
)
.then((payload) => {
if (signal.aborted || activeTransition?.id !== transition.id) {
return;
}
const mode = selectedMode(payload, state.playback.currentMode);
if (!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);
setPlayerLoadState("unavailable");
});
};
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 });
};
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<boolean> => {
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 = {
controller: new AbortController(),
fallbackHref: options.fallbackHref ?? episodeHref(episode),
id: ++transitionID,
startedAt: performance.now(),
};
activeTransition = transition;
markEpisodeTransition(episode);
setPlayerLoadState("resolving_source");
try {
const requestedMode = state.playback.currentMode;
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;
}
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]");
prefetchEnabled = true;
root?.addEventListener(
"click",
(event) => {
if (event instanceof MouseEvent) {
handleEpisodeNavigationClick(event);
}
},
{ 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;
}
};
// final episode: trigger completion flow or just stop if airing
if (state.episode.total > 0 && currentEp >= state.episode.total) {
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",
() => {
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;
prefetchEnabled = false;
cancelHoverPrefetch();
for (const key of prefetchedEpisodes.keys()) {
removePrefetch(key, true);
}
},
{ once: true },
);
};
/** Handles video end: completes the series or advances when autoplay is enabled. */
export const goToNextEpisode = async (): Promise<void> => {
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",
});
};

View File

@@ -1,56 +0,0 @@
import { state } from "../state";
/**
* Fetches episode thumbnails and titles from API. Injects images into episode cards, replaces
* placeholder.
*/
export const setupThumbnails = (): void => {
const { episodeList } = state.elements;
if (!episodeList) {
return;
}
fetch(`/api/watch/thumbnails/${state.episode.malID}`)
.then((res) => res.json())
.then((data: Array<{ mal_id: number; url: string; title?: string }>) => {
data.forEach((item) => {
const card = episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
if (!card) {
return;
}
// inject thumbnail image
if (item.url) {
const imgContainer = card.querySelector(".relative.aspect-video");
if (imgContainer) {
let img = imgContainer.querySelector("img");
if (!img) {
// replace placeholder with actual image
img = document.createElement("img");
img.className =
"h-full w-full object-cover transition-transform group-hover:scale-105";
img.loading = "lazy";
imgContainer
.querySelector(".flex.h-full.w-full.items-center.justify-center")
?.remove();
imgContainer.prepend(img);
}
img.src = item.url;
img.alt = item.title ?? `Episode ${item.mal_id}`;
}
}
// inject title text
if (item.title) {
const titleEl = card.querySelector("[data-episode-title]");
if (titleEl) {
titleEl.textContent = item.title;
}
}
});
})
.catch((error) => {
window.showToast?.({ message: "Failed to load episode thumbnails." });
console.error("failed to load episode thumbnails:", error);
});
};

View File

@@ -0,0 +1,23 @@
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 titles", async () => {
const { parseEpisodeTitles } = await import("./titles");
assert.deepEqual(
parseEpisodeTitles([
{ number: 1, title: " First title " },
{ number: 0, title: "Invalid" },
{ number: 2, title: "" },
null,
]),
[{ number: 1, title: "First title" }],
);
});

View File

@@ -0,0 +1,69 @@
import { state } from "../state";
type EpisodeTitle = { number: number; title: string };
export const episodeElements = (): HTMLElement[] => {
const elements: HTMLElement[] = [];
for (const container of [state.elements.episodeGrid, state.elements.episodeList]) {
container?.querySelectorAll<HTMLElement>("[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<HTMLElement>("[data-episode-title]");
if (label) {
label.textContent = title;
}
});
};
export const hydrateEpisodeTitles = async (signal: AbortSignal): Promise<void> => {
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()));
};

View File

@@ -36,21 +36,53 @@ const getEpisodeEls = () => {
};
};
/** Highlights current episode in grid and list. Scrolls to episode into view. */
export const updateEpisodeHighlight = (num: number): void => {
/** Highlights current episode in grid and list. */
export const updateEpisodeHighlight = (num: number, restoreFocus: boolean = false): void => {
const { gridEls, listEls } = getEpisodeEls();
// clear old highlights
[...gridEls, ...listEls].forEach((el) => {
el.classList.remove("ring-2", "ring-accent", "bg-accent/20", "text-accent");
el.classList.remove("ring-1", "ring-accent", "bg-accent/15", "bg-accent/20", "text-accent");
el.removeAttribute("aria-current");
});
// apply new highlight
const gridEl = state.elements.episodeGrid?.querySelector(`[data-episode-id="${num}"]`);
const listEl = state.elements.episodeList?.querySelector(`[data-episode-id="${num}"]`);
gridEl?.classList.add("ring-2", "ring-accent");
listEl?.classList.add("ring-2", "ring-accent");
gridEl?.classList.add("bg-accent/15", "text-accent", "ring-1", "ring-accent");
listEl?.classList.add("bg-accent/20");
gridEl?.setAttribute("aria-current", "page");
listEl?.setAttribute("aria-current", "page");
// scroll into view
(gridEl ?? listEl)?.scrollIntoView({ behavior: "smooth", block: "center" });
const activeElement = gridEl ?? listEl;
activeElement?.scrollIntoView({ behavior: "smooth", block: "center" });
if (restoreFocus && activeElement instanceof HTMLElement) {
activeElement.focus({ preventScroll: true });
}
};
const setNavigationLink = (selector: string, episode: number, visible: boolean): void => {
const anchor = document.querySelector(selector) as HTMLAnchorElement | null;
if (!anchor) {
return;
}
const url = new URL(anchor.href, window.location.href);
url.searchParams.set("ep", String(episode));
anchor.href = url.toString();
anchor.dataset.episodeId = String(episode);
anchor.classList.toggle("hidden", !visible);
anchor.classList.toggle("inline-flex", visible);
anchor.setAttribute("aria-hidden", visible ? "false" : "true");
anchor.tabIndex = visible ? 0 : -1;
};
/** Keeps the real Previous and Next links correct after an in-page transition. */
export const updateEpisodeNavigation = (episode: number): void => {
setNavigationLink("[data-episode-prev]", Math.max(1, episode - 1), episode > 1);
setNavigationLink(
"[data-episode-next]",
state.episode.total > 0 ? Math.min(state.episode.total, episode + 1) : episode + 1,
state.episode.total === 0 || episode < state.episode.total,
);
};
/** Switches visible episode range in grid. Updates dropdown label and hides/shows episode cards. */

179
static/player/loading.ts Normal file
View File

@@ -0,0 +1,179 @@
import { state } from "./state";
export type PlayerLoadState =
| "idle"
| "resolving_source"
| "loading_media"
| "buffering"
| "retrying"
| "ready"
| "unavailable";
type PlayerLoadProfile = {
bufferingCount: number;
lastBufferingMs: number;
lastMediaMetadataMs: number;
lastSourceResolutionMs: number;
retryCount: number;
totalBufferingMs: number;
unavailableCount: number;
};
declare global {
interface Window {
__malPlayerLoadProfile?: PlayerLoadProfile;
}
}
let currentState: PlayerLoadState = "idle";
let phaseStartedAt = 0;
let retryController: AbortController | null = null;
let retryInFlight = false;
const profile = (): PlayerLoadProfile => {
window.__malPlayerLoadProfile ??= {
bufferingCount: 0,
lastBufferingMs: 0,
lastMediaMetadataMs: 0,
lastSourceResolutionMs: 0,
retryCount: 0,
totalBufferingMs: 0,
unavailableCount: 0,
};
return window.__malPlayerLoadProfile;
};
const measure = (name: string, startedAt: number, duration: number): void => {
try {
performance.measure(`mal.player.${name}`, { start: startedAt, duration });
} catch (error) {
console.debug("failed to measure player loading phase:", error);
}
};
const finishPhase = (): void => {
if (phaseStartedAt <= 0) {
return;
}
const duration = performance.now() - phaseStartedAt;
const metrics = profile();
if (currentState === "resolving_source") {
metrics.lastSourceResolutionMs = duration;
measure("source_resolution", phaseStartedAt, duration);
} else if (currentState === "loading_media") {
metrics.lastMediaMetadataMs = duration;
measure("media_metadata", phaseStartedAt, duration);
} else if (currentState === "buffering") {
metrics.lastBufferingMs = duration;
metrics.totalBufferingMs += duration;
measure("buffering", phaseStartedAt, duration);
}
};
const isBusy = (loadState: PlayerLoadState): boolean =>
loadState === "resolving_source" ||
loadState === "loading_media" ||
loadState === "buffering" ||
loadState === "retrying";
const elements = (): {
back: HTMLElement | null;
loading: HTMLElement | null;
retry: HTMLButtonElement | null;
spinner: HTMLElement | null;
} => ({
back: state.elements.container.querySelector("[data-loading-back]"),
loading: state.elements.container.querySelector("[data-loading]"),
retry: state.elements.container.querySelector("[data-loading-retry]"),
spinner: state.elements.container.querySelector("[data-loading-spinner]"),
});
const showRecovery = (): void => {
const { back, retry } = elements();
retry?.classList.remove("hidden");
back?.classList.remove("hidden");
};
export const setPlayerLoadState = (nextState: PlayerLoadState): void => {
if (nextState === currentState) {
return;
}
finishPhase();
currentState = nextState;
phaseStartedAt = isBusy(nextState) ? performance.now() : 0;
const { back, loading, retry, spinner } = elements();
state.elements.container.dataset.loadState = nextState;
state.elements.container.setAttribute("aria-busy", String(isBusy(nextState)));
const visible = isBusy(nextState) || nextState === "unavailable";
if (loading) {
loading.style.display = visible ? "flex" : "none";
}
spinner?.classList.toggle("hidden", nextState === "unavailable");
retry?.classList.add("hidden");
back?.classList.add("hidden");
if (nextState === "buffering") {
profile().bufferingCount += 1;
} else if (nextState === "retrying") {
profile().retryCount += 1;
} else if (nextState === "unavailable") {
profile().unavailableCount += 1;
}
if (nextState === "unavailable") {
showRecovery();
retry?.focus({ preventScroll: true });
return;
}
if (!isBusy(nextState)) {
return;
}
};
export const setupPlayerLoading = (retry: () => Promise<boolean>, signal: AbortSignal): void => {
retryController?.abort();
retryController = new AbortController();
const retryButton = elements().retry;
retryButton?.addEventListener(
"click",
() => {
if (retryInFlight) {
return;
}
retryInFlight = true;
retryButton.disabled = true;
setPlayerLoadState("retrying");
retry()
.then((refreshed) => {
if (!refreshed) {
setPlayerLoadState("unavailable");
}
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.error("failed to retry video source:", error);
setPlayerLoadState("unavailable");
})
.finally(() => {
retryInFlight = false;
retryButton.disabled = false;
});
},
{ signal: retryController.signal },
);
signal.addEventListener("abort", () => retryController?.abort(), { once: true });
};
export const teardownPlayerLoading = (): void => {
finishPhase();
retryController?.abort();
retryController = null;
retryInFlight = false;
currentState = "idle";
phaseStartedAt = 0;
};

View File

@@ -1,21 +1,24 @@
import { onHtmxLoad, onReady } from "../utils";
import { setupControls, showControls } from "./controls";
import { formatTime } from "./controls";
import { goToNextEpisode } from "./episodes/nav";
import { setupThumbnails } from "./episodes/thumbnails";
import { hydrateEpisodeClassifications } from "./episodes/classifications";
import { goToNextEpisode, prefetchNextEpisode, setupEpisodeNavigation } from "./episodes/nav";
import { hydrateEpisodeTitles } from "./episodes/titles";
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from "./episodes/ui";
import { setupKeyboard } from "./keyboard";
import { setPlayerLoadState, setupPlayerLoading, teardownPlayerLoading } from "./loading";
import {
ensurePreferredModeSource,
hydrateAlternateMode,
setupMode,
updateModeButtons,
} from "./mode";
import { markEpisodeTransition, saveEndedProgress, setupProgress } from "./progress";
import { saveEndedProgress, setupProgress } from "./progress";
import { setupQuality, updateQualityOptions } from "./quality";
import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip";
import { setupSegmentEditor } from "./skip/editor";
import { resolveActiveSegments, renderSegments } from "./skip/segments";
import { refreshCurrentModeSource, resolveCurrentModeSource } from "./source";
import { state, initState, showEndState, hideEndState } from "./state";
import { safeLocalStorage } from "./storage";
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles";
@@ -63,9 +66,10 @@ const showPreviewPopover = (): void => {
};
const teardownPlayer = (): void => {
destroyVideoSource();
cleanup?.();
cleanup = null;
teardownPlayerLoading();
destroyVideoSource();
currentContainer = null;
};
@@ -118,10 +122,21 @@ const initPlayer = async (): Promise<void> => {
currentContainer = container;
const abortController = new AbortController();
const { signal } = abortController;
cleanup = null;
let searchDebounce: number | undefined;
cleanup = () => {
clearTimeout(searchDebounce);
abortController.abort();
};
const loading = container.querySelector("[data-loading]") as HTMLElement | null;
const progressWrap = container.querySelector("[data-progress-wrap]") as HTMLElement | null;
let sourceRefreshInFlight = false;
let automaticSourceRefreshAttempted = false;
setupPlayerLoading(() => {
automaticSourceRefreshAttempted = true;
return refreshCurrentModeSource(signal);
}, signal);
setPlayerLoadState("resolving_source");
const scrubToPointer = (clientX: number, shouldShowControls: boolean): void => {
if (!progressWrap) {
@@ -133,6 +148,7 @@ const initPlayer = async (): Promise<void> => {
);
updateTimeline(state.elements.video.currentTime);
updateSkipButton(state.elements.video.currentTime);
prefetchNextEpisode();
if (shouldShowControls) {
showControls();
}
@@ -146,6 +162,19 @@ const initPlayer = async (): Promise<void> => {
setupSubtitles();
setupQuality();
setupMode();
setupEpisodeNavigation(signal);
hydrateEpisodeTitles(signal).catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.debug("failed to enrich episode titles:", error);
});
hydrateEpisodeClassifications(signal).catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.debug("failed to enrich episode classifications:", error);
});
updateSubtitleOptions();
updateQualityOptions();
@@ -164,12 +193,37 @@ const initPlayer = async (): Promise<void> => {
message: "Playback is unavailable for this episode.",
variant: "destructive",
});
setPlayerLoadState("unavailable");
} else {
resolveCurrentModeSource(signal)
.then((resolved) => {
if (!resolved) {
setPlayerLoadState("unavailable");
return;
}
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
resolveActiveSegments();
renderSegments();
if (state.playback.modeSwitchedFrom === "dub" && state.playback.currentMode === "sub") {
window.showToast?.({
message: `Episode ${state.episode.current} is only available in sub, switched from dub.`,
});
}
})
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.error("failed to resolve initial video source:", error);
setPlayerLoadState("unavailable");
});
}
return;
} else {
await ensurePreferredModeSource(signal);
}
await ensurePreferredModeSource(signal);
const resumeAfterModeSwitch = (() => {
try {
const raw = sessionStorage.getItem("mal:resume-after-mode-switch");
@@ -187,12 +241,14 @@ const initPlayer = async (): Promise<void> => {
const initialStartTime = resumeAfterModeSwitch ?? state.playback.startTimeSeconds;
// build video src from mode, token, and saved quality preference
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
const streamToken = state.playback.modeSources[state.playback.currentMode]?.token;
if (streamToken) {
if (hasPlayableSource) {
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
const streamToken = state.playback.modeSources[state.playback.currentMode]?.token;
const source = state.playback.modeSources[state.playback.currentMode];
const url = `${state.playback.streamURL}?mode=${encodeURIComponent(state.playback.currentMode)}&token=${encodeURIComponent(streamToken)}${source?.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
loadVideoSource(url, source?.type, initialStartTime);
if (streamToken) {
const url = `${state.playback.streamURL}?mode=${encodeURIComponent(state.playback.currentMode)}&token=${encodeURIComponent(streamToken)}${source?.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
loadVideoSource(url, source?.type, initialStartTime);
}
}
updateSubtitleOptions();
@@ -206,9 +262,7 @@ const initPlayer = async (): Promise<void> => {
}
const onLoadedMetadata = (): void => {
if (loading) {
loading.style.display = "none";
}
setPlayerLoadState("ready");
invalidateBounds();
resolveActiveSegments();
@@ -265,18 +319,50 @@ const initPlayer = async (): Promise<void> => {
state.elements.video.addEventListener(
"waiting",
() => {
if (loading) {
loading.style.display = "flex";
}
setPlayerLoadState(
state.elements.video.readyState >= HTMLMediaElement.HAVE_METADATA
? "buffering"
: "loading_media",
);
},
{ signal },
);
state.elements.video.addEventListener(
"playing",
() => {
if (loading) {
loading.style.display = "none";
setPlayerLoadState("ready");
},
{ signal },
);
state.elements.video.addEventListener(
"error",
() => {
if (sourceRefreshInFlight || state.episode.transitionEpisode !== null) {
return;
}
if (automaticSourceRefreshAttempted) {
setPlayerLoadState("unavailable");
return;
}
automaticSourceRefreshAttempted = true;
sourceRefreshInFlight = true;
setPlayerLoadState("retrying");
refreshCurrentModeSource(signal)
.then((refreshed) => {
if (!refreshed) {
setPlayerLoadState("unavailable");
}
})
.catch((error) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.error("failed to refresh video source:", error);
setPlayerLoadState("unavailable");
})
.finally(() => {
sourceRefreshInFlight = false;
});
},
{ signal },
);
@@ -369,43 +455,10 @@ const initPlayer = async (): Promise<void> => {
{ signal },
);
// track next-episode links outside the player so they start fresh after finishing an episode
document.addEventListener(
"click",
(e) => {
const { target } = e;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const url = new URL(anchor.href, location.origin);
if (url.origin !== location.origin) {
return;
}
const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] !== "anime" || parts[2] !== "watch") {
return;
}
if (Number.parseInt(parts[1], 10) !== state.episode.malID) {
return;
}
const nextEpisode = Number.parseInt(url.searchParams.get("ep") ?? "1", 10);
const currentEpisode = Number.parseInt(state.episode.current, 10);
if (nextEpisode === currentEpisode + 1) {
markEpisodeTransition(nextEpisode);
}
},
{ signal },
);
state.elements.video.addEventListener("click", showControls, { signal });
const searchInput = document.querySelector("[data-episode-search]") as HTMLInputElement | null;
const dropdown = document.querySelector("[data-episode-dropdown]") as HTMLElement | null;
let searchDebounce: number | undefined;
if (searchInput) {
searchInput.addEventListener(
@@ -462,7 +515,6 @@ const initPlayer = async (): Promise<void> => {
switchEpisodeRange(Math.floor((Number.parseInt(state.episode.current, 10) - 1) / 100));
}
setupThumbnails();
window.setTimeout(() => {
if (!signal.aborted) {
hydrateAlternateMode(signal).catch((error) => {
@@ -481,11 +533,6 @@ const initPlayer = async (): Promise<void> => {
},
{ signal },
);
cleanup = () => {
clearTimeout(searchDebounce);
abortController.abort();
};
};
onReady(() => {

View File

@@ -100,10 +100,7 @@ const scheduleProgressSave = (): void => {
}, 30_000);
};
/**
* Records episode transition (clicked external link to next episode). Uses beacon for reliability
* on page unload.
*/
/** Saves the current episode before an asynchronous or fallback episode transition. */
export const markEpisodeTransition = (episodeNumber: number): void => {
if (!state.episode.malID || !episodeNumber) {
return;
@@ -112,8 +109,17 @@ export const markEpisodeTransition = (episodeNumber: number): void => {
window.clearTimeout(state.timers.progressSaveTimer);
state.timers.progressSaveTimer = undefined;
}
const alreadyTransitioning = state.episode.transitionEpisode !== null;
state.episode.transitionEpisode = episodeNumber;
const payload = buildPayload(episodeNumber, 0);
if (alreadyTransitioning || state.episode.endedProgressSaved) {
return;
}
const currentEpisode = Number.parseInt(state.episode.current, 10);
const progressSeconds = snapToEnd(currentProgressTime());
if (!currentEpisode || progressSeconds < 1) {
return;
}
const payload = buildPayload(currentEpisode, progressSeconds);
// beacon falls back to fetch with keepalive
if (!sendBeacon(payload)) {
fetch("/api/watch-progress", {
@@ -125,6 +131,7 @@ export const markEpisodeTransition = (episodeNumber: number): void => {
console.debug("failed to save progress:", error);
});
}
state.episode.lastSavedProgress = { episode: state.episode.current, seconds: progressSeconds };
};
/** Sets up progress save on timeupdate, pause, mouseup (scrub end), and beforeunload. */

View File

@@ -1,4 +1,7 @@
import { state } from "./state";
import { safeLocalStorage } from "./storage";
import { isRecord, parseModeSources, parseSegments } from "./validate";
import { loadVideoSource } from "./video";
export const streamUrlForMode = (mode: string, quality?: string): string => {
const src = state.playback.modeSources[mode];
@@ -16,3 +19,56 @@ export const streamUrlForMode = (mode: string, quality?: string): string => {
return url;
};
const loadCurrentModeSource = async (
signal: AbortSignal | undefined,
forceRefresh: boolean,
): Promise<boolean> => {
const mode = state.playback.currentMode;
const res = await fetch(
`/api/watch/episode/${state.episode.malID}/${encodeURIComponent(state.episode.current)}?mode=${encodeURIComponent(mode)}${forceRefresh ? "&refresh=1" : ""}`,
{ signal },
);
if (!res.ok) {
throw new Error(`mode source refresh failed with status ${res.status}`);
}
const data: unknown = await res.json();
if (!isRecord(data)) {
return false;
}
const sources = parseModeSources(data.mode_sources);
const initialMode = typeof data.initial_mode === "string" ? data.initial_mode : "";
let selectedMode = Object.keys(sources).find((candidate) => sources[candidate]?.token) ?? "";
if (sources[initialMode]?.token) {
selectedMode = initialMode;
}
if (sources[mode]?.token) {
selectedMode = mode;
}
const source = sources[selectedMode];
if (!source?.token) {
return false;
}
state.playback.modeSources = { ...state.playback.modeSources, ...sources };
state.playback.currentMode = selectedMode;
state.playback.modeSwitchedFrom =
typeof data.mode_switched_from === "string" ? data.mode_switched_from : "";
const startTime = Number(data.start_time_seconds);
if (Number.isFinite(startTime) && startTime > 0) {
state.playback.startTimeSeconds = startTime;
}
state.skip.parsedSegments = parseSegments(data.segments);
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
loadVideoSource(streamUrlForMode(selectedMode, preferredQuality), source.type);
return true;
};
export const resolveCurrentModeSource = (signal?: AbortSignal): Promise<boolean> =>
loadCurrentModeSource(signal, false);
export const refreshCurrentModeSource = (signal?: AbortSignal): Promise<boolean> =>
loadCurrentModeSource(signal, true);

View File

@@ -1,6 +1,7 @@
import Hls from "hls.js";
import { attachHLSProfile } from "./hls_profile";
import { setPlayerLoadState } from "./loading";
import { state } from "./state";
import { absoluteTimeFromDisplay, displayTimeFromAbsolute, invalidateBounds } from "./timeline";
@@ -43,14 +44,21 @@ const shouldUseHLS = (type: string | undefined, url: string): boolean => {
* Some browsers can be flaky when switching between HLS URLs while playing. Clearing `src` first
* ensures the media element fully resets before the new URL is set.
*/
export const loadVideoSource = (url: string, type?: string, startTimeSeconds?: number): void => {
export const loadVideoSource = (
url: string,
type?: string,
startTimeSeconds?: number,
preservePosition: boolean = true,
): void => {
if (!url) {
return;
}
setPlayerLoadState("loading_media");
const wasPlaying = !state.elements.video.paused;
const prevDisplayTime = displayTimeFromAbsolute(state.elements.video.currentTime);
const shouldPreservePosition = prevDisplayTime > 0;
const shouldPreservePosition = preservePosition && prevDisplayTime > 0;
// Fully reset the element before setting a new source.
destroyVideoSource();

View File

@@ -228,14 +228,6 @@ const toggleWatchlist = async (
type WatchlistSort = "date" | "title";
const csvEscape = (value: unknown): string => {
const str = String(value ?? "");
if (/[",\r\n]/.test(str)) {
return `"${str.replaceAll(/"/g, '""')}"`;
}
return str;
};
const watchlistItems = (): HTMLElement[] => [
...document.querySelectorAll<HTMLElement>(".watchlist-item"),
];
@@ -314,35 +306,6 @@ const applyWatchlistFilter = (status: string): void => {
});
};
const exportWatchlistCsv = (): void => {
const rows = [...watchlistItems()]
.toSorted((a, b) => {
const dateA = Number.parseInt(a.dataset.updatedAt ?? "0", 10) || 0;
const dateB = Number.parseInt(b.dataset.updatedAt ?? "0", 10) || 0;
return dateB - dateA;
})
.map((item) => {
const updatedAt = Number.parseInt(item.dataset.updatedAt ?? "0", 10) || 0;
const updatedAtISO = updatedAt > 0 ? new Date(updatedAt * 1000).toISOString() : "";
const title = item.dataset.title || item.querySelector("h3")?.textContent?.trim() || "";
return [item.dataset.malId || "", title, item.dataset.status || "", updatedAtISO];
});
const csv = [["mal_id", "title", "status", "updated_at"], ...rows]
.map((row) => row.map(csvEscape).join(","))
.join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "watchlist.csv";
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const initWatchlistPage = (): void => {
let currentSortBy: WatchlistSort = "date";
let sortOrderDesc = true;
@@ -396,12 +359,6 @@ const initWatchlistPage = (): void => {
sortVisibleWatchlistItems(currentSortBy, sortOrderDesc);
return;
}
const exportBtn = target.closest<HTMLButtonElement>("button[data-watchlist-export]");
if (exportBtn) {
exportWatchlistCsv();
return;
}
});
};