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:
@@ -18,6 +18,7 @@
|
||||
:src="posterImageUrl"
|
||||
:alt="item.title || 'Unknown'"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div v-else class="media-card-placeholder">
|
||||
|
||||
@@ -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(() => {
|
||||
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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user