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

@@ -6,6 +6,7 @@ import { updateQualityOptions } from '../quality';
import { updateModeButtons } from '../mode'; import { updateModeButtons } from '../mode';
import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui'; import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui';
import { markEpisodeTransition } from '../progress'; import { markEpisodeTransition } from '../progress';
import { safeLocalStorage } from '../storage';
/** /**
* Handles video end: either marks complete or loads next episode. * Handles video end: either marks complete or loads next episode.
@@ -71,7 +72,7 @@ export const goToNextEpisode = async (): Promise<void> => {
state.container.dataset.startTimeSeconds = String(state.startTimeSeconds); 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 = safeLocalStorage.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) { if (!state.video.paused) {

View File

@@ -1,5 +1,6 @@
import { state } from '../state'; import { state } from '../state';
import { qs } from '../../q'; import { qs } from '../../q';
import { safeLocalStorage } from '../storage';
/** /**
* Syncs autoplay checkbox with localStorage on init. * Syncs autoplay checkbox with localStorage on init.
@@ -8,11 +9,11 @@ import { qs } from '../../q';
export const setupAutoplayButton = (): void => { export const setupAutoplayButton = (): void => {
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null; const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
if (!btn) return; if (!btn) return;
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false'; btn.checked = safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false';
}; };
export const isAutoplayEnabled = (): boolean => export const isAutoplayEnabled = (): boolean =>
localStorage.getItem('mal:autoplay-enabled') !== 'false'; safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false';
/** /**
* Updates video overlay text (shown briefly on episode change). * Updates video overlay text (shown briefly on episode change).

View File

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

View File

@@ -3,6 +3,7 @@ import { displayTimeFromAbsolute } from './timeline';
import { showControls } from './controls'; import { showControls } from './controls';
import { updateSubtitleOptions } from './subtitles'; import { updateSubtitleOptions } from './subtitles';
import { updateQualityOptions } from './quality'; import { updateQualityOptions } from './quality';
import { safeLocalStorage } from './storage';
// builds stream URL with mode, token, and optional quality param // builds stream URL with mode, token, and optional quality param
const streamUrlForMode = (mode: string, quality?: string): string => { const streamUrlForMode = (mode: string, quality?: string): string => {
@@ -33,7 +34,7 @@ const loadVideo = (url: string): void => {
export const switchMode = (mode: string): void => { export const switchMode = (mode: string): void => {
if (!state.availableModes.includes(mode) || mode === state.currentMode) return; if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
state.currentMode = mode; state.currentMode = mode;
localStorage.setItem('player-audio-mode', mode); safeLocalStorage.setItem('player-audio-mode', mode);
const qualitySelect = state.container.querySelector( const qualitySelect = state.container.querySelector(
'[data-quality-select]' '[data-quality-select]'
) as HTMLSelectElement | null; ) as HTMLSelectElement | null;
@@ -91,7 +92,7 @@ export const setupMode = (): void => {
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null; const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
autoplayBtn?.addEventListener('change', e => { autoplayBtn?.addEventListener('change', e => {
localStorage.setItem( safeLocalStorage.setItem(
'mal:autoplay-enabled', 'mal:autoplay-enabled',
(e.target as HTMLInputElement).checked ? 'true' : 'false' (e.target as HTMLInputElement).checked ? 'true' : 'false'
); );

View File

@@ -1,5 +1,6 @@
import { state } from './state'; import { state } from './state';
import { displayTimeFromAbsolute } from './timeline'; import { displayTimeFromAbsolute } from './timeline';
import { safeLocalStorage } from './storage';
// same as mode.ts - could be extracted to shared util // same as mode.ts - could be extracted to shared util
const streamUrlForMode = (mode: string, quality?: string): string => { const streamUrlForMode = (mode: string, quality?: string): string => {
@@ -29,7 +30,7 @@ const loadVideo = (url: string): void => {
export const switchQuality = (quality: string): void => { export const switchQuality = (quality: string): void => {
const url = streamUrlForMode(state.currentMode, quality); const url = streamUrlForMode(state.currentMode, quality);
if (!url) return; if (!url) return;
localStorage.setItem('mal:preferred-quality', quality); safeLocalStorage.setItem('mal:preferred-quality', quality);
loadVideo(url); loadVideo(url);
}; };
@@ -56,7 +57,7 @@ export const updateQualityOptions = (): void => {
}); });
// restore saved preference // restore saved preference
const preferred = localStorage.getItem('mal:preferred-quality') || 'best'; const preferred = safeLocalStorage.getItem('mal:preferred-quality') || 'best';
select.value = qualities.includes(preferred) ? preferred : 'best'; select.value = qualities.includes(preferred) ? preferred : 'best';
// hide if no quality options // hide if no quality options

View File

@@ -2,6 +2,7 @@ import { state } from '../state';
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline'; import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline';
import { showControls } from '../controls'; import { showControls } from '../controls';
import { saveProgress } from '../progress'; import { saveProgress } from '../progress';
import { safeLocalStorage } from '../storage';
// button label based on segment type // button label based on segment type
const skipLabel = (type: string): string => (type === 'ed' ? 'Skip outro' : 'Skip intro'); const skipLabel = (type: string): string => (type === 'ed' ? 'Skip outro' : 'Skip intro');
@@ -27,7 +28,7 @@ export const updateSkipButton = (currentTime: number): void => {
} }
// auto-skip: jump to end if enabled // auto-skip: jump to end if enabled
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true'; const autoSkip = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true';
if (autoSkip && displayTime >= segment.start && displayTime < segment.end) { if (autoSkip && displayTime >= segment.start && displayTime < segment.end) {
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01); state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01);
void saveProgress(); void saveProgress();
@@ -49,7 +50,7 @@ export const updateSkipButton = (currentTime: number): void => {
export const updateAutoSkipButton = (): void => { export const updateAutoSkipButton = (): void => {
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null; const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null;
if (!btn) return; if (!btn) return;
btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true'; btn.checked = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true';
}; };
/** /**
@@ -59,7 +60,7 @@ export const setupSkip = (): void => {
document.addEventListener('change', e => { document.addEventListener('change', e => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.hasAttribute('data-autoskip')) { if (target.hasAttribute('data-autoskip')) {
localStorage.setItem( safeLocalStorage.setItem(
'mal:autoskip-enabled', 'mal:autoskip-enabled',
(target as HTMLInputElement).checked ? 'true' : 'false' (target as HTMLInputElement).checked ? 'true' : 'false'
); );

View File

@@ -1,5 +1,6 @@
import type { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types'; import type { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types';
import { q, qs, dataset } from '../q'; import { q, qs, dataset } from '../q';
import { safeLocalStorage } from './storage';
export interface PlayerState { export interface PlayerState {
container: HTMLElement; container: HTMLElement;
@@ -169,7 +170,7 @@ export const initState = (c: HTMLElement): boolean => {
// resolve initial mode: localStorage > backend default > first available > 'dub' // resolve initial mode: localStorage > backend default > first available > 'dub'
const backendInitialMode = dataset(c, 'initialMode') || 'dub'; const backendInitialMode = dataset(c, 'initialMode') || 'dub';
state.modeSwitchedFrom = dataset(c, 'modeSwitchedFrom') || ''; state.modeSwitchedFrom = dataset(c, 'modeSwitchedFrom') || '';
const storedMode = localStorage.getItem('player-audio-mode'); const storedMode = safeLocalStorage.getItem('player-audio-mode');
const initialMode = const initialMode =
storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode; storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode;
const fallbackMode = Object.keys(state.modeSources).find(m => state.modeSources[m]?.token); const fallbackMode = Object.keys(state.modeSources).find(m => state.modeSources[m]?.token);

40
static/player/storage.ts Normal file
View File

@@ -0,0 +1,40 @@
export type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
const getLocalStorage = (): StorageLike | null => {
try {
return window.localStorage;
} catch {
return null;
}
};
export const safeLocalStorage = {
getItem(key: string): string | null {
const storage = getLocalStorage();
if (!storage) return null;
try {
return storage.getItem(key);
} catch {
return null;
}
},
setItem(key: string, value: string): void {
const storage = getLocalStorage();
if (!storage) return;
try {
storage.setItem(key, value);
} catch {
// ignore
}
},
removeItem(key: string): void {
const storage = getLocalStorage();
if (!storage) return;
try {
storage.removeItem(key);
} catch {
// ignore
}
},
};