From 258fc797533f0e7d2a0c7c47386b78af7e50d73e Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 3 Sep 2026 02:23:22 +0000 Subject: [PATCH] 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. --- frontend/src/components/MediaCard.vue | 1 + frontend/src/components/MediaDetail.vue | 84 +++++++++++++++-- frontend/src/components/SeriesFullView.vue | 94 ++++++++++++++++++- .../src/composables/useIdlePreviewPlayback.ts | 74 +++++++++++++++ frontend/src/styles/main.css | 4 + 5 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 frontend/src/composables/useIdlePreviewPlayback.ts diff --git a/frontend/src/components/MediaCard.vue b/frontend/src/components/MediaCard.vue index 708e3ef..7847e1a 100644 --- a/frontend/src/components/MediaCard.vue +++ b/frontend/src/components/MediaCard.vue @@ -18,6 +18,7 @@ :src="posterImageUrl" :alt="item.title || 'Unknown'" loading="lazy" + decoding="async" @error="imageError = true" />
diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index 9d1a634..1d0ac0f 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -17,7 +17,7 @@
-
+
(null) +let collageHeaderVisible = true +let collageHeaderObserver: IntersectionObserver | null = null +const staggerTimers = new Set>() +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(() => { - const video = videos[i] - if (!video) return - video.play().catch(() => {}) - }, i * 2000) + scheduleStaggeredStart( + token, + () => { + const video = videos[i] + 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() }, 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 diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index ceded68..1e7f718 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -62,6 +62,8 @@ :src="getSeasonPoster(season)" class="season-poster-img" :alt="season.name || `Season ${season.season_number}`" + loading="lazy" + decoding="async" />
{{ season.season_number }} @@ -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" />
@@ -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>(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>() let seasonStartupToken = 0 +// Episode tiles currently near the viewport; only these are allowed to play +const visibleEpisodeKeys = new Set() +const episodeTileByKey = new Map() +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 diff --git a/frontend/src/composables/useIdlePreviewPlayback.ts b/frontend/src/composables/useIdlePreviewPlayback.ts new file mode 100644 index 0000000..2aac51d --- /dev/null +++ b/frontend/src/composables/useIdlePreviewPlayback.ts @@ -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 +} { + const idleMs = options.idleMs ?? PREVIEW_IDLE_MS + const stopped = ref(false) + let idleTimer: ReturnType | 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 } +} diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index ff9f008..f8cd9aa 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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,