Refine hover reel audio fades and idle behavior

This commit is contained in:
2026-05-30 04:38:33 +00:00
parent bb4bd3723f
commit 7a0247d78f
2 changed files with 155 additions and 69 deletions
+79 -35
View File
@@ -399,53 +399,94 @@ function startStaggeredPlayback() {
// Volume fade animation tracking
const volumeFadeIntervals = new Map<number, ReturnType<typeof setInterval>>()
const AUDIO_FADE_STEP = 0.04
const AUDIO_FADE_INTERVAL_MS = 40
const AUDIO_IDLE_FADE_DELAY_MS = 1600
const AUDIO_LEAVE_FADE_DELAY_MS = 350
const AUDIO_HOVER_TARGET_VOLUME = 0.5
let hoveredVideoIndex: number | null = null
let hoverAudioIdleTimer: ReturnType<typeof setTimeout> | null = null
// Handle hover-based audio fade in/out
function handleVideoHover(index: number, isEntering: boolean) {
function clearHoverAudioIdleTimer() {
if (hoverAudioIdleTimer !== null) {
clearTimeout(hoverAudioIdleTimer)
hoverAudioIdleTimer = null
}
}
function rampVideoVolume(index: number, targetVolume: number) {
const video = videoRefs.value[index]
if (!video) return
// Clear any existing fade for this video
const existingInterval = volumeFadeIntervals.get(index)
if (existingInterval) {
clearInterval(existingInterval)
volumeFadeIntervals.delete(index)
}
if (isEntering) {
// Mute all other videos immediately
document.querySelectorAll("video").forEach((v) => {
if (v !== video) {
v.volume = 0
v.muted = true
}
})
// Fade in this video's audio
video.muted = false
const fadeIn = setInterval(() => {
if (video.volume < 0.95) {
video.volume = Math.min(1, video.volume + 0.1)
} else {
video.volume = 1
clearInterval(fadeIn)
volumeFadeIntervals.delete(index)
}
}, 30)
volumeFadeIntervals.set(index, fadeIn)
} else {
// Fade out this video's audio
const fadeOut = setInterval(() => {
if (video.volume > 0.05) {
video.volume = Math.max(0, video.volume - 0.1)
} else {
const clampedTarget = Math.max(0, Math.min(1, targetVolume))
if (clampedTarget > 0 && video.muted) {
video.volume = 0
video.muted = true
clearInterval(fadeOut)
volumeFadeIntervals.delete(index)
}
}, 30)
volumeFadeIntervals.set(index, fadeOut)
if (clampedTarget > 0) {
video.muted = false
}
const fadeInterval = setInterval(() => {
const delta = clampedTarget - video.volume
if (Math.abs(delta) <= AUDIO_FADE_STEP) {
video.volume = clampedTarget
if (clampedTarget <= 0.001) {
video.muted = true
}
clearInterval(fadeInterval)
volumeFadeIntervals.delete(index)
return
}
video.volume += delta > 0 ? AUDIO_FADE_STEP : -AUDIO_FADE_STEP
}, AUDIO_FADE_INTERVAL_MS)
volumeFadeIntervals.set(index, fadeInterval)
}
function scheduleHoverAudioIdleFade(index: number, delayMs: number = AUDIO_IDLE_FADE_DELAY_MS) {
clearHoverAudioIdleTimer()
hoverAudioIdleTimer = setTimeout(() => {
rampVideoVolume(index, 0)
if (hoveredVideoIndex === index) {
hoveredVideoIndex = null
}
}, delayMs)
}
function handleHoverAudioMouseMove() {
if (!document.documentElement.classList.contains("mouse-active")) return
if (hoveredVideoIndex === null) return
scheduleHoverAudioIdleFade(hoveredVideoIndex)
}
// Handle hover-based audio fade in/out
function handleVideoHover(index: number, isEntering: boolean) {
if (!document.documentElement.classList.contains("mouse-active")) return
const video = videoRefs.value[index]
if (!video) return
if (isEntering) {
hoveredVideoIndex = index
for (let i = 0; i < videoRefs.value.length; i++) {
if (i !== index && videoRefs.value[i]) {
rampVideoVolume(i, 0)
}
}
rampVideoVolume(index, AUDIO_HOVER_TARGET_VOLUME)
scheduleHoverAudioIdleFade(index)
} else {
if (hoveredVideoIndex === index) {
hoveredVideoIndex = null
}
scheduleHoverAudioIdleFade(index, AUDIO_LEAVE_FADE_DELAY_MS)
}
}
@@ -859,11 +900,14 @@ function handleResize() {
onMounted(() => {
document.addEventListener("keydown", handleMovieMenuKeydown, true)
window.addEventListener("resize", handleResize)
window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true })
})
onUnmounted(() => {
document.removeEventListener("keydown", handleMovieMenuKeydown, true)
window.removeEventListener("resize", handleResize)
window.removeEventListener("mousemove", handleHoverAudioMouseMove)
clearHoverAudioIdleTimer()
disposeOutOfBoundsHandler?.()
disposeOutOfBoundsHandler = null
lastReleaseShortcutRow = null
+76 -34
View File
@@ -921,6 +921,72 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) {
// Volume fade animation tracking
const volumeFadeIntervals = new Map<string, ReturnType<typeof setInterval>>()
const AUDIO_FADE_STEP = 0.04
const AUDIO_FADE_INTERVAL_MS = 40
const AUDIO_IDLE_FADE_DELAY_MS = 1600
const AUDIO_LEAVE_FADE_DELAY_MS = 350
const AUDIO_HOVER_TARGET_VOLUME = 0.5
let hoveredEpisodeAudioKey: string | null = null
let hoverEpisodeAudioIdleTimer: ReturnType<typeof setTimeout> | null = null
function clearEpisodeHoverAudioIdleTimer() {
if (hoverEpisodeAudioIdleTimer !== null) {
clearTimeout(hoverEpisodeAudioIdleTimer)
hoverEpisodeAudioIdleTimer = null
}
}
function rampEpisodeVolume(key: string, targetVolume: number) {
const video = videoRefs.value.get(key)
if (!video) return
const existingInterval = volumeFadeIntervals.get(key)
if (existingInterval) {
clearInterval(existingInterval)
volumeFadeIntervals.delete(key)
}
const clampedTarget = Math.max(0, Math.min(1, targetVolume))
if (clampedTarget > 0 && video.muted) {
video.volume = 0
}
if (clampedTarget > 0) {
video.muted = false
}
const fadeInterval = setInterval(() => {
const delta = clampedTarget - video.volume
if (Math.abs(delta) <= AUDIO_FADE_STEP) {
video.volume = clampedTarget
if (clampedTarget <= 0.001) {
video.muted = true
}
clearInterval(fadeInterval)
volumeFadeIntervals.delete(key)
return
}
video.volume += delta > 0 ? AUDIO_FADE_STEP : -AUDIO_FADE_STEP
}, AUDIO_FADE_INTERVAL_MS)
volumeFadeIntervals.set(key, fadeInterval)
}
function scheduleEpisodeHoverAudioIdleFade(key: string, delayMs: number = AUDIO_IDLE_FADE_DELAY_MS) {
clearEpisodeHoverAudioIdleTimer()
hoverEpisodeAudioIdleTimer = setTimeout(() => {
rampEpisodeVolume(key, 0)
if (hoveredEpisodeAudioKey === key) {
hoveredEpisodeAudioKey = null
}
}, delayMs)
}
function handleEpisodeHoverAudioMouseMove() {
if (!document.documentElement.classList.contains("mouse-active")) return
if (!hoveredEpisodeAudioKey) return
scheduleEpisodeHoverAudioIdleFade(hoveredEpisodeAudioKey)
}
// Handle hover-based audio fade in/out for episode videos
function handleEpisodeHover(eventOrKey: MouseEvent | string, keyOrIsEntering: string | boolean, maybeIsEntering?: boolean) {
@@ -945,47 +1011,20 @@ function handleEpisodeHover(eventOrKey: MouseEvent | string, keyOrIsEntering: st
if (parsed.seasonIndex !== activeSeasonIndex.value) return
// Clear any existing fade for this video
const existingInterval = volumeFadeIntervals.get(key)
if (existingInterval) {
clearInterval(existingInterval)
volumeFadeIntervals.delete(key)
}
if (isEntering) {
// Mute all other videos immediately
hoveredEpisodeAudioKey = key
videoRefs.value.forEach((v, k) => {
if (k !== key) {
v.volume = 0
v.muted = true
rampEpisodeVolume(k, 0)
}
})
// Fade in this video's audio
video.muted = false
const fadeIn = setInterval(() => {
if (video.volume < 0.95) {
video.volume = Math.min(1, video.volume + 0.1)
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
scheduleEpisodeHoverAudioIdleFade(key)
} else {
video.volume = 1
clearInterval(fadeIn)
volumeFadeIntervals.delete(key)
if (hoveredEpisodeAudioKey === key) {
hoveredEpisodeAudioKey = null
}
}, 30)
volumeFadeIntervals.set(key, fadeIn)
} else {
// Fade out this video's audio
const fadeOut = setInterval(() => {
if (video.volume > 0.05) {
video.volume = Math.max(0, video.volume - 0.1)
} else {
video.volume = 0
video.muted = true
clearInterval(fadeOut)
volumeFadeIntervals.delete(key)
}
}, 30)
volumeFadeIntervals.set(key, fadeOut)
scheduleEpisodeHoverAudioIdleFade(key, AUDIO_LEAVE_FADE_DELAY_MS)
}
}
@@ -1096,6 +1135,7 @@ onMounted(() => {
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.addEventListener("resize", scheduleEpisodeNavLayoutRecompute, { passive: true })
window.addEventListener("resize", scheduleSeasonLayoutRecompute, { passive: true })
window.addEventListener("mousemove", handleEpisodeHoverAudioMouseMove, { passive: true })
nextTick(() => {
const focusSeasonNumber = props.focusEpisode?.seasonNumber
if (typeof focusSeasonNumber === "number") {
@@ -1115,6 +1155,8 @@ onUnmounted(() => {
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.removeEventListener("resize", scheduleEpisodeNavLayoutRecompute)
window.removeEventListener("resize", scheduleSeasonLayoutRecompute)
window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove)
clearEpisodeHoverAudioIdleTimer()
clearSeasonStartupTimers()
if (seasonLayoutFrame !== null) {
window.cancelAnimationFrame(seasonLayoutFrame)