fix: reinit player safely

This commit is contained in:
2026-05-26 22:20:26 +02:00
parent 6da80df655
commit 30441c3e1f
8 changed files with 96 additions and 30 deletions

View File

@@ -12,6 +12,7 @@ import { resolveActiveSegments, renderSegments } from './skip/segments';
import { setupSegmentEditor } from './skip/editor';
import { setupThumbnails } from './episodes/thumbnails';
import { markEpisodeTransition, setupProgress } from './progress';
import { safeLocalStorage } from './storage';
import {
absoluteTimeFromDisplay,
absoluteTimeFromRatio,
@@ -20,7 +21,8 @@ import {
} from './timeline';
import { formatTime } from './controls';
let initialized = false; // prevent double init on htmx swaps
let currentContainer: HTMLElement | null = null;
let cleanup: (() => void) | null = null;
type ClosableDropdown = HTMLElement & { close: () => void };
const isClosableDropdown = (el: Element | null): el is ClosableDropdown => {
@@ -45,6 +47,12 @@ const showPreviewPopover = (): void => {
state.previewPopover.classList.add('opacity-100');
};
const teardownPlayer = (): void => {
cleanup?.();
cleanup = null;
currentContainer = null;
};
// updates time preview on progress bar hover
const updatePreviewUI = (ratio: number): void => {
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
@@ -75,20 +83,25 @@ const updatePreviewUI = (ratio: number): void => {
const initPlayer = (): void => {
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
if (!container || initialized) return;
if (!container) return;
if (container === currentContainer) return;
teardownPlayer();
if (!initState(container)) {
console.error('Video player markup is missing required controls.');
return;
}
initialized = true;
currentContainer = container;
const abortController = new AbortController();
const signal = abortController.signal;
cleanup = () => abortController.abort();
const loading = container.querySelector('[data-loading]') as HTMLElement | null;
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
// build video src from mode, token, and saved quality preference
// Only set if not already provided by the inline script during HTML parsing
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
const preferredQuality = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
const streamToken = state.modeSources[state.currentMode]?.token;
if (!state.video.src && streamToken) {
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
@@ -146,7 +159,7 @@ const initPlayer = (): void => {
updateSkipButton(state.video.currentTime);
};
state.video.addEventListener('loadedmetadata', onLoadedMetadata);
state.video.addEventListener('loadedmetadata', onLoadedMetadata, { signal });
// inline script runs during HTML parsing before initPlayer; if metadata
// already loaded, fire the handler immediately
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
@@ -157,27 +170,27 @@ const initPlayer = (): void => {
if (loading) {
loading.style.display = 'flex';
}
});
}, { signal });
state.video.addEventListener('playing', () => {
if (loading) {
loading.style.display = 'none';
}
});
}, { signal });
// update progress bar during buffering
state.video.addEventListener('progress', () => {
updateTimeline(state.video.currentTime);
});
}, { signal });
// main loop: update progress, subtitles, skip buttons
state.video.addEventListener('timeupdate', () => {
updateTimeline(state.video.currentTime);
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
updateSkipButton(state.video.currentTime);
});
}, { signal });
state.video.addEventListener('ended', () => {
goToNextEpisode();
});
}, { signal });
// click/drag to seek (pointer events are more consistent across fullscreen/mobile)
progressWrap?.addEventListener('pointerdown', e => {
@@ -194,20 +207,20 @@ const initPlayer = (): void => {
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
showControls();
});
}, { signal });
// hover to preview time
progressWrap?.addEventListener('pointermove', e => {
const rect = progressWrap.getBoundingClientRect();
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
});
}, { signal });
progressWrap?.addEventListener('pointerleave', hidePreviewPopover);
progressWrap?.addEventListener('pointerleave', hidePreviewPopover, { signal });
progressWrap?.addEventListener('pointerup', () => {
// ensure we finish the seek even if no window mousemove fired
if (!progressWrap) return;
state.isScrubbing = false;
});
}, { signal });
// dragging outside progress bar while scrubbing
window.addEventListener('pointermove', e => {
@@ -218,7 +231,7 @@ const initPlayer = (): void => {
);
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
});
}, { signal });
// track next-episode links outside the player so they start fresh after finishing an episode
document.addEventListener('click', e => {
@@ -234,9 +247,9 @@ const initPlayer = (): void => {
const nextEpisode = Number.parseInt(url.searchParams.get('ep') ?? '1', 10);
const currentEpisode = Number.parseInt(state.currentEpisode, 10);
if (nextEpisode === currentEpisode + 1) markEpisodeTransition(nextEpisode);
});
}, { signal });
state.video.addEventListener('click', showControls);
state.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;
@@ -265,7 +278,7 @@ const initPlayer = (): void => {
updateEpisodeHighlight(clamped);
}
}, 300);
});
}, { signal });
}
// range buttons (100s of episodes)
@@ -276,7 +289,7 @@ const initPlayer = (): void => {
switchEpisodeRange(idx);
const dd = btn.closest('ui-dropdown');
if (isClosableDropdown(dd)) dd.close();
});
}, { signal });
});
}
@@ -293,3 +306,10 @@ document.body.addEventListener('htmx:afterSwap', (e: Event) => {
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
if (target?.querySelector('[data-video-player]')) initPlayer();
});
document.body.addEventListener('htmx:beforeSwap', (e: Event) => {
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
if (target && currentContainer && target.contains(currentContainer)) {
teardownPlayer();
}
});