Merge upstream/main
110
static/anime.ts
@@ -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();
|
||||
|
||||
@@ -3,6 +3,7 @@ import "./toast";
|
||||
import "./htmx";
|
||||
import "./dropdown";
|
||||
import "./anime";
|
||||
import "./episode_availability_warning";
|
||||
import "./search";
|
||||
import "./dedupe";
|
||||
import "./watchlist";
|
||||
|
||||
BIN
static/assets/app-icon-192.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
BIN
static/assets/favicon-16.png
Normal file
|
After Width: | Height: | Size: 619 B |
BIN
static/assets/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/assets/logo-128.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
@@ -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"
|
||||
|
||||
23
static/episode_availability_warning.ts
Normal 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();
|
||||
821
static/player/browser-flow.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
26
static/player/episodes/classifications.test.ts
Normal 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 },
|
||||
],
|
||||
);
|
||||
});
|
||||
91
static/player/episodes/classifications.ts
Normal 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()));
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
23
static/player/episodes/titles.test.ts
Normal 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" }],
|
||||
);
|
||||
});
|
||||
69
static/player/episodes/titles.ts
Normal 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()));
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||