From 8c3ff3df94da58ce0541ca9b9e4e5c623d36cde4 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 29 May 2026 00:04:17 +0200 Subject: [PATCH] feat: add end-state detection and prevent airing auto-complete --- static/player/episodes/nav.ts | 19 ++++++++--- static/player/main.ts | 34 +++++++++++++++----- static/player/progress.ts | 60 +++++++++++++++++++++++++++-------- 3 files changed, 87 insertions(+), 26 deletions(-) diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index ac15678..5dfadb3 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -1,4 +1,4 @@ -import { state } from "../state"; +import { state, showEndState, hideEndState } from "../state"; import type { SkipSegment } from "../types"; import { resolveActiveSegments, renderSegments } from "../skip/segments"; import { updateSubtitleOptions } from "../subtitles"; @@ -7,6 +7,7 @@ import { updateModeButtons } from "../mode"; import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from "./ui"; import { markEpisodeTransition } from "../progress"; import { safeLocalStorage } from "../storage"; +import { completeAnime } from "./complete"; /** * Handles video end: either marks complete or loads next episode. @@ -16,14 +17,20 @@ export const goToNextEpisode = async (): Promise => { const currentEp = Number.parseInt(state.currentEpisode, 10); if (!currentEp) return; - // final episode: trigger completion flow + // final episode: trigger completion flow or just stop if airing if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) { - import("./complete").then((m) => m.completeAnime(currentEp)); + if (!state.isAiring) { + void completeAnime(currentEp); + } + showEndState(); return; } // skip if autoplay disabled - if (!isAutoplayEnabled()) return; + if (!isAutoplayEnabled()) { + showEndState(); + return; + } const nextEp = currentEp + 1; markEpisodeTransition(nextEp); @@ -61,6 +68,10 @@ export const goToNextEpisode = async (): Promise => { state.currentEpisode = String(nextEp); state.currentMode = fallback; + state.endedProgressSaved = false; + + hideEndState(); + if (data.mode_switched_from === "dub" && fallback === "sub") { window.showToast?.({ message: `Episode ${nextEp} is only available in sub, switched from dub.`, diff --git a/static/player/main.ts b/static/player/main.ts index 1d2a79d..99abcc1 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -1,4 +1,4 @@ -import { state, initState } from "./state"; +import { state, initState, showEndState, hideEndState } from "./state"; import { invalidateBounds, updateTimeline } from "./timeline"; import { setupControls, showControls } from "./controls"; import { setupKeyboard } from "./keyboard"; @@ -11,7 +11,7 @@ import { goToNextEpisode } from "./episodes/nav"; import { resolveActiveSegments, renderSegments } from "./skip/segments"; import { setupSegmentEditor } from "./skip/editor"; import { setupThumbnails } from "./episodes/thumbnails"; -import { markEpisodeTransition, setupProgress } from "./progress"; +import { markEpisodeTransition, saveEndedProgress, setupProgress } from "./progress"; import { safeLocalStorage } from "./storage"; import { absoluteTimeFromDisplay, @@ -137,10 +137,16 @@ const initPlayer = (): void => { resolveActiveSegments(); renderSegments(); - // resume from saved position + // Resume from saved position const startTime = state.startTimeSeconds; - if (startTime > 0 && state.video.currentTime <= 0.5 && getBounds().duration > startTime) { - state.video.currentTime = absoluteTimeFromDisplay(startTime); + 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; + + if (startTime > 0 && state.video.currentTime <= 2) { + if (resumeTime > 0) { + state.video.currentTime = absoluteTimeFromDisplay(resumeTime); + } } // resume after mode switch if (state.pendingSeekTime !== null) { @@ -151,12 +157,18 @@ const initPlayer = (): void => { state.transitionEpisode = null; } // autoplay if not already playing (inline script may have already called play()) - if (state.shouldAutoPlay || state.video.paused) { + // but don't autoplay if we've reached the end + if (!isAtEnd && (state.shouldAutoPlay || state.video.paused)) { state.video.play().catch(() => undefined); } updateTimeline(state.video.currentTime); updateSkipButton(state.video.currentTime); + + // Apply end-state visuals if we resumed at the end + if (isAtEnd) { + showEndState(); + } }; state.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal }); @@ -200,14 +212,20 @@ const initPlayer = (): void => { updateTimeline(state.video.currentTime); updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime)); updateSkipButton(state.video.currentTime); + + // Restore visibility if we've moved away from the end + if (state.video.currentTime < state.video.duration - 1) { + hideEndState(); + } }, { signal }, ); state.video.addEventListener( "ended", - () => { - goToNextEpisode(); + async () => { + await saveEndedProgress(); + await goToNextEpisode(); }, { signal }, ); diff --git a/static/player/progress.ts b/static/player/progress.ts index bf80f6d..5b55b45 100644 --- a/static/player/progress.ts +++ b/static/player/progress.ts @@ -1,8 +1,16 @@ import { state } from "./state"; -import { displayTimeFromAbsolute } from "./timeline"; +import { displayTimeFromAbsolute, getBounds } from "./timeline"; + +const snapToEnd = (time: number): number => { + const duration = getBounds().duration; + if (duration > 0 && Math.abs(time - duration) < 2) { + return duration; + } + return time; +}; // builds JSON payload for progress API -const buildPayload = (episode: number, seconds: number) => +const buildPayload = (episode: number, seconds: number): string => JSON.stringify({ mal_id: state.malID, episode, @@ -10,7 +18,7 @@ const buildPayload = (episode: number, seconds: number) => }); // sends progress via beacon (survives page unload) -const sendBeacon = (payload: string) => { +const sendBeacon = (payload: string): boolean => { if (!navigator.sendBeacon) return false; navigator.sendBeacon("/api/watch-progress", new Blob([payload], { type: "application/json" })); return true; @@ -18,28 +26,38 @@ const sendBeacon = (payload: string) => { let saveProgressInFlight: Promise | null = null; +const currentProgressTime = (): number => displayTimeFromAbsolute(state.video.currentTime); + /** * Saves current progress to backend. * Debounced: skips if within 5s of last save for same episode. */ -export const saveProgress = async (): Promise => { - if (saveProgressInFlight) return saveProgressInFlight; +export const saveProgress = async ( + force: boolean = false, + progressSeconds: number = currentProgressTime(), +): Promise => { + if (saveProgressInFlight) { + if (!force) return saveProgressInFlight; + await saveProgressInFlight; + } const request = (async (): Promise => { - if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return; + if (state.endedProgressSaved && !force) return; + if (state.transitionEpisode !== null || !state.malID || progressSeconds < 1) return; const episode = Number.parseInt(state.currentEpisode, 10); if (!episode) return; - const safeTime = displayTimeFromAbsolute(state.video.currentTime); - // skip if recently saved + const savedTime = snapToEnd(progressSeconds); + // skip if recently saved, unless forced if ( + !force && state.lastSavedProgress.episode === state.currentEpisode && - Math.abs(state.lastSavedProgress.seconds - safeTime) < 5 + Math.abs(state.lastSavedProgress.seconds - savedTime) < 5 ) { return; } - const payload = buildPayload(episode, safeTime); + const payload = buildPayload(episode, savedTime); try { const res = await fetch("/api/watch-progress", { method: "POST", @@ -49,7 +67,7 @@ export const saveProgress = async (): Promise => { if (!res.ok) return; state.lastSavedProgress = { episode: state.currentEpisode, - seconds: safeTime, + seconds: savedTime, }; } catch {} })(); @@ -109,22 +127,36 @@ export const setupProgress = (): void => { state.video.addEventListener("pause", () => { window.clearTimeout(state.progressSaveTimer); state.progressSaveTimer = undefined; - saveProgress(); + if (state.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; + + saveProgress(isAtEnd); }); // save after scrubbing window.addEventListener("mouseup", () => { state.isScrubbing = false; + if (state.endedProgressSaved) return; saveProgress(); }); // save on page close window.addEventListener("beforeunload", () => { - if (state.transitionEpisode !== null || state.completionSent || !state.malID) { + if (state.endedProgressSaved || state.transitionEpisode !== null || !state.malID) { return; } const episode = Number.parseInt(state.currentEpisode, 10); if (!episode) return; - sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime))); + const time = displayTimeFromAbsolute(state.video.currentTime); + sendBeacon(buildPayload(episode, snapToEnd(time))); }); }; + +export const saveEndedProgress = async (): Promise => { + const duration = getBounds().duration; + state.endedProgressSaved = true; + await saveProgress(true, duration > 0 ? duration : currentProgressTime()); +};