chore: format player main

This commit is contained in:
2026-05-28 11:30:01 +02:00
parent a48d48f5ad
commit b52cd311a5

View File

@@ -1,25 +1,25 @@
import { state, initState } from './state'; import { state, initState } from "./state";
import { invalidateBounds, updateTimeline } from './timeline'; import { invalidateBounds, updateTimeline } from "./timeline";
import { setupControls, showControls } from './controls'; import { setupControls, showControls } from "./controls";
import { setupKeyboard } from './keyboard'; import { setupKeyboard } from "./keyboard";
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles'; import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from "./subtitles";
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip'; import { setupSkip, updateSkipButton, updateAutoSkipButton } from "./skip";
import { setupQuality, updateQualityOptions } from './quality'; import { setupQuality, updateQualityOptions } from "./quality";
import { setupMode, updateModeButtons } from './mode'; import { setupMode, updateModeButtons } from "./mode";
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui'; import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from "./episodes/ui";
import { goToNextEpisode } from './episodes/nav'; import { goToNextEpisode } from "./episodes/nav";
import { resolveActiveSegments, renderSegments } from './skip/segments'; import { resolveActiveSegments, renderSegments } from "./skip/segments";
import { setupSegmentEditor } from './skip/editor'; import { setupSegmentEditor } from "./skip/editor";
import { setupThumbnails } from './episodes/thumbnails'; import { setupThumbnails } from "./episodes/thumbnails";
import { markEpisodeTransition, setupProgress } from './progress'; import { markEpisodeTransition, setupProgress } from "./progress";
import { safeLocalStorage } from './storage'; import { safeLocalStorage } from "./storage";
import { import {
absoluteTimeFromDisplay, absoluteTimeFromDisplay,
absoluteTimeFromRatio, absoluteTimeFromRatio,
getBounds, getBounds,
displayTimeFromAbsolute, displayTimeFromAbsolute,
} from './timeline'; } from "./timeline";
import { formatTime } from './controls'; import { formatTime } from "./controls";
let currentContainer: HTMLElement | null = null; let currentContainer: HTMLElement | null = null;
let cleanup: (() => void) | null = null; let cleanup: (() => void) | null = null;
@@ -29,22 +29,22 @@ const isClosableDropdown = (el: Element | null): el is ClosableDropdown => {
if (!el) return false; if (!el) return false;
if (!(el instanceof HTMLElement)) return false; if (!(el instanceof HTMLElement)) return false;
const maybe = el as Partial<{ close: unknown }>; const maybe = el as Partial<{ close: unknown }>;
return typeof maybe.close === 'function'; return typeof maybe.close === "function";
}; };
const hidePreviewPopover = (): void => { const hidePreviewPopover = (): void => {
if (!state.previewPopover) return; if (!state.previewPopover) return;
state.previewPopover.classList.add('hidden'); state.previewPopover.classList.add("hidden");
state.previewPopover.classList.add('opacity-0'); state.previewPopover.classList.add("opacity-0");
state.previewPopover.classList.remove('opacity-100'); state.previewPopover.classList.remove("opacity-100");
state.previewPopover.style.left = ''; state.previewPopover.style.left = "";
}; };
const showPreviewPopover = (): void => { const showPreviewPopover = (): void => {
if (!state.previewPopover) return; if (!state.previewPopover) return;
state.previewPopover.classList.remove('hidden'); state.previewPopover.classList.remove("hidden");
state.previewPopover.classList.remove('opacity-0'); state.previewPopover.classList.remove("opacity-0");
state.previewPopover.classList.add('opacity-100'); state.previewPopover.classList.add("opacity-100");
}; };
const teardownPlayer = (): void => { const teardownPlayer = (): void => {
@@ -55,7 +55,7 @@ const teardownPlayer = (): void => {
// updates time preview on progress bar hover // updates time preview on progress bar hover
const updatePreviewUI = (ratio: number): void => { const updatePreviewUI = (ratio: number): void => {
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null; const progressWrap = state.container.querySelector("[data-progress-wrap]") as HTMLElement | null;
if (!progressWrap || !state.previewPopover || !state.previewTime) { if (!progressWrap || !state.previewPopover || !state.previewTime) {
hidePreviewPopover(); hidePreviewPopover();
return; return;
@@ -82,13 +82,13 @@ const updatePreviewUI = (ratio: number): void => {
}; };
const initPlayer = (): void => { const initPlayer = (): void => {
const container = document.querySelector('[data-video-player]') as HTMLElement | null; const container = document.querySelector("[data-video-player]") as HTMLElement | null;
if (!container) return; if (!container) return;
if (container === currentContainer) return; if (container === currentContainer) return;
teardownPlayer(); teardownPlayer();
if (!initState(container)) { if (!initState(container)) {
console.error('Video player markup is missing required controls.'); console.error("Video player markup is missing required controls.");
return; return;
} }
currentContainer = container; currentContainer = container;
@@ -96,15 +96,15 @@ const initPlayer = (): void => {
const signal = abortController.signal; const signal = abortController.signal;
cleanup = () => abortController.abort(); cleanup = () => abortController.abort();
const loading = container.querySelector('[data-loading]') as HTMLElement | null; const loading = container.querySelector("[data-loading]") as HTMLElement | null;
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null; const progressWrap = container.querySelector("[data-progress-wrap]") as HTMLElement | null;
// build video src from mode, token, and saved quality preference // build video src from mode, token, and saved quality preference
// Only set if not already provided by the inline script during HTML parsing // Only set if not already provided by the inline script during HTML parsing
const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best'; const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
const streamToken = state.modeSources[state.currentMode]?.token; const streamToken = state.modeSources[state.currentMode]?.token;
if (!state.video.src && streamToken) { if (!state.video.src && streamToken) {
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`; state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
} }
setupProgress(); setupProgress();
@@ -122,7 +122,7 @@ const initPlayer = (): void => {
setupAutoplayButton(); setupAutoplayButton();
updateAutoSkipButton(); updateAutoSkipButton();
showControls(); showControls();
if (state.modeSwitchedFrom === 'dub' && state.currentMode === 'sub') { if (state.modeSwitchedFrom === "dub" && state.currentMode === "sub") {
window.showToast?.({ window.showToast?.({
message: `Episode ${state.currentEpisode} is only available in sub, switched from dub.`, message: `Episode ${state.currentEpisode} is only available in sub, switched from dub.`,
}); });
@@ -130,7 +130,7 @@ const initPlayer = (): void => {
const onLoadedMetadata = (): void => { const onLoadedMetadata = (): void => {
if (loading) { if (loading) {
loading.style.display = 'none'; loading.style.display = "none";
} }
invalidateBounds(); invalidateBounds();
@@ -159,7 +159,7 @@ const initPlayer = (): void => {
updateSkipButton(state.video.currentTime); updateSkipButton(state.video.currentTime);
}; };
state.video.addEventListener('loadedmetadata', onLoadedMetadata, { signal }); state.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal });
// inline script runs during HTML parsing before initPlayer; if metadata // inline script runs during HTML parsing before initPlayer; if metadata
// already loaded, fire the handler immediately // already loaded, fire the handler immediately
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
@@ -167,142 +167,142 @@ const initPlayer = (): void => {
} }
state.video.addEventListener( state.video.addEventListener(
'waiting', "waiting",
() => { () => {
if (loading) { if (loading) {
loading.style.display = 'flex'; loading.style.display = "flex";
} }
}, },
{ signal } { signal },
); );
state.video.addEventListener( state.video.addEventListener(
'playing', "playing",
() => { () => {
if (loading) { if (loading) {
loading.style.display = 'none'; loading.style.display = "none";
} }
}, },
{ signal } { signal },
); );
// update progress bar during buffering // update progress bar during buffering
state.video.addEventListener( state.video.addEventListener(
'progress', "progress",
() => { () => {
updateTimeline(state.video.currentTime); updateTimeline(state.video.currentTime);
}, },
{ signal } { signal },
); );
// main loop: update progress, subtitles, skip buttons // main loop: update progress, subtitles, skip buttons
state.video.addEventListener( state.video.addEventListener(
'timeupdate', "timeupdate",
() => { () => {
updateTimeline(state.video.currentTime); updateTimeline(state.video.currentTime);
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime)); updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
updateSkipButton(state.video.currentTime); updateSkipButton(state.video.currentTime);
}, },
{ signal } { signal },
); );
state.video.addEventListener( state.video.addEventListener(
'ended', "ended",
() => { () => {
goToNextEpisode(); goToNextEpisode();
}, },
{ signal } { signal },
); );
// click/drag to seek (pointer events are more consistent across fullscreen/mobile) // click/drag to seek (pointer events are more consistent across fullscreen/mobile)
progressWrap?.addEventListener( progressWrap?.addEventListener(
'pointerdown', "pointerdown",
e => { (e) => {
// ignore right/middle click // ignore right/middle click
if ('button' in e && e.button !== 0) return; if ("button" in e && e.button !== 0) return;
state.isScrubbing = true; state.isScrubbing = true;
try { try {
(e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId); (e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId);
} catch {} } catch {}
const rect = progressWrap.getBoundingClientRect(); const rect = progressWrap.getBoundingClientRect();
state.video.currentTime = absoluteTimeFromRatio( state.video.currentTime = absoluteTimeFromRatio(
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
); );
updateTimeline(state.video.currentTime); updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime); updateSkipButton(state.video.currentTime);
showControls(); showControls();
}, },
{ signal } { signal },
); );
// hover to preview time // hover to preview time
progressWrap?.addEventListener( progressWrap?.addEventListener(
'pointermove', "pointermove",
e => { (e) => {
const rect = progressWrap.getBoundingClientRect(); const rect = progressWrap.getBoundingClientRect();
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
}, },
{ signal } { signal },
); );
progressWrap?.addEventListener('pointerleave', hidePreviewPopover, { signal }); progressWrap?.addEventListener("pointerleave", hidePreviewPopover, { signal });
progressWrap?.addEventListener( progressWrap?.addEventListener(
'pointerup', "pointerup",
() => { () => {
// ensure we finish the seek even if no window mousemove fired // ensure we finish the seek even if no window mousemove fired
if (!progressWrap) return; if (!progressWrap) return;
state.isScrubbing = false; state.isScrubbing = false;
}, },
{ signal } { signal },
); );
// dragging outside progress bar while scrubbing // dragging outside progress bar while scrubbing
window.addEventListener( window.addEventListener(
'pointermove', "pointermove",
e => { (e) => {
if (!state.isScrubbing || !progressWrap) return; if (!state.isScrubbing || !progressWrap) return;
const rect = progressWrap.getBoundingClientRect(); const rect = progressWrap.getBoundingClientRect();
state.video.currentTime = absoluteTimeFromRatio( state.video.currentTime = absoluteTimeFromRatio(
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
); );
updateTimeline(state.video.currentTime); updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime); updateSkipButton(state.video.currentTime);
}, },
{ signal } { signal },
); );
// track next-episode links outside the player so they start fresh after finishing an episode // track next-episode links outside the player so they start fresh after finishing an episode
document.addEventListener( document.addEventListener(
'click', "click",
e => { (e) => {
const target = e.target; const target = e.target;
if (!(target instanceof Element)) return; if (!(target instanceof Element)) return;
const anchor = target.closest('a[href]'); const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return; if (!(anchor instanceof HTMLAnchorElement)) return;
const url = new URL(anchor.href, location.origin); const url = new URL(anchor.href, location.origin);
if (url.origin !== location.origin) return; if (url.origin !== location.origin) return;
const parts = url.pathname.split('/').filter(Boolean); const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] !== 'anime' || parts[2] !== 'watch') return; if (parts[0] !== "anime" || parts[2] !== "watch") return;
if (Number.parseInt(parts[1], 10) !== state.malID) return; if (Number.parseInt(parts[1], 10) !== state.malID) return;
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10); const nextEpisode = Number.parseInt(url.searchParams.get("ep") ?? "1", 10);
const currentEpisode = Number.parseInt(state.currentEpisode, 10); const currentEpisode = Number.parseInt(state.currentEpisode, 10);
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode); if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
}, },
{ signal } { signal },
); );
state.video.addEventListener('click', showControls, { signal }); state.video.addEventListener("click", showControls, { signal });
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null; const searchInput = document.querySelector("[data-episode-search]") as HTMLInputElement | null;
const dropdown = document.querySelector('[data-episode-dropdown]') as HTMLElement | null; const dropdown = document.querySelector("[data-episode-dropdown]") as HTMLElement | null;
let searchDebounce: number | undefined; let searchDebounce: number | undefined;
if (searchInput) { if (searchInput) {
searchInput.addEventListener( searchInput.addEventListener(
'input', "input",
() => { () => {
clearTimeout(searchDebounce); clearTimeout(searchDebounce);
// debounce to avoid excessive range switches while typing // debounce to avoid excessive range switches while typing
searchDebounce = window.setTimeout(() => { searchDebounce = window.setTimeout(() => {
const val = searchInput.value.replace(/\D/g, ''); const val = searchInput.value.replace(/\D/g, "");
if (!val) { if (!val) {
// clear: jump to current episode range // clear: jump to current episode range
const cur = Number.parseInt(state.currentEpisode, 10); const cur = Number.parseInt(state.currentEpisode, 10);
@@ -321,22 +321,22 @@ const initPlayer = (): void => {
} }
}, 300); }, 300);
}, },
{ signal } { signal },
); );
} }
// range buttons (100s of episodes) // range buttons (100s of episodes)
if (dropdown) { if (dropdown) {
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => { dropdown.querySelectorAll(".episode-range-btn").forEach((btn) => {
btn.addEventListener( btn.addEventListener(
'click', "click",
() => { () => {
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10); const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? "0", 10);
switchEpisodeRange(idx); switchEpisodeRange(idx);
const dd = btn.closest('ui-dropdown'); const dd = btn.closest("ui-dropdown");
if (isClosableDropdown(dd)) dd.close(); if (isClosableDropdown(dd)) dd.close();
}, },
{ signal } { signal },
); );
}); });
} }
@@ -349,13 +349,13 @@ const initPlayer = (): void => {
setupThumbnails(); setupThumbnails();
}; };
document.addEventListener('DOMContentLoaded', initPlayer); document.addEventListener("DOMContentLoaded", initPlayer);
document.body.addEventListener('htmx:afterSwap', (e: Event) => { document.body.addEventListener("htmx:afterSwap", (e: Event) => {
const target = (e as CustomEvent).detail?.target as HTMLElement | null; const target = (e as CustomEvent).detail?.target as HTMLElement | null;
if (target?.querySelector('[data-video-player]')) initPlayer(); if (target?.querySelector("[data-video-player]")) initPlayer();
}); });
document.body.addEventListener('htmx:beforeSwap', (e: Event) => { document.body.addEventListener("htmx:beforeSwap", (e: Event) => {
const target = (e as CustomEvent).detail?.target as HTMLElement | null; const target = (e as CustomEvent).detail?.target as HTMLElement | null;
if (target && currentContainer && target.contains(currentContainer)) { if (target && currentContainer && target.contains(currentContainer)) {
teardownPlayer(); teardownPlayer();