diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index c61d3ca..b5fcc44 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -6,6 +6,7 @@ import { updateQualityOptions } from '../quality'; import { updateModeButtons } from '../mode'; import { updateOverlay, isAutoplayEnabled, switchEpisodeRange } from './ui'; import { markEpisodeTransition } from '../progress'; +import { safeLocalStorage } from '../storage'; /** * Handles video end: either marks complete or loads next episode. @@ -71,7 +72,7 @@ export const goToNextEpisode = async (): Promise => { state.container.dataset.startTimeSeconds = String(state.startTimeSeconds); // 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.load(); if (!state.video.paused) { diff --git a/static/player/episodes/ui.ts b/static/player/episodes/ui.ts index 62921f2..6bc984c 100644 --- a/static/player/episodes/ui.ts +++ b/static/player/episodes/ui.ts @@ -1,5 +1,6 @@ import { state } from '../state'; import { qs } from '../../q'; +import { safeLocalStorage } from '../storage'; /** * Syncs autoplay checkbox with localStorage on init. @@ -8,11 +9,11 @@ import { qs } from '../../q'; export const setupAutoplayButton = (): void => { const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null; if (!btn) return; - btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false'; + btn.checked = safeLocalStorage.getItem('mal:autoplay-enabled') !== 'false'; }; 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). diff --git a/static/player/main.ts b/static/player/main.ts index 6272cff..c9acf53 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -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(); + } +}); diff --git a/static/player/mode.ts b/static/player/mode.ts index 6f903f9..e7bcbe5 100644 --- a/static/player/mode.ts +++ b/static/player/mode.ts @@ -3,6 +3,7 @@ import { displayTimeFromAbsolute } from './timeline'; import { showControls } from './controls'; import { updateSubtitleOptions } from './subtitles'; import { updateQualityOptions } from './quality'; +import { safeLocalStorage } from './storage'; // builds stream URL with mode, token, and optional quality param const streamUrlForMode = (mode: string, quality?: string): string => { @@ -33,7 +34,7 @@ const loadVideo = (url: string): void => { export const switchMode = (mode: string): void => { if (!state.availableModes.includes(mode) || mode === state.currentMode) return; state.currentMode = mode; - localStorage.setItem('player-audio-mode', mode); + safeLocalStorage.setItem('player-audio-mode', mode); const qualitySelect = state.container.querySelector( '[data-quality-select]' ) as HTMLSelectElement | null; @@ -91,7 +92,7 @@ export const setupMode = (): void => { const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null; autoplayBtn?.addEventListener('change', e => { - localStorage.setItem( + safeLocalStorage.setItem( 'mal:autoplay-enabled', (e.target as HTMLInputElement).checked ? 'true' : 'false' ); diff --git a/static/player/quality.ts b/static/player/quality.ts index 425b323..2acc633 100644 --- a/static/player/quality.ts +++ b/static/player/quality.ts @@ -1,5 +1,6 @@ import { state } from './state'; import { displayTimeFromAbsolute } from './timeline'; +import { safeLocalStorage } from './storage'; // same as mode.ts - could be extracted to shared util const streamUrlForMode = (mode: string, quality?: string): string => { @@ -29,7 +30,7 @@ const loadVideo = (url: string): void => { export const switchQuality = (quality: string): void => { const url = streamUrlForMode(state.currentMode, quality); if (!url) return; - localStorage.setItem('mal:preferred-quality', quality); + safeLocalStorage.setItem('mal:preferred-quality', quality); loadVideo(url); }; @@ -56,7 +57,7 @@ export const updateQualityOptions = (): void => { }); // 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'; // hide if no quality options diff --git a/static/player/skip/index.ts b/static/player/skip/index.ts index 3f0256d..fa0142b 100644 --- a/static/player/skip/index.ts +++ b/static/player/skip/index.ts @@ -2,6 +2,7 @@ import { state } from '../state'; import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline'; import { showControls } from '../controls'; import { saveProgress } from '../progress'; +import { safeLocalStorage } from '../storage'; // button label based on segment type 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 - const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true'; + const autoSkip = safeLocalStorage.getItem('mal:autoskip-enabled') === 'true'; if (autoSkip && displayTime >= segment.start && displayTime < segment.end) { state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01); void saveProgress(); @@ -49,7 +50,7 @@ export const updateSkipButton = (currentTime: number): void => { export const updateAutoSkipButton = (): void => { const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null; 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 => { const target = e.target as HTMLElement; if (target.hasAttribute('data-autoskip')) { - localStorage.setItem( + safeLocalStorage.setItem( 'mal:autoskip-enabled', (target as HTMLInputElement).checked ? 'true' : 'false' ); diff --git a/static/player/state.ts b/static/player/state.ts index 92becc0..cecd061 100644 --- a/static/player/state.ts +++ b/static/player/state.ts @@ -1,5 +1,6 @@ import type { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types'; import { q, qs, dataset } from '../q'; +import { safeLocalStorage } from './storage'; export interface PlayerState { container: HTMLElement; @@ -169,7 +170,7 @@ export const initState = (c: HTMLElement): boolean => { // resolve initial mode: localStorage > backend default > first available > 'dub' const backendInitialMode = dataset(c, 'initialMode') || 'dub'; state.modeSwitchedFrom = dataset(c, 'modeSwitchedFrom') || ''; - const storedMode = localStorage.getItem('player-audio-mode'); + const storedMode = safeLocalStorage.getItem('player-audio-mode'); const initialMode = storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode; const fallbackMode = Object.keys(state.modeSources).find(m => state.modeSources[m]?.token); diff --git a/static/player/storage.ts b/static/player/storage.ts new file mode 100644 index 0000000..8ad6723 --- /dev/null +++ b/static/player/storage.ts @@ -0,0 +1,40 @@ +export type StorageLike = Pick; + +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 + } + }, +}; +