From b569b06591c2e5cbfbcc2474ea0c653a8f7f2974 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 16 Jun 2026 10:37:55 +0200 Subject: [PATCH] refactor: group player state --- static/player/controls.ts | 83 ++++++------ static/player/keyboard.ts | 6 +- static/player/mode.ts | 50 ++++---- static/player/quality.ts | 14 +- static/player/source.ts | 4 +- static/player/state.ts | 264 +++++++++++++++++++++----------------- static/player/timeline.ts | 30 +++-- static/player/video.ts | 28 ++-- 8 files changed, 260 insertions(+), 219 deletions(-) diff --git a/static/player/controls.ts b/static/player/controls.ts index 1ed5c7d..3d398df 100644 --- a/static/player/controls.ts +++ b/static/player/controls.ts @@ -13,50 +13,53 @@ export const formatTime = (seconds: number): string => { * Shows the controls overlay and schedules auto-hide after 2s if playing. */ export const showControls = (): void => { - state.container.classList.add("show-controls"); - window.clearTimeout(state.playerControlsTimeout); - state.playerControlsTimeout = window.setTimeout(() => { - if (!state.isScrubbing && !state.video.paused) { - state.container.classList.remove("show-controls"); + state.elements.container.classList.add("show-controls"); + window.clearTimeout(state.timers.playerControlsTimeout); + state.timers.playerControlsTimeout = window.setTimeout(() => { + if (!state.ui.isScrubbing && !state.elements.video.paused) { + state.elements.container.classList.remove("show-controls"); } }, 2000); }; // seek relative to current position export const seekBy = (delta: number): void => { - if (state.video.duration <= 0) return; - state.video.currentTime = Math.max( + if (state.elements.video.duration <= 0) return; + state.elements.video.currentTime = Math.max( 0, - Math.min(state.video.duration, state.video.currentTime + delta), + Math.min(state.elements.video.duration, state.elements.video.currentTime + delta), ); showControls(); }; export const togglePlayPause = (): void => { - if (state.video.paused) { - state.video.play(); + if (state.elements.video.paused) { + state.elements.video.play(); } else { - state.video.pause(); + state.elements.video.pause(); } }; // toggle mute, restoring previous volume export const toggleMute = (): void => { - if (state.video.muted || state.video.volume === 0) { - const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1; - state.video.muted = false; - state.video.volume = restored; + if (state.elements.video.muted || state.elements.video.volume === 0) { + const restored = state.playback.lastKnownVolume > 0 ? state.playback.lastKnownVolume : 1; + state.elements.video.muted = false; + state.elements.video.volume = restored; } else { - state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume; - state.video.muted = true; + state.playback.lastKnownVolume = + state.elements.video.volume > 0 + ? state.elements.video.volume + : state.playback.lastKnownVolume; + state.elements.video.muted = true; } }; // set volume (0-1), auto-unmute export const setVolume = (value: number): void => { - state.video.volume = Math.max(0, Math.min(1, value)); - state.video.muted = value === 0; - if (value > 0) state.lastKnownVolume = value; + state.elements.video.volume = Math.max(0, Math.min(1, value)); + state.elements.video.muted = value === 0; + if (value > 0) state.playback.lastKnownVolume = value; }; export const toggleFullscreen = (): void => { @@ -64,19 +67,19 @@ export const toggleFullscreen = (): void => { document.exitFullscreen(); return; } - state.container.requestFullscreen?.(); + state.elements.container.requestFullscreen?.(); }; // syncs volume slider, underline, and mute icon export const syncVolumeUI = (): void => { const { volumeRange, volumeUnderline } = getControls(); - const value = state.video.muted ? 0 : Math.round(state.video.volume * 100); + const value = state.elements.video.muted ? 0 : Math.round(state.elements.video.volume * 100); if (volumeRange) { volumeRange.value = String(value); volumeRange.style.setProperty("--volume-percent", `${value}%`); } if (volumeUnderline) volumeUnderline.style.height = `${value}%`; - updateMuteIcons(state.video.muted || state.video.volume === 0); + updateMuteIcons(state.elements.video.muted || state.elements.video.volume === 0); }; const VOLUME_STORAGE_KEY = "player-volume"; @@ -93,17 +96,17 @@ const applyStoredVolume = (): void => { const stored = parseStoredVolume(safeLocalStorage.getItem(VOLUME_STORAGE_KEY)); if (stored === null) return; - state.video.volume = stored; - state.video.muted = stored === 0; - if (stored > 0) state.lastKnownVolume = stored; + state.elements.video.volume = stored; + state.elements.video.muted = stored === 0; + if (stored > 0) state.playback.lastKnownVolume = stored; }; let volumeSaveTimer: number | undefined; const schedulePersistVolume = (): void => { window.clearTimeout(volumeSaveTimer); volumeSaveTimer = window.setTimeout(() => { - if (!Number.isFinite(state.video.volume)) return; - const clamped = Math.max(0, Math.min(1, state.video.volume)); + if (!Number.isFinite(state.elements.video.volume)) return; + const clamped = Math.max(0, Math.min(1, state.elements.video.volume)); safeLocalStorage.setItem(VOLUME_STORAGE_KEY, clamped.toFixed(3)); }, 250); }; @@ -130,7 +133,7 @@ let controlsCache: Controls | null = null; const getControls = (): Controls => { if (controlsCache) return controlsCache; - const c = state.container; + const c = state.elements.container; controlsCache = { playPause: c.querySelector("[data-play-pause]"), muteBtn: c.querySelector("[data-mute]"), @@ -186,7 +189,7 @@ export const setupControls = (): void => { togglePlayPause(); showControls(); }); - state.video.addEventListener("click", () => { + state.elements.video.addEventListener("click", () => { togglePlayPause(); showControls(); }); @@ -216,37 +219,37 @@ export const setupControls = (): void => { // skip intro/outro button skipSegmentBtn?.addEventListener("click", () => { - if (!state.activeSkipSegment) return; - state.video.currentTime = state.activeSkipSegment.end + 0.01; + if (!state.skip.activeSegment) return; + state.elements.video.currentTime = state.skip.activeSegment.end + 0.01; showControls(); }); // fullscreen change handler document.addEventListener("fullscreenchange", () => { - state.isFullscreen = !!document.fullscreenElement; - state.container.classList.toggle("fullscreen", state.isFullscreen); - if (state.isFullscreen) showControls(); + state.ui.isFullscreen = !!document.fullscreenElement; + state.elements.container.classList.toggle("fullscreen", state.ui.isFullscreen); + if (state.ui.isFullscreen) showControls(); }); // icon sync on state changes - state.video.addEventListener("play", () => { + state.elements.video.addEventListener("play", () => { updatePlayPauseIcons(true); showControls(); }); - state.video.addEventListener("pause", () => { + state.elements.video.addEventListener("pause", () => { updatePlayPauseIcons(false); showControls(); void saveProgress(); }); - state.video.addEventListener("volumechange", () => { + state.elements.video.addEventListener("volumechange", () => { syncVolumeUI(); schedulePersistVolume(); }); // mouse move in container shows controls - state.container.addEventListener("mousemove", showControls); + state.elements.container.addEventListener("mousemove", showControls); // initial sync — check actual video state since inline script may have started playback - updatePlayPauseIcons(!state.video.paused); + updatePlayPauseIcons(!state.elements.video.paused); syncVolumeUI(); }; diff --git a/static/player/keyboard.ts b/static/player/keyboard.ts index 04dda74..42f88a2 100644 --- a/static/player/keyboard.ts +++ b/static/player/keyboard.ts @@ -41,12 +41,12 @@ export const setupKeyboard = (): void => { break; case "ArrowUp": e.preventDefault(); - setVolume(state.video.volume + 0.05); + setVolume(state.elements.video.volume + 0.05); showControls(); break; case "ArrowDown": e.preventDefault(); - setVolume(state.video.volume - 0.05); + setVolume(state.elements.video.volume - 0.05); showControls(); break; case "KeyM": @@ -65,7 +65,7 @@ export const setupKeyboard = (): void => { const b = getBounds(); if (b.duration > 0) { e.preventDefault(); - state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10); + state.elements.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10); showControls(); } } diff --git a/static/player/mode.ts b/static/player/mode.ts index 97560be..4914a7f 100644 --- a/static/player/mode.ts +++ b/static/player/mode.ts @@ -14,13 +14,13 @@ const alternateModeFor = (mode: string): "sub" | "dub" | null => { }; export const hydrateAlternateMode = async (signal?: AbortSignal): Promise => { - const alternateMode = alternateModeFor(state.currentMode); + const alternateMode = alternateModeFor(state.playback.currentMode); if (!alternateMode) return; - if (state.modeSources[alternateMode]?.token) return; + if (state.playback.modeSources[alternateMode]?.token) return; try { const res = await fetch( - `/api/watch/episode/${state.malID}/${encodeURIComponent(state.currentEpisode)}?mode=${encodeURIComponent(alternateMode)}`, + `/api/watch/episode/${state.episode.malID}/${encodeURIComponent(state.episode.current)}?mode=${encodeURIComponent(alternateMode)}`, { signal }, ); if (!res.ok) return; @@ -32,8 +32,8 @@ export const hydrateAlternateMode = async (signal?: AbortSignal): Promise const alternateSource = sources[alternateMode]; if (!alternateSource?.token) return; - state.modeSources = { - ...state.modeSources, + state.playback.modeSources = { + ...state.playback.modeSources, [alternateMode]: alternateSource, }; @@ -50,24 +50,24 @@ export const hydrateAlternateMode = async (signal?: AbortSignal): Promise * Saves preference to localStorage, reloads video src. */ export const switchMode = (mode: string): void => { - if (!state.availableModes.includes(mode) || mode === state.currentMode) return; - state.currentMode = mode; + if (!state.playback.availableModes.includes(mode) || mode === state.playback.currentMode) return; + state.playback.currentMode = mode; safeLocalStorage.setItem("player-audio-mode", mode); - const qualitySelect = state.container.querySelector( + const qualitySelect = state.elements.container.querySelector( "[data-quality-select]", ) as HTMLSelectElement | null; const url = streamUrlForMode(mode, qualitySelect?.value); - loadVideoSource(url, state.modeSources[mode]?.type); + loadVideoSource(url, state.playback.modeSources[mode]?.type); // Fallback: if the media element doesn't actually switch sources (some browsers can get "stuck"), // reload the page with the desired mode and resume time via sessionStorage. if (url) { - const expectedToken = state.modeSources[mode]?.token; + const expectedToken = state.playback.modeSources[mode]?.token; const expectedMode = mode; - const resumeSeconds = state.video.currentTime; + const resumeSeconds = state.elements.video.currentTime; window.setTimeout(() => { if (!expectedToken) return; - const currentSrc = state.video.currentSrc || state.video.src || ""; + const currentSrc = state.elements.video.currentSrc || state.elements.video.src || ""; if (currentSrc.includes(`token=${encodeURIComponent(expectedToken)}`)) return; try { @@ -90,24 +90,24 @@ export const switchMode = (mode: string): void => { * Disables unavailable modes. */ export const updateModeButtons = (): void => { - const dub = state.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null; - const sub = state.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null; - const m = state.currentMode; + const dub = state.elements.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null; + const sub = state.elements.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null; + const m = state.playback.currentMode; dub?.classList.toggle("text-accent", m === "dub"); dub?.classList.toggle("text-foreground", m !== "dub"); - dub?.classList.toggle("opacity-50", !state.availableModes.includes("dub")); - dub?.classList.toggle("cursor-not-allowed", !state.availableModes.includes("dub")); + dub?.classList.toggle("opacity-50", !state.playback.availableModes.includes("dub")); + dub?.classList.toggle("cursor-not-allowed", !state.playback.availableModes.includes("dub")); if (dub) { - dub.disabled = !state.availableModes.includes("dub"); + dub.disabled = !state.playback.availableModes.includes("dub"); } sub?.classList.toggle("text-accent", m === "sub"); sub?.classList.toggle("text-foreground", m !== "sub"); - sub?.classList.toggle("opacity-50", !state.availableModes.includes("sub")); - sub?.classList.toggle("cursor-not-allowed", !state.availableModes.includes("sub")); + sub?.classList.toggle("opacity-50", !state.playback.availableModes.includes("sub")); + sub?.classList.toggle("cursor-not-allowed", !state.playback.availableModes.includes("sub")); if (sub) { - sub.disabled = !state.availableModes.includes("sub"); + sub.disabled = !state.playback.availableModes.includes("sub"); } }; @@ -115,17 +115,17 @@ export const updateModeButtons = (): void => { * Binds click handlers for mode buttons and autoplay toggle. */ export const setupMode = (): void => { - const dub = state.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null; - const sub = state.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null; + const dub = state.elements.container.querySelector("[data-mode-dub]") as HTMLButtonElement | null; + const sub = state.elements.container.querySelector("[data-mode-sub]") as HTMLButtonElement | null; dub?.addEventListener("click", () => { - if (state.availableModes.includes("dub")) { + if (state.playback.availableModes.includes("dub")) { switchMode("dub"); showControls(); } }); sub?.addEventListener("click", () => { - if (state.availableModes.includes("sub")) { + if (state.playback.availableModes.includes("sub")) { switchMode("sub"); showControls(); } diff --git a/static/player/quality.ts b/static/player/quality.ts index bb38650..5044744 100644 --- a/static/player/quality.ts +++ b/static/player/quality.ts @@ -8,10 +8,10 @@ import { loadVideoSource } from "./video"; * Persists preference to localStorage. */ export const switchQuality = (quality: string): void => { - const url = streamUrlForMode(state.currentMode, quality); + const url = streamUrlForMode(state.playback.currentMode, quality); if (!url) return; safeLocalStorage.setItem("mal:preferred-quality", quality); - loadVideoSource(url, state.modeSources[state.currentMode]?.type); + loadVideoSource(url, state.playback.modeSources[state.playback.currentMode]?.type); }; /** @@ -19,9 +19,11 @@ export const switchQuality = (quality: string): void => { * Shows/hides dropdown based on availability. */ export const updateQualityOptions = (): void => { - const select = state.container.querySelector("[data-quality-select]") as HTMLSelectElement | null; + const select = state.elements.container.querySelector( + "[data-quality-select]", + ) as HTMLSelectElement | null; if (!select) return; - const qualities = state.modeSources[state.currentMode]?.qualities ?? []; + const qualities = state.playback.modeSources[state.playback.currentMode]?.qualities ?? []; select.innerHTML = ""; const best = document.createElement("option"); @@ -49,7 +51,9 @@ export const updateQualityOptions = (): void => { * Binds quality select change handler. */ export const setupQuality = (): void => { - const select = state.container.querySelector("[data-quality-select]") as HTMLSelectElement | null; + const select = state.elements.container.querySelector( + "[data-quality-select]", + ) as HTMLSelectElement | null; select?.addEventListener("change", (e) => { switchQuality((e.target as HTMLSelectElement).value); }); diff --git a/static/player/source.ts b/static/player/source.ts index cb735d0..832a0cf 100644 --- a/static/player/source.ts +++ b/static/player/source.ts @@ -1,10 +1,10 @@ import { state } from "./state"; export const streamUrlForMode = (mode: string, quality?: string): string => { - const src = state.modeSources[mode]; + const src = state.playback.modeSources[mode]; if (!src?.token) return ""; - let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`; + let url = `${state.playback.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`; if (src.type === "m3u8") { url += "&hls=1"; } diff --git a/static/player/state.ts b/static/player/state.ts index afeb7a1..0b8e828 100644 --- a/static/player/state.ts +++ b/static/player/state.ts @@ -4,102 +4,130 @@ import { q, qs, dataset } from "../q"; import { safeLocalStorage } from "./storage"; export interface PlayerState { - container: HTMLElement; - video: HTMLVideoElement; - progress: HTMLElement; - scrubber: HTMLElement; - buffered: HTMLElement; - timeDisplay: HTMLElement; - durationDisplay: HTMLElement; - modeSources: Record; - readonly availableModes: string[]; - currentMode: string; - modeSwitchedFrom: string; - currentEpisode: string; - totalEpisodes: number; - isAiring: boolean; - malID: number; - streamURL: string; - initialStreamToken: string; - startTimeSeconds: number; - shouldAutoPlay: boolean; - parsedSegments: SkipSegment[]; - activeSegments: ActiveSegment[]; - activeSkipSegment: ActiveSegment | null; - activeSubtitles: SubtitleCue[]; - currentSubtitleTracks: SubtitleTrack[]; - lastKnownVolume: number; - pendingSeekTime: number | null; - isScrubbing: boolean; - isFullscreen: boolean; - playerControlsTimeout: number | undefined; - progressSaveTimer: number | undefined; - transitionEpisode: number | null; - completionSent: boolean; - completionAttempts: number; - endedProgressSaved: boolean; - lastSavedProgress: { episode: string; seconds: number }; - episodeGrid: HTMLElement | null; - episodeList: HTMLElement | null; - previewPopover: HTMLElement | null; - previewTime: HTMLElement | null; - videoOverlay: HTMLElement | null; + elements: { + container: HTMLElement; + video: HTMLVideoElement; + progress: HTMLElement; + scrubber: HTMLElement; + buffered: HTMLElement; + timeDisplay: HTMLElement; + durationDisplay: HTMLElement; + episodeGrid: HTMLElement | null; + episodeList: HTMLElement | null; + previewPopover: HTMLElement | null; + previewTime: HTMLElement | null; + videoOverlay: HTMLElement | null; + }; + playback: { + modeSources: Record; + readonly availableModes: string[]; + currentMode: string; + modeSwitchedFrom: string; + streamURL: string; + initialStreamToken: string; + startTimeSeconds: number; + shouldAutoPlay: boolean; + lastKnownVolume: number; + pendingSeekTime: number | null; + }; + episode: { + current: string; + total: number; + isAiring: boolean; + malID: number; + transitionEpisode: number | null; + completionSent: boolean; + completionAttempts: number; + endedProgressSaved: boolean; + lastSavedProgress: { episode: string; seconds: number }; + }; + skip: { + parsedSegments: SkipSegment[]; + activeSegments: ActiveSegment[]; + activeSegment: ActiveSegment | null; + }; + subtitles: { + activeCues: SubtitleCue[]; + tracks: SubtitleTrack[]; + }; + ui: { + isScrubbing: boolean; + isFullscreen: boolean; + }; + timers: { + playerControlsTimeout: number | undefined; + progressSaveTimer: number | undefined; + }; } const createInitialState = (): PlayerState => ({ - container: document.createElement("div"), - video: document.createElement("video"), - progress: document.createElement("div"), - scrubber: document.createElement("div"), - buffered: document.createElement("div"), - timeDisplay: document.createElement("div"), - durationDisplay: document.createElement("div"), - modeSources: {}, - get availableModes() { - return Object.keys(this.modeSources); + elements: { + container: document.createElement("div"), + video: document.createElement("video"), + progress: document.createElement("div"), + scrubber: document.createElement("div"), + buffered: document.createElement("div"), + timeDisplay: document.createElement("div"), + durationDisplay: document.createElement("div"), + episodeGrid: null, + episodeList: null, + previewPopover: null, + previewTime: null, + videoOverlay: null, + }, + playback: { + modeSources: {}, + get availableModes() { + return Object.keys(this.modeSources); + }, + currentMode: "dub", + modeSwitchedFrom: "", + streamURL: "/watch/proxy/stream", + initialStreamToken: "", + startTimeSeconds: 0, + shouldAutoPlay: false, + lastKnownVolume: 1, + pendingSeekTime: null, + }, + episode: { + current: "1", + total: 0, + isAiring: false, + malID: 0, + transitionEpisode: null, + completionSent: false, + completionAttempts: 0, + endedProgressSaved: false, + lastSavedProgress: { episode: "1", seconds: -1 }, + }, + skip: { + parsedSegments: [], + activeSegments: [], + activeSegment: null, + }, + subtitles: { + activeCues: [], + tracks: [], + }, + ui: { + isScrubbing: false, + isFullscreen: false, + }, + timers: { + playerControlsTimeout: undefined, + progressSaveTimer: undefined, }, - currentMode: "dub", - modeSwitchedFrom: "", - currentEpisode: "1", - totalEpisodes: 0, - isAiring: false, - malID: 0, - streamURL: "/watch/proxy/stream", - initialStreamToken: "", - startTimeSeconds: 0, - shouldAutoPlay: false, - parsedSegments: [], - activeSegments: [], - activeSkipSegment: null, - activeSubtitles: [], - currentSubtitleTracks: [], - lastKnownVolume: 1, - pendingSeekTime: null, - isScrubbing: false, - isFullscreen: false, - playerControlsTimeout: undefined, - progressSaveTimer: undefined, - transitionEpisode: null, - completionSent: false, - completionAttempts: 0, - endedProgressSaved: false, - lastSavedProgress: { episode: "1", seconds: -1 }, - episodeGrid: null, - episodeList: null, - previewPopover: null, - previewTime: null, - videoOverlay: null, }); export const state: PlayerState = createInitialState(); export const showEndState = (): void => { - state.container.classList.add("video-ended"); - state.video.pause(); + state.elements.container.classList.add("video-ended"); + state.elements.video.pause(); }; export const hideEndState = (): void => { - state.container.classList.remove("video-ended"); + state.elements.container.classList.remove("video-ended"); }; interface RequiredPlayerElements { @@ -145,33 +173,33 @@ export const initState = (c: HTMLElement): boolean => { if (!elements) return false; // core elements - state.container = c; - state.video = elements.video; - state.progress = elements.progress; - state.scrubber = elements.scrubber; - state.buffered = elements.buffered; - state.timeDisplay = elements.timeDisplay; - state.durationDisplay = elements.durationDisplay; - state.previewPopover = q(c, "[data-preview-popover]"); - state.previewTime = q(c, "[data-preview-time]"); - state.videoOverlay = q(c, "[data-video-overlay]"); + state.elements.container = c; + state.elements.video = elements.video; + state.elements.progress = elements.progress; + state.elements.scrubber = elements.scrubber; + state.elements.buffered = elements.buffered; + state.elements.timeDisplay = elements.timeDisplay; + state.elements.durationDisplay = elements.durationDisplay; + state.elements.previewPopover = q(c, "[data-preview-popover]"); + state.elements.previewTime = q(c, "[data-preview-time]"); + state.elements.videoOverlay = q(c, "[data-video-overlay]"); // data attributes from server - state.malID = Number.parseInt(dataset(c, "malId"), 10); - state.currentEpisode = dataset(c, "currentEpisode") || "1"; + state.episode.malID = Number.parseInt(dataset(c, "malId"), 10); + state.episode.current = dataset(c, "currentEpisode") || "1"; - state.totalEpisodes = Number.parseInt(dataset(c, "totalEpisodes"), 10); - state.isAiring = dataset(c, "isAiring") === "true"; - state.streamURL = dataset(c, "streamUrl") || "/watch/proxy/stream"; - state.initialStreamToken = dataset(c, "streamToken") || ""; - state.startTimeSeconds = Number.parseFloat(dataset(c, "startTimeSeconds") || "0") || 0; + state.episode.total = Number.parseInt(dataset(c, "totalEpisodes"), 10); + state.episode.isAiring = dataset(c, "isAiring") === "true"; + state.playback.streamURL = dataset(c, "streamUrl") || "/watch/proxy/stream"; + state.playback.initialStreamToken = dataset(c, "streamToken") || ""; + state.playback.startTimeSeconds = Number.parseFloat(dataset(c, "startTimeSeconds") || "0") || 0; // from session: previous page set this when autoplay triggered - state.shouldAutoPlay = sessionStorage.getItem("mal:autoplay-next") === "true"; + state.playback.shouldAutoPlay = sessionStorage.getItem("mal:autoplay-next") === "true"; sessionStorage.removeItem("mal:autoplay-next"); // global elements (not inside player container) - state.episodeGrid = qs("[data-episode-grid]"); - state.episodeList = qs("[data-episode-list]"); + state.elements.episodeGrid = qs("[data-episode-grid]"); + state.elements.episodeList = qs("[data-episode-list]"); const safeJsonUnknown = (raw: string | undefined): unknown => { try { @@ -182,23 +210,27 @@ export const initState = (c: HTMLElement): boolean => { }; // mode sources = { sub: { token, subtitles, qualities }, dub: { ... } } - state.modeSources = parseModeSources(safeJsonUnknown(dataset(c, "modeSources"))); + state.playback.modeSources = parseModeSources(safeJsonUnknown(dataset(c, "modeSources"))); // resolve initial mode: localStorage > backend default > first available > 'dub' const backendInitialMode = dataset(c, "initialMode") || "dub"; - state.modeSwitchedFrom = dataset(c, "modeSwitchedFrom") || ""; + state.playback.modeSwitchedFrom = dataset(c, "modeSwitchedFrom") || ""; const storedMode = safeLocalStorage.getItem("player-audio-mode"); const initialMode = - storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode; - const fallbackMode = Object.keys(state.modeSources).find((m) => state.modeSources[m]?.token); - state.currentMode = state.modeSources[initialMode]?.token + storedMode && state.playback.availableModes.includes(storedMode) + ? storedMode + : backendInitialMode; + const fallbackMode = Object.keys(state.playback.modeSources).find( + (m) => state.playback.modeSources[m]?.token, + ); + state.playback.currentMode = state.playback.modeSources[initialMode]?.token ? initialMode - : (fallbackMode ?? state.availableModes[0] ?? "dub"); + : (fallbackMode ?? state.playback.availableModes[0] ?? "dub"); // If the inline template script already set a video src, prefer deriving the mode from it. // This avoids mismatches where the UI highlights one mode but the video is actually playing the other. const deriveModeFromVideoSrc = (): string | null => { - const raw = state.video.currentSrc || state.video.src; + const raw = state.elements.video.currentSrc || state.elements.video.src; if (!raw) return null; try { const u = new URL(raw, window.location.href); @@ -213,16 +245,16 @@ export const initState = (c: HTMLElement): boolean => { const modeFromVideo = deriveModeFromVideoSrc(); if ( modeFromVideo && - modeFromVideo !== state.currentMode && - state.availableModes.includes(modeFromVideo) && - state.modeSources[modeFromVideo]?.token + modeFromVideo !== state.playback.currentMode && + state.playback.availableModes.includes(modeFromVideo) && + state.playback.modeSources[modeFromVideo]?.token ) { - state.currentMode = modeFromVideo; + state.playback.currentMode = modeFromVideo; } // parse skip segments from data attribute const segments = parseSegments(safeJsonUnknown(dataset(c, "segments"))); - state.parsedSegments = segments + state.skip.parsedSegments = segments .map((s) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 })) .filter((s) => s.end > s.start); diff --git a/static/player/timeline.ts b/static/player/timeline.ts index 3f2872c..16c16d6 100644 --- a/static/player/timeline.ts +++ b/static/player/timeline.ts @@ -8,10 +8,12 @@ let cachedDuration = 0; let cachedSeekableEnd = 0; const getDuration = (): number => - Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0; + Number.isFinite(state.elements.video.duration) && state.elements.video.duration > 0 + ? state.elements.video.duration + : 0; const getSeekableEnd = (): number => { - const ranges = state.video.seekable; + const ranges = state.elements.video.seekable; if (!ranges || ranges.length <= 0) return 0; const end = ranges.end(ranges.length - 1); return Number.isFinite(end) && end > 0 ? end : 0; @@ -27,9 +29,9 @@ export const timelineBounds = (): TimelineBounds => { return { start: 0, end: duration, duration }; } - if (state.video.seekable.length > 0) { - const seekableStart = state.video.seekable.start(0); - const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1); + if (state.elements.video.seekable.length > 0) { + const seekableStart = state.elements.video.seekable.start(0); + const seekableEnd = state.elements.video.seekable.end(state.elements.video.seekable.length - 1); if ( Number.isFinite(seekableStart) && Number.isFinite(seekableEnd) && @@ -92,23 +94,23 @@ export const absoluteTimeFromRatio = (ratio: number): number => { // finds the end of the buffered region containing currentTime export const getBufferedEnd = (): number => { - const currentTime = state.video.currentTime; + const currentTime = state.elements.video.currentTime; let end = 0; // first: find buffered range that contains current time - for (let i = 0; i < state.video.buffered.length; i++) { + for (let i = 0; i < state.elements.video.buffered.length; i++) { if ( - state.video.buffered.start(i) <= currentTime && - state.video.buffered.end(i) >= currentTime + state.elements.video.buffered.start(i) <= currentTime && + state.elements.video.buffered.end(i) >= currentTime ) { - end = state.video.buffered.end(i); + end = state.elements.video.buffered.end(i); break; } } // fallback: next buffered range after current time if (end === 0) { - for (let i = 0; i < state.video.buffered.length; i++) { - if (state.video.buffered.end(i) > currentTime) { - end = Math.max(end, state.video.buffered.end(i)); + for (let i = 0; i < state.elements.video.buffered.length; i++) { + if (state.elements.video.buffered.end(i) > currentTime) { + end = Math.max(end, state.elements.video.buffered.end(i)); } } } @@ -120,7 +122,7 @@ export const getBufferedEnd = (): number => { * Called on timeupdate, progress events, and seek. */ export const updateTimeline = (currentTime: number): void => { - const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state; + const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state.elements; const b = getBounds(); if (b.duration <= 0) { diff --git a/static/player/video.ts b/static/player/video.ts index 741d98d..08428bd 100644 --- a/static/player/video.ts +++ b/static/player/video.ts @@ -15,9 +15,9 @@ const destroyHLS = (): void => { export const destroyVideoSource = (): void => { destroyHLS(); - state.video.pause(); - state.video.removeAttribute("src"); - state.video.load(); + state.elements.video.pause(); + state.elements.video.removeAttribute("src"); + state.elements.video.load(); }; const shouldUseHLS = (type: string | undefined, url: string): boolean => { @@ -40,31 +40,31 @@ const shouldUseHLS = (type: string | undefined, url: string): boolean => { export const loadVideoSource = (url: string, type?: string): void => { if (!url) return; - const wasPlaying = !state.video.paused; - const prevDisplayTime = displayTimeFromAbsolute(state.video.currentTime); + const wasPlaying = !state.elements.video.paused; + const prevDisplayTime = displayTimeFromAbsolute(state.elements.video.currentTime); // Fully reset the element before setting a new source. destroyVideoSource(); if (shouldUseHLS(type, url) && Hls.isSupported()) { hls = new Hls(); - stopHLSProfile = attachHLSProfile(hls, state.video); + stopHLSProfile = attachHLSProfile(hls, state.elements.video); hls.loadSource(url); - hls.attachMedia(state.video); + hls.attachMedia(state.elements.video); } else { - state.video.src = url; - state.video.load(); + state.elements.video.src = url; + state.elements.video.load(); } // Try an eager seek; if metadata isn't ready yet, main.ts will restore via pendingSeekTime. - state.pendingSeekTime = prevDisplayTime; - if (state.video.readyState >= HTMLMediaElement.HAVE_METADATA) { + state.playback.pendingSeekTime = prevDisplayTime; + if (state.elements.video.readyState >= HTMLMediaElement.HAVE_METADATA) { invalidateBounds(); - state.video.currentTime = absoluteTimeFromDisplay(prevDisplayTime); - state.pendingSeekTime = null; + state.elements.video.currentTime = absoluteTimeFromDisplay(prevDisplayTime); + state.playback.pendingSeekTime = null; } if (wasPlaying) { - state.video.play().catch(() => undefined); + state.elements.video.play().catch(() => undefined); } };