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:
@@ -283,6 +283,7 @@ const collageHeaderRef = ref<HTMLElement | null>(null)
|
|||||||
let collageHeaderVisible = true
|
let collageHeaderVisible = true
|
||||||
let collageHeaderObserver: IntersectionObserver | null = null
|
let collageHeaderObserver: IntersectionObserver | null = null
|
||||||
const staggerTimers = new Set<ReturnType<typeof setTimeout>>()
|
const staggerTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||||
|
const COLLAGE_STOP_STEP_MS = 500
|
||||||
let staggerToken = 0
|
let staggerToken = 0
|
||||||
|
|
||||||
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
|
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() {
|
function stopPreviews() {
|
||||||
cancelStaggeredPlayback()
|
cancelStaggeredPlayback()
|
||||||
|
const token = staggerToken
|
||||||
clearHoverAudioIdleTimer()
|
clearHoverAudioIdleTimer()
|
||||||
hoveredVideoIndex = null
|
hoveredVideoIndex = null
|
||||||
for (const interval of volumeFadeIntervals.values()) {
|
for (const interval of volumeFadeIntervals.values()) {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
volumeFadeIntervals.clear()
|
volumeFadeIntervals.clear()
|
||||||
|
|
||||||
|
let stopIndex = 0
|
||||||
for (const video of videoRefs.value) {
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
v-if="getEpisodeVideoSources(episode).length > 0"
|
v-if="getEpisodeVideoSources(episode).length > 0"
|
||||||
:ref="(el) => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)"
|
:ref="(el) => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)"
|
||||||
:autoplay="false"
|
:autoplay="false"
|
||||||
:preload="sIndex === activeSeasonIndex ? 'auto' : 'none'"
|
preload="auto"
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
@@ -641,15 +641,15 @@ function handleOpenFolderFromMenu(filePath: string) {
|
|||||||
// Video refs for hover effects
|
// Video refs for hover effects
|
||||||
const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map())
|
const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map())
|
||||||
const safariAutoplay = isSafariBrowser()
|
const safariAutoplay = isSafariBrowser()
|
||||||
// No season plays until the user browses it (keyboard/gamepad focus or mouse hover)
|
const activeSeasonIndex = ref(0)
|
||||||
const activeSeasonIndex = ref(-1)
|
|
||||||
const SEASON_VIDEO_STARTUP_STEP_MS = 500
|
const SEASON_VIDEO_STARTUP_STEP_MS = 500
|
||||||
const seasonStartupTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
const seasonStartupTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
let seasonStartupToken = 0
|
let seasonStartupToken = 0
|
||||||
|
|
||||||
// Episode tiles currently near the viewport; only these are allowed to play
|
// Episode videos the IntersectionObserver has reported as off-screen. This is
|
||||||
const visibleEpisodeKeys = new Set<string>()
|
// a blocklist, not an allowlist: a video may play until reported otherwise, so
|
||||||
const episodeTileByKey = new Map<string, Element>()
|
// playback degrades to always-on if observation fails or lags.
|
||||||
|
const offscreenEpisodeKeys = new Set<string>()
|
||||||
let episodeVisibilityObserver: IntersectionObserver | null = null
|
let episodeVisibilityObserver: IntersectionObserver | null = null
|
||||||
|
|
||||||
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
|
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
|
||||||
@@ -680,14 +680,14 @@ function getEpisodeVisibilityObserver(): IntersectionObserver {
|
|||||||
(entries) => {
|
(entries) => {
|
||||||
let changed = false
|
let changed = false
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const target = entry.target as HTMLElement
|
const key = (entry.target as HTMLElement).dataset.previewKey
|
||||||
const key = `${target.dataset.seasonIndex}-${target.dataset.episodeIndex}`
|
if (!key) continue
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
if (!visibleEpisodeKeys.has(key)) {
|
if (offscreenEpisodeKeys.delete(key)) {
|
||||||
visibleEpisodeKeys.add(key)
|
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
} else if (visibleEpisodeKeys.delete(key)) {
|
} else if (!offscreenEpisodeKeys.has(key)) {
|
||||||
|
offscreenEpisodeKeys.add(key)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,6 +703,7 @@ function getEpisodeVisibilityObserver(): IntersectionObserver {
|
|||||||
|
|
||||||
function stopEpisodePreviews() {
|
function stopEpisodePreviews() {
|
||||||
seasonStartupToken += 1
|
seasonStartupToken += 1
|
||||||
|
const token = seasonStartupToken
|
||||||
clearSeasonStartupTimers()
|
clearSeasonStartupTimers()
|
||||||
clearEpisodeHoverAudioIdleTimer()
|
clearEpisodeHoverAudioIdleTimer()
|
||||||
hoveredEpisodeAudioKey = null
|
hoveredEpisodeAudioKey = null
|
||||||
@@ -710,8 +711,21 @@ function stopEpisodePreviews() {
|
|||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
volumeFadeIntervals.clear()
|
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()
|
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()) {
|
for (const [key, video] of videoRefs.value.entries()) {
|
||||||
const parsed = parseEpisodeKey(key)
|
const parsed = parseEpisodeKey(key)
|
||||||
@@ -742,15 +757,19 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the browsed season's near-viewport tiles may play, and only while
|
// Only the browsed season's on-screen tiles may play, and only while the
|
||||||
// the user is active.
|
// user is active.
|
||||||
const eligible =
|
const eligible =
|
||||||
parsed.seasonIndex === activeSeasonIndex.value &&
|
parsed.seasonIndex === activeSeasonIndex.value &&
|
||||||
visibleEpisodeKeys.has(key) &&
|
!offscreenEpisodeKeys.has(key) &&
|
||||||
!previewPlaybackStopped.value
|
!previewPlaybackStopped.value
|
||||||
|
|
||||||
if (!eligible) {
|
if (!eligible) {
|
||||||
pauseEpisodeVideo(video)
|
if (!video.paused && !video.ended) {
|
||||||
|
videosToStop.push({ key, episodeIndex: parsed.episodeIndex, video })
|
||||||
|
} else {
|
||||||
|
pauseEpisodeVideo(video)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,14 +780,14 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pauseEpisodeVideo(video)
|
pauseEpisodeVideo(video)
|
||||||
activeSeasonVideos.push({
|
videosToStart.push({
|
||||||
key,
|
key,
|
||||||
episodeIndex: parsed.episodeIndex,
|
episodeIndex: parsed.episodeIndex,
|
||||||
video,
|
video,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
activeSeasonVideos.sort((a, b) => {
|
videosToStart.sort((a, b) => {
|
||||||
if (priorityKey) {
|
if (priorityKey) {
|
||||||
if (a.key === priorityKey) return -1
|
if (a.key === priorityKey) return -1
|
||||||
if (b.key === priorityKey) return 1
|
if (b.key === priorityKey) return 1
|
||||||
@@ -776,8 +795,8 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
|
|||||||
return a.episodeIndex - b.episodeIndex
|
return a.episodeIndex - b.episodeIndex
|
||||||
})
|
})
|
||||||
|
|
||||||
for (let i = 0; i < activeSeasonVideos.length; i += 1) {
|
for (let i = 0; i < videosToStart.length; i += 1) {
|
||||||
const { key, video } = activeSeasonVideos[i]
|
const { key, video } = videosToStart[i]
|
||||||
const delayMs = priorityKey ? (i === 0 ? 0 : i * SEASON_VIDEO_STARTUP_STEP_MS) : i * SEASON_VIDEO_STARTUP_STEP_MS
|
const delayMs = priorityKey ? (i === 0 ? 0 : i * SEASON_VIDEO_STARTUP_STEP_MS) : i * SEASON_VIDEO_STARTUP_STEP_MS
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (token !== seasonStartupToken || activeSeasonIndex.value < 0 || previewPlaybackStopped.value)
|
if (token !== seasonStartupToken || activeSeasonIndex.value < 0 || previewPlaybackStopped.value)
|
||||||
@@ -790,6 +809,18 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
|
|||||||
}, delayMs)
|
}, delayMs)
|
||||||
seasonStartupTimers.set(key, timeoutId)
|
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) {
|
function setActiveSeason(seasonIndex: number) {
|
||||||
@@ -822,11 +853,11 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
videoRefs.value.set(key, el)
|
videoRefs.value.set(key, el)
|
||||||
const tile = el.closest(".episode-tile")
|
// Observe the video itself (it fills the tile, so same visibility box).
|
||||||
if (tile) {
|
// Note: el.closest() is unreliable here — ref callbacks can fire before
|
||||||
episodeTileByKey.set(key, tile)
|
// the element's ancestors are attached.
|
||||||
getEpisodeVisibilityObserver().observe(tile)
|
el.dataset.previewKey = key
|
||||||
}
|
getEpisodeVisibilityObserver().observe(el)
|
||||||
el.addEventListener(
|
el.addEventListener(
|
||||||
"loadeddata",
|
"loadeddata",
|
||||||
() => {
|
() => {
|
||||||
@@ -844,12 +875,6 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) {
|
|||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
seasonStartupTimers.delete(key)
|
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)
|
const old = videoRefs.value.get(key)
|
||||||
if (old) {
|
if (old) {
|
||||||
cleanupVideo(old)
|
cleanupVideo(old)
|
||||||
@@ -1099,8 +1124,7 @@ onUnmounted(() => {
|
|||||||
clearSeasonStartupTimers()
|
clearSeasonStartupTimers()
|
||||||
episodeVisibilityObserver?.disconnect()
|
episodeVisibilityObserver?.disconnect()
|
||||||
episodeVisibilityObserver = null
|
episodeVisibilityObserver = null
|
||||||
episodeTileByKey.clear()
|
offscreenEpisodeKeys.clear()
|
||||||
visibleEpisodeKeys.clear()
|
|
||||||
if (seasonLayoutFrame !== null) {
|
if (seasonLayoutFrame !== null) {
|
||||||
window.cancelAnimationFrame(seasonLayoutFrame)
|
window.cancelAnimationFrame(seasonLayoutFrame)
|
||||||
seasonLayoutFrame = null
|
seasonLayoutFrame = null
|
||||||
|
|||||||
Reference in New Issue
Block a user