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"
|
: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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user