- 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.
75 lines
2.1 KiB
TypeScript
75 lines
2.1 KiB
TypeScript
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 }
|
|
}
|