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"
:alt="item.title || 'Unknown'"
loading="lazy"
decoding="async"
@error="imageError = true"
/>
<div v-else class="media-card-placeholder">
+74 -4
View File
@@ -17,7 +17,7 @@
<div v-else class="movie-page">
<div class="movie-page-content">
<!-- Diagonal collage header -->
<div class="collage-header">
<div ref="collageHeaderRef" class="collage-header">
<!-- Background collage of showreel videos -->
<div class="collage-grid">
<div
@@ -253,6 +253,7 @@ import {
FOCUSABLE_ATTR,
setModalOpen,
} from "../composables/useKeyboardNavigation"
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
const props = defineProps<{
item: MediaItem
@@ -278,6 +279,21 @@ const safariAutoplay = isSafariBrowser()
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2]
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 lastReleaseShortcutRow: number | null = null
@@ -362,15 +378,35 @@ function isVideoReady(index: number): boolean {
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
function startStaggeredPlayback() {
cancelStaggeredPlayback()
const token = staggerToken
const videos = videoRefs.value.filter((v) => v !== null) as HTMLVideoElement[]
if (videos.length === 0) return
if (videos.length === 0 || previewsSuppressed()) return
if (safariAutoplay) {
videos.forEach((video, index) => {
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0
const startVideo = () => {
if (token !== staggerToken || previewsSuppressed()) return
video.currentTime = offset
video.play().catch(() => {})
}
@@ -390,11 +426,29 @@ function startStaggeredPlayback() {
// Set up staggered start for remaining videos
for (let i = 1; i < videos.length; i++) {
setTimeout(() => {
scheduleStaggeredStart(
token,
() => {
const video = videos[i]
if (!video) return
video.play().catch(() => {})
}, i * 2000)
},
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()
}, 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()
})
@@ -990,6 +1057,9 @@ onUnmounted(() => {
window.removeEventListener("mousemove", handleHoverAudioMouseMove)
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
clearHoverAudioIdleTimer()
cancelStaggeredPlayback()
collageHeaderObserver?.disconnect()
collageHeaderObserver = null
disposeOutOfBoundsHandler?.()
disposeOutOfBoundsHandler = null
lastReleaseShortcutRow = null
+90 -4
View File
@@ -62,6 +62,8 @@
:src="getSeasonPoster(season)"
class="season-poster-img"
:alt="season.name || `Season ${season.season_number}`"
loading="lazy"
decoding="async"
/>
<div v-else class="poster-placeholder">
<span class="poster-num">{{ season.season_number }}</span>
@@ -99,7 +101,7 @@
v-if="getEpisodeVideoSources(episode).length > 0"
:ref="(el) => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)"
:autoplay="false"
preload="auto"
:preload="sIndex === activeSeasonIndex ? 'auto' : 'none'"
loop
muted
playsinline
@@ -153,6 +155,8 @@
:src="getCoverUrl(movie.cover_path, movie.root_id)"
class="linked-movie-poster"
:alt="movie.title || 'Movie'"
loading="lazy"
decoding="async"
/>
<div v-else class="linked-movie-poster linked-movie-poster-fallback"></div>
<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 { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings"
@@ -636,11 +641,22 @@ function handleOpenFolderFromMenu(filePath: string) {
// Video refs for hover effects
const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map())
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 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>()
let episodeVisibilityObserver: IntersectionObserver | null = null
const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({
onStop: stopEpisodePreviews,
onRestart: () => syncSeasonVideoPlayback(),
})
function parseEpisodeKey(key: string): { seasonIndex: number; episodeIndex: number } | null {
const [seasonPart, episodePart] = key.split("-")
const seasonIndex = parseInt(seasonPart || "", 10)
@@ -658,6 +674,47 @@ function clearSeasonStartupTimers() {
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) {
video.pause()
if (video.readyState >= 1) {
@@ -685,11 +742,24 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
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)
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)
activeSeasonVideos.push({
key,
@@ -710,7 +780,8 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
const { key, video } = activeSeasonVideos[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) return
if (token !== seasonStartupToken || activeSeasonIndex.value < 0 || previewPlaybackStopped.value)
return
if (safariAutoplay && video.readyState >= 1) {
video.currentTime = 0.001
}
@@ -751,6 +822,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)
}
el.addEventListener(
"loadeddata",
() => {
@@ -768,6 +844,12 @@ 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)
@@ -1015,6 +1097,10 @@ onUnmounted(() => {
window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove)
clearEpisodeHoverAudioIdleTimer()
clearSeasonStartupTimers()
episodeVisibilityObserver?.disconnect()
episodeVisibilityObserver = null
episodeTileByKey.clear()
visibleEpisodeKeys.clear()
if (seasonLayoutFrame !== null) {
window.cancelAnimationFrame(seasonLayoutFrame)
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;
text-decoration: none;
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,