From ff54e9c5db9749e8a5e0ca3e246daeee6e301c1d Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 16 Jun 2026 10:38:16 +0200 Subject: [PATCH] refactor: group episode state --- static/player/episodes/complete.ts | 18 ++-- static/player/episodes/nav.ts | 56 ++++++------- static/player/episodes/thumbnails.ts | 4 +- static/player/episodes/ui.ts | 14 ++-- static/player/main.ts | 120 ++++++++++++++------------- static/player/progress.ts | 64 +++++++------- 6 files changed, 143 insertions(+), 133 deletions(-) diff --git a/static/player/episodes/complete.ts b/static/player/episodes/complete.ts index 4c94753..ac1e25b 100644 --- a/static/player/episodes/complete.ts +++ b/static/player/episodes/complete.ts @@ -1,21 +1,21 @@ import { state } from "../state"; export const completeAnime = async (episodeNumber: number): Promise => { - if (state.completionSent || !state.malID || !episodeNumber) return; - state.completionSent = true; + if (state.episode.completionSent || !state.episode.malID || !episodeNumber) return; + state.episode.completionSent = true; try { const res = await fetch("/api/watch-complete", { method: "POST", headers: { "Content-Type": "application/json" }, keepalive: true, - body: JSON.stringify({ mal_id: state.malID, episode: episodeNumber }), + body: JSON.stringify({ mal_id: state.episode.malID, episode: episodeNumber }), }); if (!res.ok) { - state.completionSent = false; - if (state.completionAttempts < 2) { - state.completionAttempts++; + state.episode.completionSent = false; + if (state.episode.completionAttempts < 2) { + state.episode.completionAttempts++; setTimeout(() => completeAnime(episodeNumber), 1000); } return; @@ -30,9 +30,9 @@ export const completeAnime = async (episodeNumber: number): Promise => { trigger.appendChild(caret); } } catch { - state.completionSent = false; - if (state.completionAttempts < 2) { - state.completionAttempts++; + state.episode.completionSent = false; + if (state.episode.completionAttempts < 2) { + state.episode.completionAttempts++; setTimeout(() => completeAnime(episodeNumber), 1000); } } diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index 9bb0d89..e3bcf06 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -15,7 +15,7 @@ import { loadVideoSource } from "../video"; * Fetches episode data from API, updates player state and URL. */ export const goToNextEpisode = async (): Promise => { - const currentEp = Number.parseInt(state.currentEpisode, 10); + const currentEp = Number.parseInt(state.episode.current, 10); if (!currentEp) return; const navigateToEpisode = (episode: number): void => { @@ -30,8 +30,8 @@ export const goToNextEpisode = async (): Promise => { }; // final episode: trigger completion flow or just stop if airing - if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) { - if (!state.isAiring) { + if (state.episode.total > 0 && currentEp >= state.episode.total) { + if (!state.episode.isAiring) { void completeAnime(currentEp); } showEndState(); @@ -49,7 +49,7 @@ export const goToNextEpisode = async (): Promise => { try { const res = await fetch( - `/api/watch/episode/${state.malID}/${nextEp}?mode=${encodeURIComponent(state.currentMode)}`, + `/api/watch/episode/${state.episode.malID}/${nextEp}?mode=${encodeURIComponent(state.playback.currentMode)}`, ); if (!res.ok) { // fallback: full page navigation @@ -60,20 +60,20 @@ export const goToNextEpisode = async (): Promise => { const data = await res.json(); // update state with new episode data - state.modeSources = data.mode_sources ?? {}; + state.playback.modeSources = data.mode_sources ?? {}; const backendMode = typeof data.initial_mode === "string" ? data.initial_mode : ""; - const fallback = state.modeSources[backendMode]?.token + const fallback = state.playback.modeSources[backendMode]?.token ? backendMode - : state.availableModes.find((m) => state.modeSources[m]?.token); + : state.playback.availableModes.find((m) => state.playback.modeSources[m]?.token); if (!fallback) { fallbackToEpisodeNavigation(nextEp); return; } - state.currentEpisode = String(nextEp); - state.currentMode = fallback; - state.endedProgressSaved = false; + state.episode.current = String(nextEp); + state.playback.currentMode = fallback; + state.episode.endedProgressSaved = false; hideEndState(); @@ -83,34 +83,34 @@ export const goToNextEpisode = async (): Promise => { }); } // The progress reset is sent asynchronously, so do not trust the fetch to observe it first. - state.startTimeSeconds = 0; - state.container.dataset.currentEpisode = state.currentEpisode; - state.container.dataset.startTimeSeconds = String(state.startTimeSeconds); + 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.modeSources[fallback]; - const nextSourceURL = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(source.token)}${source.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`; + 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.video.paused) { - state.video.play().catch(() => undefined); + if (!state.elements.video.paused) { + state.elements.video.play().catch(() => undefined); } - state.pendingSeekTime = null; - state.completionSent = false; - state.completionAttempts = 0; - state.activeSubtitles = []; + state.playback.pendingSeekTime = null; + state.episode.completionSent = false; + state.episode.completionAttempts = 0; + state.subtitles.activeCues = []; // update UI updateSubtitleOptions(); updateQualityOptions(); updateModeButtons(); - updateOverlay(state.currentEpisode, data.episode_title ?? ""); + updateOverlay(state.episode.current, data.episode_title ?? ""); void hydrateAlternateMode(); // update skip segments if (data.segments?.length) { - state.parsedSegments = data.segments + 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(); @@ -118,18 +118,18 @@ export const goToNextEpisode = async (): Promise => { } // highlight new episode in list/grid - state.episodeList + state.elements.episodeList ?.querySelectorAll("[data-episode-id]") .forEach((el) => el.classList.remove("bg-accent/20")); - const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`); + const newListEl = state.elements.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`); newListEl?.classList.add("bg-accent/20"); - if (state.episodeGrid) { - state.episodeGrid.querySelectorAll("[data-episode-id]").forEach((el) => { + 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.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`); + const newGridEl = state.elements.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`); newGridEl?.classList.add("bg-accent/20", "ring-2", "ring-accent", "text-accent"); } diff --git a/static/player/episodes/thumbnails.ts b/static/player/episodes/thumbnails.ts index 9408d79..52aff9e 100644 --- a/static/player/episodes/thumbnails.ts +++ b/static/player/episodes/thumbnails.ts @@ -5,10 +5,10 @@ import { state } from "../state"; * Injects images into episode cards, replaces placeholder. */ export const setupThumbnails = (): void => { - const episodeList = state.episodeList; + const episodeList = state.elements.episodeList; if (!episodeList) return; - fetch(`/api/watch/thumbnails/${state.malID}`) + fetch(`/api/watch/thumbnails/${state.episode.malID}`) .then((res) => res.json()) .then((data: { mal_id: number; url: string; title?: string }[]) => { data.forEach((item) => { diff --git a/static/player/episodes/ui.ts b/static/player/episodes/ui.ts index ec71cf7..133e9cb 100644 --- a/static/player/episodes/ui.ts +++ b/static/player/episodes/ui.ts @@ -19,16 +19,16 @@ export const isAutoplayEnabled = (): boolean => * Updates video overlay text (shown briefly on episode change). */ export const updateOverlay = (episode: string, title: string): void => { - if (!state.videoOverlay) return; - const p = state.videoOverlay.querySelector("p"); + if (!state.elements.videoOverlay) return; + const p = state.elements.videoOverlay.querySelector("p"); if (!p) return; p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`; }; // helper: get all episode elements from grid and list const getEpisodeEls = () => { - const grid = state.episodeGrid; - const list = state.episodeList; + const grid = state.elements.episodeGrid; + const list = state.elements.episodeList; return { gridEls: grid ? Array.from(grid.querySelectorAll("[data-episode-id]")) : [], listEls: list ? Array.from(list.querySelectorAll("[data-episode-id]")) : [], @@ -47,8 +47,8 @@ export const updateEpisodeHighlight = (num: number): void => { ); // apply new highlight - const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`); - const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`); + 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"); // scroll into view @@ -75,7 +75,7 @@ export const switchEpisodeRange = (idx: number): void => { label.textContent = `${String(start).padStart(2, "0")}-${String(end).padStart(2, "0")}`; // show/hide episodes in range - state.episodeGrid?.querySelectorAll("[data-episode-id]").forEach((el) => { + state.elements.episodeGrid?.querySelectorAll("[data-episode-id]").forEach((el) => { const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? "0", 10); el.classList.toggle("hidden", n < start || n > end); }); diff --git a/static/player/main.ts b/static/player/main.ts index 1aad499..755f110 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -35,18 +35,18 @@ const isClosableDropdown = (el: Element | null): el is ClosableDropdown => { }; const hidePreviewPopover = (): void => { - if (!state.previewPopover) return; - state.previewPopover.classList.add("hidden"); - state.previewPopover.classList.add("opacity-0"); - state.previewPopover.classList.remove("opacity-100"); - state.previewPopover.style.left = ""; + if (!state.elements.previewPopover) return; + state.elements.previewPopover.classList.add("hidden"); + state.elements.previewPopover.classList.add("opacity-0"); + state.elements.previewPopover.classList.remove("opacity-100"); + state.elements.previewPopover.style.left = ""; }; const showPreviewPopover = (): void => { - if (!state.previewPopover) return; - state.previewPopover.classList.remove("hidden"); - state.previewPopover.classList.remove("opacity-0"); - state.previewPopover.classList.add("opacity-100"); + if (!state.elements.previewPopover) return; + state.elements.previewPopover.classList.remove("hidden"); + state.elements.previewPopover.classList.remove("opacity-0"); + state.elements.previewPopover.classList.add("opacity-100"); }; const teardownPlayer = (): void => { @@ -58,8 +58,10 @@ const teardownPlayer = (): void => { // updates time preview on progress bar hover const updatePreviewUI = (ratio: number): void => { - const progressWrap = state.container.querySelector("[data-progress-wrap]") as HTMLElement | null; - if (!progressWrap || !state.previewPopover || !state.previewTime) { + const progressWrap = state.elements.container.querySelector( + "[data-progress-wrap]", + ) as HTMLElement | null; + if (!progressWrap || !state.elements.previewPopover || !state.elements.previewTime) { hidePreviewPopover(); return; } @@ -70,7 +72,9 @@ const updatePreviewUI = (ratio: number): void => { } // show time for hovered position - state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration))); + state.elements.previewTime.textContent = formatTime( + Math.max(0, Math.min(b.duration, ratio * b.duration)), + ); const barWidth = progressWrap.clientWidth; if (barWidth <= 0) { @@ -80,8 +84,8 @@ const updatePreviewUI = (ratio: number): void => { showPreviewPopover(); // clamp to stay within bar bounds - const popoverWidth = state.previewPopover.offsetWidth || 72; - state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`; + const popoverWidth = state.elements.previewPopover.offsetWidth || 72; + state.elements.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`; }; const initPlayer = (): void => { @@ -105,11 +109,11 @@ const initPlayer = (): void => { const scrubToPointer = (clientX: number, shouldShowControls: boolean): void => { if (!progressWrap) return; const rect = progressWrap.getBoundingClientRect(); - state.video.currentTime = absoluteTimeFromRatio( + state.elements.video.currentTime = absoluteTimeFromRatio( Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)), ); - updateTimeline(state.video.currentTime); - updateSkipButton(state.video.currentTime); + updateTimeline(state.elements.video.currentTime); + updateSkipButton(state.elements.video.currentTime); if (shouldShowControls) { showControls(); } @@ -117,10 +121,10 @@ const initPlayer = (): void => { // build video src from mode, token, and saved quality preference const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best"; - const streamToken = state.modeSources[state.currentMode]?.token; + const streamToken = state.playback.modeSources[state.playback.currentMode]?.token; if (streamToken) { - const source = state.modeSources[state.currentMode]; - const url = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${source?.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`; + 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); } @@ -139,9 +143,9 @@ const initPlayer = (): void => { setupAutoplayButton(); updateAutoSkipButton(); showControls(); - if (state.modeSwitchedFrom === "dub" && state.currentMode === "sub") { + if (state.playback.modeSwitchedFrom === "dub" && state.playback.currentMode === "sub") { window.showToast?.({ - message: `Episode ${state.currentEpisode} is only available in sub, switched from dub.`, + message: `Episode ${state.episode.current} is only available in sub, switched from dub.`, }); } @@ -155,7 +159,7 @@ const initPlayer = (): void => { renderSegments(); // Resume from saved position - const startTime = state.startTimeSeconds; + const startTime = state.playback.startTimeSeconds; const bounds = getBounds(); const resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0; const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2; @@ -176,31 +180,31 @@ const initPlayer = (): void => { if (resumeAfterModeSwitch !== null) { const clamped = bounds.duration > 0 ? Math.min(resumeAfterModeSwitch, bounds.duration) : 0; if (clamped > 0) { - state.video.currentTime = clamped; + state.elements.video.currentTime = clamped; } } - if (startTime > 0 && state.video.currentTime <= 2) { + if (startTime > 0 && state.elements.video.currentTime <= 2) { if (resumeTime > 0) { - state.video.currentTime = absoluteTimeFromDisplay(resumeTime); + state.elements.video.currentTime = absoluteTimeFromDisplay(resumeTime); } } // resume after mode switch - if (state.pendingSeekTime !== null) { - state.video.currentTime = absoluteTimeFromDisplay(state.pendingSeekTime); - state.pendingSeekTime = null; + if (state.playback.pendingSeekTime !== null) { + state.elements.video.currentTime = absoluteTimeFromDisplay(state.playback.pendingSeekTime); + state.playback.pendingSeekTime = null; } - if (state.transitionEpisode === Number.parseInt(state.currentEpisode, 10)) { - state.transitionEpisode = null; + if (state.episode.transitionEpisode === Number.parseInt(state.episode.current, 10)) { + state.episode.transitionEpisode = null; } // autoplay if not already playing (inline script may have already called play()) // but don't autoplay if we've reached the end - if (!isAtEnd && (state.shouldAutoPlay || state.video.paused)) { - state.video.play().catch(() => undefined); + if (!isAtEnd && (state.playback.shouldAutoPlay || state.elements.video.paused)) { + state.elements.video.play().catch(() => undefined); } - updateTimeline(state.video.currentTime); - updateSkipButton(state.video.currentTime); + updateTimeline(state.elements.video.currentTime); + updateSkipButton(state.elements.video.currentTime); // Apply end-state visuals if we resumed at the end if (isAtEnd) { @@ -208,12 +212,12 @@ const initPlayer = (): void => { } }; - state.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal }); - if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + state.elements.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal }); + if (state.elements.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { onLoadedMetadata(); } - state.video.addEventListener( + state.elements.video.addEventListener( "waiting", () => { if (loading) { @@ -222,7 +226,7 @@ const initPlayer = (): void => { }, { signal }, ); - state.video.addEventListener( + state.elements.video.addEventListener( "playing", () => { if (loading) { @@ -232,31 +236,31 @@ const initPlayer = (): void => { { signal }, ); // update progress bar during buffering - state.video.addEventListener( + state.elements.video.addEventListener( "progress", () => { - updateTimeline(state.video.currentTime); + updateTimeline(state.elements.video.currentTime); }, { signal }, ); // main loop: update progress, subtitles, skip buttons - state.video.addEventListener( + state.elements.video.addEventListener( "timeupdate", () => { - updateTimeline(state.video.currentTime); - updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime)); - updateSkipButton(state.video.currentTime); + updateTimeline(state.elements.video.currentTime); + updateSubtitleRender(displayTimeFromAbsolute(state.elements.video.currentTime)); + updateSkipButton(state.elements.video.currentTime); // Restore visibility if we've moved away from the end - if (state.video.currentTime < state.video.duration - 1) { + if (state.elements.video.currentTime < state.elements.video.duration - 1) { hideEndState(); } }, { signal }, ); - state.video.addEventListener( + state.elements.video.addEventListener( "ended", async () => { await saveEndedProgress(); @@ -271,7 +275,7 @@ const initPlayer = (): void => { (e) => { // ignore right/middle click if ("button" in e && e.button !== 0) return; - state.isScrubbing = true; + state.ui.isScrubbing = true; try { (e.currentTarget as HTMLElement).setPointerCapture((e as PointerEvent).pointerId); } catch (e) { @@ -298,7 +302,7 @@ const initPlayer = (): void => { () => { // ensure we finish the seek even if no window mousemove fired if (!progressWrap) return; - state.isScrubbing = false; + state.ui.isScrubbing = false; }, { signal }, ); @@ -307,7 +311,7 @@ const initPlayer = (): void => { window.addEventListener( "pointermove", (e) => { - if (!state.isScrubbing || !progressWrap) return; + if (!state.ui.isScrubbing || !progressWrap) return; scrubToPointer(e.clientX, false); }, { signal }, @@ -325,15 +329,15 @@ const initPlayer = (): void => { 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.malID) 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.currentEpisode, 10); + const currentEpisode = Number.parseInt(state.episode.current, 10); if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode); }, { signal }, ); - state.video.addEventListener("click", showControls, { 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; @@ -349,17 +353,17 @@ const initPlayer = (): void => { const val = searchInput.value.replace(/\D/g, ""); if (!val) { // clear: jump to current episode range - const cur = Number.parseInt(state.currentEpisode, 10); + const cur = Number.parseInt(state.episode.current, 10); switchEpisodeRange(Math.floor((cur - 1) / 100)); updateEpisodeHighlight(cur); return; } const ep = Number.parseInt(val, 10); if (!ep || ep <= 0) return; - const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500; + const maxEp = state.episode.total > 0 ? state.episode.total : 500; const clamped = Math.min(ep, maxEp); searchInput.value = String(clamped); - if (state.episodeGrid) { + if (state.elements.episodeGrid) { switchEpisodeRange(Math.floor((clamped - 1) / 100)); updateEpisodeHighlight(clamped); } @@ -386,8 +390,8 @@ const initPlayer = (): void => { } // initial range for large episode lists - if (state.episodeGrid && state.totalEpisodes > 100) { - switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100)); + if (state.elements.episodeGrid && state.episode.total > 100) { + switchEpisodeRange(Math.floor((Number.parseInt(state.episode.current, 10) - 1) / 100)); } setupThumbnails(); diff --git a/static/player/progress.ts b/static/player/progress.ts index 93d8aef..5f4a9fc 100644 --- a/static/player/progress.ts +++ b/static/player/progress.ts @@ -12,7 +12,7 @@ const snapToEnd = (time: number): number => { // builds JSON payload for progress API const buildPayload = (episode: number, seconds: number): string => JSON.stringify({ - mal_id: state.malID, + mal_id: state.episode.malID, episode, time_seconds: seconds, }); @@ -26,7 +26,7 @@ const sendBeacon = (payload: string): boolean => { let saveProgressInFlight: Promise | null = null; -const currentProgressTime = (): number => displayTimeFromAbsolute(state.video.currentTime); +const currentProgressTime = (): number => displayTimeFromAbsolute(state.elements.video.currentTime); /** * Saves current progress to backend. @@ -42,17 +42,18 @@ export const saveProgress = async ( } const request = (async (): Promise => { - if (state.endedProgressSaved && !force) return; - if (state.transitionEpisode !== null || !state.malID || progressSeconds < 1) return; - const episode = Number.parseInt(state.currentEpisode, 10); + if (state.episode.endedProgressSaved && !force) return; + if (state.episode.transitionEpisode !== null || !state.episode.malID || progressSeconds < 1) + return; + const episode = Number.parseInt(state.episode.current, 10); if (!episode) return; const savedTime = snapToEnd(progressSeconds); // skip if recently saved, unless forced if ( !force && - state.lastSavedProgress.episode === state.currentEpisode && - Math.abs(state.lastSavedProgress.seconds - savedTime) < 5 + state.episode.lastSavedProgress.episode === state.episode.current && + Math.abs(state.episode.lastSavedProgress.seconds - savedTime) < 5 ) { return; } @@ -65,8 +66,8 @@ export const saveProgress = async ( body: payload, }); if (!res.ok) return; - state.lastSavedProgress = { - episode: state.currentEpisode, + state.episode.lastSavedProgress = { + episode: state.episode.current, seconds: savedTime, }; } catch (e) { @@ -86,9 +87,9 @@ export const saveProgress = async ( // schedules periodic save every 30s during playback const scheduleProgressSave = (): void => { - if (state.progressSaveTimer !== undefined) return; - state.progressSaveTimer = window.setTimeout(() => { - state.progressSaveTimer = undefined; + if (state.timers.progressSaveTimer !== undefined) return; + state.timers.progressSaveTimer = window.setTimeout(() => { + state.timers.progressSaveTimer = undefined; saveProgress(); }, 30000); }; @@ -98,12 +99,12 @@ const scheduleProgressSave = (): void => { * Uses beacon for reliability on page unload. */ export const markEpisodeTransition = (episodeNumber: number): void => { - if (!state.malID || !episodeNumber) return; - if (state.progressSaveTimer !== undefined) { - window.clearTimeout(state.progressSaveTimer); - state.progressSaveTimer = undefined; + if (!state.episode.malID || !episodeNumber) return; + if (state.timers.progressSaveTimer !== undefined) { + window.clearTimeout(state.timers.progressSaveTimer); + state.timers.progressSaveTimer = undefined; } - state.transitionEpisode = episodeNumber; + state.episode.transitionEpisode = episodeNumber; const payload = buildPayload(episodeNumber, 0); // beacon falls back to fetch with keepalive if (!sendBeacon(payload)) { @@ -121,44 +122,49 @@ export const markEpisodeTransition = (episodeNumber: number): void => { */ export const setupProgress = (): void => { // periodic save during playback - state.video.addEventListener("timeupdate", () => { + state.elements.video.addEventListener("timeupdate", () => { scheduleProgressSave(); }); // immediate save on pause - state.video.addEventListener("pause", () => { - window.clearTimeout(state.progressSaveTimer); - state.progressSaveTimer = undefined; - if (state.endedProgressSaved) return; + state.elements.video.addEventListener("pause", () => { + window.clearTimeout(state.timers.progressSaveTimer); + state.timers.progressSaveTimer = undefined; + if (state.episode.endedProgressSaved) return; // If we're at the very end, force a save to ensure we don't skip the last frame const isAtEnd = - state.video.duration > 0 && Math.abs(state.video.currentTime - state.video.duration) < 1.5; + state.elements.video.duration > 0 && + Math.abs(state.elements.video.currentTime - state.elements.video.duration) < 1.5; saveProgress(isAtEnd); }); // save after scrubbing window.addEventListener("mouseup", () => { - state.isScrubbing = false; - if (state.endedProgressSaved) return; + state.ui.isScrubbing = false; + if (state.episode.endedProgressSaved) return; saveProgress(); }); // save on page close window.addEventListener("beforeunload", () => { - if (state.endedProgressSaved || state.transitionEpisode !== null || !state.malID) { + if ( + state.episode.endedProgressSaved || + state.episode.transitionEpisode !== null || + !state.episode.malID + ) { return; } - const episode = Number.parseInt(state.currentEpisode, 10); + const episode = Number.parseInt(state.episode.current, 10); if (!episode) return; - const time = displayTimeFromAbsolute(state.video.currentTime); + const time = displayTimeFromAbsolute(state.elements.video.currentTime); sendBeacon(buildPayload(episode, snapToEnd(time))); }); }; export const saveEndedProgress = async (): Promise => { const duration = getBounds().duration; - state.endedProgressSaved = true; + state.episode.endedProgressSaved = true; await saveProgress(true, duration > 0 ? duration : currentProgressTime()); };