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.
This commit is contained in:
2026-09-03 03:19:35 +00:00
parent 258fc79753
commit e5bc736ffa
2 changed files with 79 additions and 37 deletions
+19 -1
View File
@@ -283,6 +283,7 @@ const collageHeaderRef = ref<HTMLElement | null>(null)
let collageHeaderVisible = true
let collageHeaderObserver: IntersectionObserver | null = null
const staggerTimers = new Set<ReturnType<typeof setTimeout>>()
const COLLAGE_STOP_STEP_MS = 500
let staggerToken = 0
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
@@ -438,19 +439,36 @@ 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) {
if (video && !video.paused && !video.ended) {
scheduleStaggeredStop(token, () => video.pause(), stopIndex * COLLAGE_STOP_STEP_MS)
stopIndex += 1
} else {
video?.pause()
}
}
}
// Volume fade animation tracking
const volumeFadeIntervals = new Map<number, ReturnType<typeof setInterval>>()
+57 -33
View File
@@ -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<Map<string, HTMLVideoElement>>(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<string, ReturnType<typeof setTimeout>>()
let seasonStartupToken = 0
// Episode tiles currently near the viewport; only these are allowed to play
const visibleEpisodeKeys = new Set<string>()
const episodeTileByKey = new Map<string, Element>()
// 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<string>()
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()) {
// 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) {
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