Reduce preview video GPU load: idle stop, browse-gated seasons, viewport culling

- Add useIdlePreviewPlayback composable: stops preview videos after 30s
  without input and restarts them staggered on activity; also stops
  while the tab is hidden.
- SeriesFullView: no episode videos play until a season is browsed
  (keyboard/gamepad focus or mouse hover); only near-viewport tiles of
  the active season play (IntersectionObserver); inactive seasons no
  longer preload video data; sync is incremental so playing tiles are
  not rewound on scroll.
- MediaDetail: header showreel videos use cancellable stagger timers,
  pause when scrolled out of view, and honor the idle stop.
- Images: lazy/async decoding on series page posters; content-visibility:
  auto on media cards to skip rendering off-screen row items.
This commit is contained in:
2026-09-03 02:23:22 +00:00
parent 7a60ef5384
commit 258fc79753
5 changed files with 246 additions and 11 deletions
+1
View File
@@ -18,6 +18,7 @@
:src="posterImageUrl" :src="posterImageUrl"
:alt="item.title || 'Unknown'" :alt="item.title || 'Unknown'"
loading="lazy" loading="lazy"
decoding="async"
@error="imageError = true" @error="imageError = true"
/> />
<div v-else class="media-card-placeholder"> <div v-else class="media-card-placeholder">
+77 -7
View File
@@ -17,7 +17,7 @@
<div v-else class="movie-page"> <div v-else class="movie-page">
<div class="movie-page-content"> <div class="movie-page-content">
<!-- Diagonal collage header --> <!-- Diagonal collage header -->
<div class="collage-header"> <div ref="collageHeaderRef" class="collage-header">
<!-- Background collage of showreel videos --> <!-- Background collage of showreel videos -->
<div class="collage-grid"> <div class="collage-grid">
<div <div
@@ -253,6 +253,7 @@ import {
FOCUSABLE_ATTR, FOCUSABLE_ATTR,
setModalOpen, setModalOpen,
} from "../composables/useKeyboardNavigation" } from "../composables/useKeyboardNavigation"
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
const props = defineProps<{ const props = defineProps<{
item: MediaItem item: MediaItem
@@ -278,6 +279,21 @@ const safariAutoplay = isSafariBrowser()
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2] const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2]
const DESKTOP_NAV_SHORTCUT_MIN_WIDTH = 900 const DESKTOP_NAV_SHORTCUT_MIN_WIDTH = 900
const collageHeaderRef = ref<HTMLElement | null>(null)
let collageHeaderVisible = true
let collageHeaderObserver: IntersectionObserver | null = null
const staggerTimers = new Set<ReturnType<typeof setTimeout>>()
let staggerToken = 0
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
onStop: stopPreviews,
onRestart: () => startStaggeredPlayback(),
})
function previewsSuppressed(): boolean {
return previewPlaybackStopped.value || !collageHeaderVisible || document.hidden
}
let disposeOutOfBoundsHandler: (() => void) | null = null let disposeOutOfBoundsHandler: (() => void) | null = null
let lastReleaseShortcutRow: number | null = null let lastReleaseShortcutRow: number | null = null
@@ -362,15 +378,35 @@ function isVideoReady(index: number): boolean {
return videoStates.value[index] === "ready" return videoStates.value[index] === "ready"
} }
function cancelStaggeredPlayback() {
staggerToken += 1
for (const timer of staggerTimers) {
clearTimeout(timer)
}
staggerTimers.clear()
}
function scheduleStaggeredStart(token: number, start: () => void, delayMs: number) {
const timer = setTimeout(() => {
staggerTimers.delete(timer)
if (token !== staggerToken || previewsSuppressed()) return
start()
}, delayMs)
staggerTimers.add(timer)
}
// Start staggered video playback // Start staggered video playback
function startStaggeredPlayback() { function startStaggeredPlayback() {
cancelStaggeredPlayback()
const token = staggerToken
const videos = videoRefs.value.filter((v) => v !== null) as HTMLVideoElement[] const videos = videoRefs.value.filter((v) => v !== null) as HTMLVideoElement[]
if (videos.length === 0) return if (videos.length === 0 || previewsSuppressed()) return
if (safariAutoplay) { if (safariAutoplay) {
videos.forEach((video, index) => { videos.forEach((video, index) => {
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0 const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0
const startVideo = () => { const startVideo = () => {
if (token !== staggerToken || previewsSuppressed()) return
video.currentTime = offset video.currentTime = offset
video.play().catch(() => {}) video.play().catch(() => {})
} }
@@ -390,11 +426,29 @@ function startStaggeredPlayback() {
// Set up staggered start for remaining videos // Set up staggered start for remaining videos
for (let i = 1; i < videos.length; i++) { for (let i = 1; i < videos.length; i++) {
setTimeout(() => { scheduleStaggeredStart(
const video = videos[i] token,
if (!video) return () => {
video.play().catch(() => {}) const video = videos[i]
}, i * 2000) if (!video) return
video.play().catch(() => {})
},
i * 2000,
)
}
}
// Stop all collage previews and cancel any pending staggered starts
function stopPreviews() {
cancelStaggeredPlayback()
clearHoverAudioIdleTimer()
hoveredVideoIndex = null
for (const interval of volumeFadeIntervals.values()) {
clearInterval(interval)
}
volumeFadeIntervals.clear()
for (const video of videoRefs.value) {
video?.pause()
} }
} }
@@ -497,6 +551,19 @@ onMounted(() => {
startStaggeredPlayback() startStaggeredPlayback()
}, 100) }, 100)
// Pause the collage videos while the header is scrolled out of view
if (collageHeaderRef.value) {
collageHeaderObserver = new IntersectionObserver((entries) => {
collageHeaderVisible = entries[0]?.isIntersecting ?? true
if (collageHeaderVisible) {
startStaggeredPlayback()
} else {
stopPreviews()
}
})
collageHeaderObserver.observe(collageHeaderRef.value)
}
registerMovieOutOfBoundsShortcut() registerMovieOutOfBoundsShortcut()
}) })
@@ -990,6 +1057,9 @@ onUnmounted(() => {
window.removeEventListener("mousemove", handleHoverAudioMouseMove) window.removeEventListener("mousemove", handleHoverAudioMouseMove)
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
clearHoverAudioIdleTimer() clearHoverAudioIdleTimer()
cancelStaggeredPlayback()
collageHeaderObserver?.disconnect()
collageHeaderObserver = null
disposeOutOfBoundsHandler?.() disposeOutOfBoundsHandler?.()
disposeOutOfBoundsHandler = null disposeOutOfBoundsHandler = null
lastReleaseShortcutRow = null lastReleaseShortcutRow = null
+90 -4
View File
@@ -62,6 +62,8 @@
:src="getSeasonPoster(season)" :src="getSeasonPoster(season)"
class="season-poster-img" class="season-poster-img"
:alt="season.name || `Season ${season.season_number}`" :alt="season.name || `Season ${season.season_number}`"
loading="lazy"
decoding="async"
/> />
<div v-else class="poster-placeholder"> <div v-else class="poster-placeholder">
<span class="poster-num">{{ season.season_number }}</span> <span class="poster-num">{{ season.season_number }}</span>
@@ -99,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="auto" :preload="sIndex === activeSeasonIndex ? 'auto' : 'none'"
loop loop
muted muted
playsinline playsinline
@@ -153,6 +155,8 @@
:src="getCoverUrl(movie.cover_path, movie.root_id)" :src="getCoverUrl(movie.cover_path, movie.root_id)"
class="linked-movie-poster" class="linked-movie-poster"
:alt="movie.title || 'Movie'" :alt="movie.title || 'Movie'"
loading="lazy"
decoding="async"
/> />
<div v-else class="linked-movie-poster linked-movie-poster-fallback"></div> <div v-else class="linked-movie-poster linked-movie-poster-fallback"></div>
<div class="linked-movie-meta"> <div class="linked-movie-meta">
@@ -195,6 +199,7 @@ import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue"
import type { Series, Season, Episode, MovieUi } from "../types" import type { Series, Season, Episode, MovieUi } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api" import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation" import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue" import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings" import { sortTorrentsByPreference } from "../composables/useSettings"
@@ -636,11 +641,22 @@ 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()
const activeSeasonIndex = ref(0) // No season plays until the user browses it (keyboard/gamepad focus or mouse hover)
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
const visibleEpisodeKeys = new Set<string>()
const episodeTileByKey = new Map<string, Element>()
let episodeVisibilityObserver: IntersectionObserver | null = null
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
onStop: stopEpisodePreviews,
onRestart: () => syncSeasonVideoPlayback(),
})
function parseEpisodeKey(key: string): { seasonIndex: number; episodeIndex: number } | null { function parseEpisodeKey(key: string): { seasonIndex: number; episodeIndex: number } | null {
const [seasonPart, episodePart] = key.split("-") const [seasonPart, episodePart] = key.split("-")
const seasonIndex = parseInt(seasonPart || "", 10) const seasonIndex = parseInt(seasonPart || "", 10)
@@ -658,6 +674,47 @@ function clearSeasonStartupTimers() {
seasonStartupTimers.clear() seasonStartupTimers.clear()
} }
function getEpisodeVisibilityObserver(): IntersectionObserver {
if (!episodeVisibilityObserver) {
episodeVisibilityObserver = new IntersectionObserver(
(entries) => {
let changed = false
for (const entry of entries) {
const target = entry.target as HTMLElement
const key = `${target.dataset.seasonIndex}-${target.dataset.episodeIndex}`
if (entry.isIntersecting) {
if (!visibleEpisodeKeys.has(key)) {
visibleEpisodeKeys.add(key)
changed = true
}
} else if (visibleEpisodeKeys.delete(key)) {
changed = true
}
}
if (changed) {
syncSeasonVideoPlayback()
}
},
{ rootMargin: "100px 0px" },
)
}
return episodeVisibilityObserver
}
function stopEpisodePreviews() {
seasonStartupToken += 1
clearSeasonStartupTimers()
clearEpisodeHoverAudioIdleTimer()
hoveredEpisodeAudioKey = null
for (const interval of volumeFadeIntervals.values()) {
clearInterval(interval)
}
volumeFadeIntervals.clear()
for (const video of videoRefs.value.values()) {
pauseEpisodeVideo(video)
}
}
function pauseEpisodeVideo(video: HTMLVideoElement) { function pauseEpisodeVideo(video: HTMLVideoElement) {
video.pause() video.pause()
if (video.readyState >= 1) { if (video.readyState >= 1) {
@@ -685,11 +742,24 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
continue continue
} }
if (parsed.seasonIndex !== activeSeasonIndex.value) { // Only the browsed season's near-viewport tiles may play, and only while
// the user is active.
const eligible =
parsed.seasonIndex === activeSeasonIndex.value &&
visibleEpisodeKeys.has(key) &&
!previewPlaybackStopped.value
if (!eligible) {
pauseEpisodeVideo(video) pauseEpisodeVideo(video)
continue continue
} }
// Already playing and still eligible: leave it running so visibility
// updates (scrolling, idle resume) don't restart it from the beginning.
if (!video.paused && !video.ended) {
continue
}
pauseEpisodeVideo(video) pauseEpisodeVideo(video)
activeSeasonVideos.push({ activeSeasonVideos.push({
key, key,
@@ -710,7 +780,8 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
const { key, video } = activeSeasonVideos[i] const { key, video } = activeSeasonVideos[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) return if (token !== seasonStartupToken || activeSeasonIndex.value < 0 || previewPlaybackStopped.value)
return
if (safariAutoplay && video.readyState >= 1) { if (safariAutoplay && video.readyState >= 1) {
video.currentTime = 0.001 video.currentTime = 0.001
} }
@@ -751,6 +822,11 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) {
} }
videoRefs.value.set(key, el) videoRefs.value.set(key, el)
const tile = el.closest(".episode-tile")
if (tile) {
episodeTileByKey.set(key, tile)
getEpisodeVisibilityObserver().observe(tile)
}
el.addEventListener( el.addEventListener(
"loadeddata", "loadeddata",
() => { () => {
@@ -768,6 +844,12 @@ 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)
@@ -1015,6 +1097,10 @@ onUnmounted(() => {
window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove) window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove)
clearEpisodeHoverAudioIdleTimer() clearEpisodeHoverAudioIdleTimer()
clearSeasonStartupTimers() clearSeasonStartupTimers()
episodeVisibilityObserver?.disconnect()
episodeVisibilityObserver = null
episodeTileByKey.clear()
visibleEpisodeKeys.clear()
if (seasonLayoutFrame !== null) { if (seasonLayoutFrame !== null) {
window.cancelAnimationFrame(seasonLayoutFrame) window.cancelAnimationFrame(seasonLayoutFrame)
seasonLayoutFrame = null seasonLayoutFrame = null
@@ -0,0 +1,74 @@
import { onUnmounted, ref, type Ref } from "vue"
export const PREVIEW_IDLE_MS = 30_000
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "wheel", "keydown", "touchstart"] as const
const GAMEPAD_ACTIVITY_EVENT = "mediahive:gamepad-action"
interface IdlePreviewPlaybackOptions {
idleMs?: number
onStop: () => void
onRestart: () => void
}
// Stops preview videos after a period without user input and restarts them
// (via the component's staggered startup) when activity resumes. Also stops
// previews while the tab is hidden. Activity listeners run in the capture
// phase so the stopped flag clears before hover/focus handlers react.
export function useIdlePreviewPlayback(options: IdlePreviewPlaybackOptions): {
stopped: Ref<boolean>
} {
const idleMs = options.idleMs ?? PREVIEW_IDLE_MS
const stopped = ref(false)
let idleTimer: ReturnType<typeof setTimeout> | null = null
function clearIdleTimer() {
if (idleTimer !== null) {
clearTimeout(idleTimer)
idleTimer = null
}
}
function stop() {
clearIdleTimer()
if (stopped.value) return
stopped.value = true
options.onStop()
}
function handleActivity() {
if (document.hidden) return
if (stopped.value) {
stopped.value = false
options.onRestart()
}
clearIdleTimer()
idleTimer = setTimeout(stop, idleMs)
}
function handleVisibilityChange() {
if (document.hidden) {
stop()
} else {
handleActivity()
}
}
for (const eventName of ACTIVITY_EVENTS) {
window.addEventListener(eventName, handleActivity, { passive: true, capture: true })
}
window.addEventListener(GAMEPAD_ACTIVITY_EVENT, handleActivity, { passive: true, capture: true })
document.addEventListener("visibilitychange", handleVisibilityChange)
idleTimer = setTimeout(stop, idleMs)
onUnmounted(() => {
for (const eventName of ACTIVITY_EVENTS) {
window.removeEventListener(eventName, handleActivity, { capture: true })
}
window.removeEventListener(GAMEPAD_ACTIVITY_EVENT, handleActivity, { capture: true })
document.removeEventListener("visibilitychange", handleVisibilityChange)
clearIdleTimer()
})
return { stopped }
}
+4
View File
@@ -420,6 +420,10 @@ html:not(.mouse-active) .btn-secondary.nav-focused {
outline: none; outline: none;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
/* Skip rendering work for cards scrolled out of view (long rows). Width is
fixed; the intrinsic height is only a pre-first-render estimate. */
content-visibility: auto;
contain-intrinsic-height: auto 330px;
} }
html.mouse-active .media-card:hover, html.mouse-active .media-card:hover,