From 88edf8717840d4115a2c513f7beafa1a78557996 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 15 May 2026 02:22:43 +0200 Subject: [PATCH] fix: episode transition, progress save, and seek alignment --- internal/playback/handler/handler.go | 9 ++++--- static/player/episodes/nav.ts | 10 +++++--- static/player/main.ts | 38 +++++++++++++++++++--------- static/player/progress.ts | 2 +- static/player/state.ts | 3 +++ 5 files changed, 42 insertions(+), 20 deletions(-) diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index c5b9376..2d8c9dc 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -131,10 +131,11 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "mode_sources": modeSources, - "available_modes": availableModes, - "segments": segments, - "episode_title": episodeTitle, + "mode_sources": modeSources, + "available_modes": availableModes, + "start_time_seconds": watchData["StartTimeSeconds"], + "segments": segments, + "episode_title": episodeTitle, }) } diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index ce3f5b4..1fb770a 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -55,14 +55,19 @@ export const goToNextEpisode = async (): Promise => { return; } + state.currentEpisode = String(nextEp); + state.currentMode = fallback; + // 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); + // load new video (keep preferences) const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'; state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`; state.video.load(); if (!state.video.paused) state.video.play().catch(() => {}); - state.currentEpisode = String(nextEp); - state.currentMode = fallback; state.pendingSeekTime = null; state.completionSent = false; state.completionAttempts = 0; @@ -103,7 +108,6 @@ export const goToNextEpisode = async (): Promise => { const url = new URL(window.location.href); url.searchParams.set('ep', String(nextEp)); history.pushState(null, '', url.toString()); - state.transitionEpisode = null; } catch { sessionStorage.setItem('mal:autoplay-next', 'true'); const url = new URL(window.location.href); diff --git a/static/player/main.ts b/static/player/main.ts index ca3eac0..55e0491 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -11,7 +11,12 @@ import { goToNextEpisode } from './episodes/nav'; import { resolveActiveSegments, renderSegments } from './skip/segments'; import { setupThumbnails } from './episodes/thumbnails'; import { markEpisodeTransition, setupProgress } from './progress'; -import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline'; +import { + absoluteTimeFromDisplay, + absoluteTimeFromRatio, + getBounds, + displayTimeFromAbsolute, +} from './timeline'; import { formatTime } from './controls'; let initialized = false; // prevent double init on htmx swaps @@ -98,15 +103,18 @@ const initPlayer = (): void => { renderSegments(); // resume from saved position - const startTime = Number(container.dataset.startTimeSeconds ?? '0'); - if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) { - state.video.currentTime = startTime; + const startTime = state.startTimeSeconds; + if (startTime > 0 && state.video.currentTime <= 0.5 && getBounds().duration > startTime) { + state.video.currentTime = absoluteTimeFromDisplay(startTime); } // resume after mode switch if (state.pendingSeekTime !== null) { - state.video.currentTime = state.pendingSeekTime; + state.video.currentTime = absoluteTimeFromDisplay(state.pendingSeekTime); state.pendingSeekTime = null; } + if (state.transitionEpisode === Number.parseInt(state.currentEpisode, 10)) { + state.transitionEpisode = null; + } // autoplay if not already playing (inline script may have already called play()) if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {}); @@ -184,14 +192,20 @@ const initPlayer = (): void => { updateSkipButton(state.video.currentTime); }); - // track episode transitions from external links - container.addEventListener('click', e => { - const anchor = (e.target as Node).parentElement?.closest('a[href]'); + // track next-episode links outside the player so they start fresh after finishing an episode + document.addEventListener('click', e => { + const target = e.target; + if (!(target instanceof Element)) return; + const anchor = target.closest('a[href]'); if (!(anchor instanceof HTMLAnchorElement)) return; - const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean); - if (parts[0] === 'watch' && Number.parseInt(parts[2], 10) > 0) { - markEpisodeTransition(Number.parseInt(parts[2], 10)); - } + 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.malID) return; + const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10); + const currentEpisode = Number.parseInt(state.currentEpisode, 10); + if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode); }); state.video.addEventListener('click', showControls); diff --git a/static/player/progress.ts b/static/player/progress.ts index b95ab42..d5d4a30 100644 --- a/static/player/progress.ts +++ b/static/player/progress.ts @@ -21,7 +21,7 @@ const sendBeacon = (payload: string) => { * Debounced: skips if within 5s of last save for same episode. */ export const saveProgress = async (): Promise => { - if (!state.malID || state.video.currentTime < 1) return; + if (state.transitionEpisode !== null || !state.malID || state.video.currentTime < 1) return; const episode = Number.parseInt(state.currentEpisode, 10); if (!episode) return; diff --git a/static/player/state.ts b/static/player/state.ts index 4a59095..18f293d 100644 --- a/static/player/state.ts +++ b/static/player/state.ts @@ -17,6 +17,7 @@ export interface PlayerState { malID: number; streamURL: string; initialStreamToken: string; + startTimeSeconds: number; shouldAutoPlay: boolean; parsedSegments: SkipSegment[]; activeSegments: ActiveSegment[]; @@ -56,6 +57,7 @@ export const state: PlayerState = { malID: 0, streamURL: '/watch/proxy/stream', initialStreamToken: '', + startTimeSeconds: 0, shouldAutoPlay: false, parsedSegments: [], activeSegments: [], @@ -102,6 +104,7 @@ export const initState = (c: HTMLElement): void => { state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10); state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream'; state.initialStreamToken = dataset(c, 'streamToken') || ''; + state.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'; sessionStorage.removeItem('mal:autoplay-next');