fix: episode transition, progress save, and seek alignment
This commit is contained in:
@@ -131,10 +131,11 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"mode_sources": modeSources,
|
"mode_sources": modeSources,
|
||||||
"available_modes": availableModes,
|
"available_modes": availableModes,
|
||||||
"segments": segments,
|
"start_time_seconds": watchData["StartTimeSeconds"],
|
||||||
"episode_title": episodeTitle,
|
"segments": segments,
|
||||||
|
"episode_title": episodeTitle,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,14 +55,19 @@ export const goToNextEpisode = async (): Promise<void> => {
|
|||||||
return;
|
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)
|
// load new video (keep preferences)
|
||||||
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
|
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.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
||||||
state.video.load();
|
state.video.load();
|
||||||
if (!state.video.paused) state.video.play().catch(() => {});
|
if (!state.video.paused) state.video.play().catch(() => {});
|
||||||
|
|
||||||
state.currentEpisode = String(nextEp);
|
|
||||||
state.currentMode = fallback;
|
|
||||||
state.pendingSeekTime = null;
|
state.pendingSeekTime = null;
|
||||||
state.completionSent = false;
|
state.completionSent = false;
|
||||||
state.completionAttempts = 0;
|
state.completionAttempts = 0;
|
||||||
@@ -103,7 +108,6 @@ export const goToNextEpisode = async (): Promise<void> => {
|
|||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('ep', String(nextEp));
|
url.searchParams.set('ep', String(nextEp));
|
||||||
history.pushState(null, '', url.toString());
|
history.pushState(null, '', url.toString());
|
||||||
state.transitionEpisode = null;
|
|
||||||
} catch {
|
} catch {
|
||||||
sessionStorage.setItem('mal:autoplay-next', 'true');
|
sessionStorage.setItem('mal:autoplay-next', 'true');
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { goToNextEpisode } from './episodes/nav';
|
|||||||
import { resolveActiveSegments, renderSegments } from './skip/segments';
|
import { resolveActiveSegments, renderSegments } from './skip/segments';
|
||||||
import { setupThumbnails } from './episodes/thumbnails';
|
import { setupThumbnails } from './episodes/thumbnails';
|
||||||
import { markEpisodeTransition, setupProgress } from './progress';
|
import { markEpisodeTransition, setupProgress } from './progress';
|
||||||
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline';
|
import {
|
||||||
|
absoluteTimeFromDisplay,
|
||||||
|
absoluteTimeFromRatio,
|
||||||
|
getBounds,
|
||||||
|
displayTimeFromAbsolute,
|
||||||
|
} from './timeline';
|
||||||
import { formatTime } from './controls';
|
import { formatTime } from './controls';
|
||||||
|
|
||||||
let initialized = false; // prevent double init on htmx swaps
|
let initialized = false; // prevent double init on htmx swaps
|
||||||
@@ -98,15 +103,18 @@ const initPlayer = (): void => {
|
|||||||
renderSegments();
|
renderSegments();
|
||||||
|
|
||||||
// resume from saved position
|
// resume from saved position
|
||||||
const startTime = Number(container.dataset.startTimeSeconds ?? '0');
|
const startTime = state.startTimeSeconds;
|
||||||
if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) {
|
if (startTime > 0 && state.video.currentTime <= 0.5 && getBounds().duration > startTime) {
|
||||||
state.video.currentTime = startTime;
|
state.video.currentTime = absoluteTimeFromDisplay(startTime);
|
||||||
}
|
}
|
||||||
// resume after mode switch
|
// resume after mode switch
|
||||||
if (state.pendingSeekTime !== null) {
|
if (state.pendingSeekTime !== null) {
|
||||||
state.video.currentTime = state.pendingSeekTime;
|
state.video.currentTime = absoluteTimeFromDisplay(state.pendingSeekTime);
|
||||||
state.pendingSeekTime = null;
|
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())
|
// autoplay if not already playing (inline script may have already called play())
|
||||||
if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {});
|
if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {});
|
||||||
|
|
||||||
@@ -184,14 +192,20 @@ const initPlayer = (): void => {
|
|||||||
updateSkipButton(state.video.currentTime);
|
updateSkipButton(state.video.currentTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
// track episode transitions from external links
|
// track next-episode links outside the player so they start fresh after finishing an episode
|
||||||
container.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
const anchor = (e.target as Node).parentElement?.closest('a[href]');
|
const target = e.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
const anchor = target.closest('a[href]');
|
||||||
if (!(anchor instanceof HTMLAnchorElement)) return;
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||||||
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean);
|
const url = new URL(anchor.href, location.origin);
|
||||||
if (parts[0] === 'watch' && Number.parseInt(parts[2], 10) > 0) {
|
if (url.origin !== location.origin) return;
|
||||||
markEpisodeTransition(Number.parseInt(parts[2], 10));
|
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);
|
state.video.addEventListener('click', showControls);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const sendBeacon = (payload: string) => {
|
|||||||
* Debounced: skips if within 5s of last save for same episode.
|
* Debounced: skips if within 5s of last save for same episode.
|
||||||
*/
|
*/
|
||||||
export const saveProgress = async (): Promise<void> => {
|
export const saveProgress = async (): Promise<void> => {
|
||||||
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);
|
const episode = Number.parseInt(state.currentEpisode, 10);
|
||||||
if (!episode) return;
|
if (!episode) return;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface PlayerState {
|
|||||||
malID: number;
|
malID: number;
|
||||||
streamURL: string;
|
streamURL: string;
|
||||||
initialStreamToken: string;
|
initialStreamToken: string;
|
||||||
|
startTimeSeconds: number;
|
||||||
shouldAutoPlay: boolean;
|
shouldAutoPlay: boolean;
|
||||||
parsedSegments: SkipSegment[];
|
parsedSegments: SkipSegment[];
|
||||||
activeSegments: ActiveSegment[];
|
activeSegments: ActiveSegment[];
|
||||||
@@ -56,6 +57,7 @@ export const state: PlayerState = {
|
|||||||
malID: 0,
|
malID: 0,
|
||||||
streamURL: '/watch/proxy/stream',
|
streamURL: '/watch/proxy/stream',
|
||||||
initialStreamToken: '',
|
initialStreamToken: '',
|
||||||
|
startTimeSeconds: 0,
|
||||||
shouldAutoPlay: false,
|
shouldAutoPlay: false,
|
||||||
parsedSegments: [],
|
parsedSegments: [],
|
||||||
activeSegments: [],
|
activeSegments: [],
|
||||||
@@ -102,6 +104,7 @@ export const initState = (c: HTMLElement): void => {
|
|||||||
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10);
|
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10);
|
||||||
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream';
|
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream';
|
||||||
state.initialStreamToken = dataset(c, 'streamToken') || '';
|
state.initialStreamToken = dataset(c, 'streamToken') || '';
|
||||||
|
state.startTimeSeconds = Number.parseFloat(dataset(c, 'startTimeSeconds') || '0') || 0;
|
||||||
// from session: previous page set this when autoplay triggered
|
// from session: previous page set this when autoplay triggered
|
||||||
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true';
|
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true';
|
||||||
sessionStorage.removeItem('mal:autoplay-next');
|
sessionStorage.removeItem('mal:autoplay-next');
|
||||||
|
|||||||
Reference in New Issue
Block a user