From e5bc736ffad286239ded13df9e2431ced9e8334b Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 3 Sep 2026 03:19:35 +0000 Subject: [PATCH] Fix episode preview playback and add staggered stopping - Observe the video element itself instead of el.closest('.episode-tile'): ref callbacks can fire before ancestors are attached, so no tile was ever observed and the visibility allowlist blocked all playback. - Use a blocklist for off-screen culling (play until reported off-screen) so observer lag/failure degrades to the old always-on behavior. - Default to the first season playing on page view again. - Keep preload="auto" so tiles show a first-frame preview picture; playback gating already prevents GPU use from inactive videos. - Stop videos staggered (same 500ms step as startup) on idle stop, season switch and scroll culling; same for movie page showreels. --- frontend/src/components/MediaDetail.vue | 22 ++++- frontend/src/components/SeriesFullView.vue | 94 ++++++++++++++-------- 2 files changed, 79 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index 1d0ac0f..1fc7c9a 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -283,6 +283,7 @@ const collageHeaderRef = ref(null) let collageHeaderVisible = true let collageHeaderObserver: IntersectionObserver | null = null const staggerTimers = new Set>() +const COLLAGE_STOP_STEP_MS = 500 let staggerToken = 0 const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({ @@ -438,17 +439,34 @@ function startStaggeredPlayback() { } } -// Stop all collage previews and cancel any pending staggered starts +function scheduleStaggeredStop(token: number, stop: () => void, delayMs: number) { + const timer = setTimeout(() => { + staggerTimers.delete(timer) + if (token !== staggerToken) return + stop() + }, delayMs) + staggerTimers.add(timer) +} + +// Stop all collage previews with a stagger, and cancel pending staggered starts function stopPreviews() { cancelStaggeredPlayback() + const token = staggerToken clearHoverAudioIdleTimer() hoveredVideoIndex = null for (const interval of volumeFadeIntervals.values()) { clearInterval(interval) } volumeFadeIntervals.clear() + + let stopIndex = 0 for (const video of videoRefs.value) { - video?.pause() + if (video && !video.paused && !video.ended) { + scheduleStaggeredStop(token, () => video.pause(), stopIndex * COLLAGE_STOP_STEP_MS) + stopIndex += 1 + } else { + video?.pause() + } } } diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 1e7f718..2540604 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -101,7 +101,7 @@ v-if="getEpisodeVideoSources(episode).length > 0" :ref="(el) => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)" :autoplay="false" - :preload="sIndex === activeSeasonIndex ? 'auto' : 'none'" + preload="auto" loop muted playsinline @@ -641,15 +641,15 @@ function handleOpenFolderFromMenu(filePath: string) { // Video refs for hover effects const videoRefs = ref>(new Map()) const safariAutoplay = isSafariBrowser() -// No season plays until the user browses it (keyboard/gamepad focus or mouse hover) -const activeSeasonIndex = ref(-1) +const activeSeasonIndex = ref(0) const SEASON_VIDEO_STARTUP_STEP_MS = 500 const seasonStartupTimers = new Map>() let seasonStartupToken = 0 -// Episode tiles currently near the viewport; only these are allowed to play -const visibleEpisodeKeys = new Set() -const episodeTileByKey = new Map() +// Episode videos the IntersectionObserver has reported as off-screen. This is +// a blocklist, not an allowlist: a video may play until reported otherwise, so +// playback degrades to always-on if observation fails or lags. +const offscreenEpisodeKeys = new Set() let episodeVisibilityObserver: IntersectionObserver | null = null const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({ @@ -680,14 +680,14 @@ function getEpisodeVisibilityObserver(): IntersectionObserver { (entries) => { let changed = false for (const entry of entries) { - const target = entry.target as HTMLElement - const key = `${target.dataset.seasonIndex}-${target.dataset.episodeIndex}` + const key = (entry.target as HTMLElement).dataset.previewKey + if (!key) continue if (entry.isIntersecting) { - if (!visibleEpisodeKeys.has(key)) { - visibleEpisodeKeys.add(key) + if (offscreenEpisodeKeys.delete(key)) { changed = true } - } else if (visibleEpisodeKeys.delete(key)) { + } else if (!offscreenEpisodeKeys.has(key)) { + offscreenEpisodeKeys.add(key) changed = true } } @@ -703,6 +703,7 @@ function getEpisodeVisibilityObserver(): IntersectionObserver { function stopEpisodePreviews() { seasonStartupToken += 1 + const token = seasonStartupToken clearSeasonStartupTimers() clearEpisodeHoverAudioIdleTimer() hoveredEpisodeAudioKey = null @@ -710,8 +711,21 @@ function stopEpisodePreviews() { clearInterval(interval) } volumeFadeIntervals.clear() - for (const video of videoRefs.value.values()) { - pauseEpisodeVideo(video) + + // Stagger the stop instead of pausing everything at once + let stopIndex = 0 + for (const [key, video] of videoRefs.value.entries()) { + if (!video.paused && !video.ended) { + const timeoutId = setTimeout(() => { + seasonStartupTimers.delete(key) + if (token !== seasonStartupToken) return + pauseEpisodeVideo(video) + }, stopIndex * SEASON_VIDEO_STARTUP_STEP_MS) + seasonStartupTimers.set(key, timeoutId) + stopIndex += 1 + } else { + pauseEpisodeVideo(video) + } } } @@ -733,7 +747,8 @@ function syncSeasonVideoPlayback(priorityKey?: string) { } volumeFadeIntervals.clear() - const activeSeasonVideos: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = [] + const videosToStart: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = [] + const videosToStop: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = [] for (const [key, video] of videoRefs.value.entries()) { const parsed = parseEpisodeKey(key) @@ -742,15 +757,19 @@ function syncSeasonVideoPlayback(priorityKey?: string) { continue } - // Only the browsed season's near-viewport tiles may play, and only while - // the user is active. + // Only the browsed season's on-screen tiles may play, and only while the + // user is active. const eligible = parsed.seasonIndex === activeSeasonIndex.value && - visibleEpisodeKeys.has(key) && + !offscreenEpisodeKeys.has(key) && !previewPlaybackStopped.value if (!eligible) { - pauseEpisodeVideo(video) + if (!video.paused && !video.ended) { + videosToStop.push({ key, episodeIndex: parsed.episodeIndex, video }) + } else { + pauseEpisodeVideo(video) + } continue } @@ -761,14 +780,14 @@ function syncSeasonVideoPlayback(priorityKey?: string) { } pauseEpisodeVideo(video) - activeSeasonVideos.push({ + videosToStart.push({ key, episodeIndex: parsed.episodeIndex, video, }) } - activeSeasonVideos.sort((a, b) => { + videosToStart.sort((a, b) => { if (priorityKey) { if (a.key === priorityKey) return -1 if (b.key === priorityKey) return 1 @@ -776,8 +795,8 @@ function syncSeasonVideoPlayback(priorityKey?: string) { return a.episodeIndex - b.episodeIndex }) - for (let i = 0; i < activeSeasonVideos.length; i += 1) { - const { key, video } = activeSeasonVideos[i] + for (let i = 0; i < videosToStart.length; i += 1) { + const { key, video } = videosToStart[i] const delayMs = priorityKey ? (i === 0 ? 0 : i * SEASON_VIDEO_STARTUP_STEP_MS) : i * SEASON_VIDEO_STARTUP_STEP_MS const timeoutId = setTimeout(() => { if (token !== seasonStartupToken || activeSeasonIndex.value < 0 || previewPlaybackStopped.value) @@ -790,6 +809,18 @@ function syncSeasonVideoPlayback(priorityKey?: string) { }, delayMs) seasonStartupTimers.set(key, timeoutId) } + + // Stop no-longer-eligible videos with the same stagger instead of all at once + videosToStop.sort((a, b) => a.episodeIndex - b.episodeIndex) + for (let i = 0; i < videosToStop.length; i += 1) { + const { key, video } = videosToStop[i] + const timeoutId = setTimeout(() => { + seasonStartupTimers.delete(key) + if (token !== seasonStartupToken) return + pauseEpisodeVideo(video) + }, i * SEASON_VIDEO_STARTUP_STEP_MS) + seasonStartupTimers.set(key, timeoutId) + } } function setActiveSeason(seasonIndex: number) { @@ -822,11 +853,11 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) { } videoRefs.value.set(key, el) - const tile = el.closest(".episode-tile") - if (tile) { - episodeTileByKey.set(key, tile) - getEpisodeVisibilityObserver().observe(tile) - } + // Observe the video itself (it fills the tile, so same visibility box). + // Note: el.closest() is unreliable here — ref callbacks can fire before + // the element's ancestors are attached. + el.dataset.previewKey = key + getEpisodeVisibilityObserver().observe(el) el.addEventListener( "loadeddata", () => { @@ -844,12 +875,6 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) { clearTimeout(timeoutId) seasonStartupTimers.delete(key) } - const tile = episodeTileByKey.get(key) - if (tile) { - episodeVisibilityObserver?.unobserve(tile) - episodeTileByKey.delete(key) - } - visibleEpisodeKeys.delete(key) const old = videoRefs.value.get(key) if (old) { cleanupVideo(old) @@ -1099,8 +1124,7 @@ onUnmounted(() => { clearSeasonStartupTimers() episodeVisibilityObserver?.disconnect() episodeVisibilityObserver = null - episodeTileByKey.clear() - visibleEpisodeKeys.clear() + offscreenEpisodeKeys.clear() if (seasonLayoutFrame !== null) { window.cancelAnimationFrame(seasonLayoutFrame) seasonLayoutFrame = null