From 7a0247d78feb7b6f298f02c624ed6c6023796834 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 30 May 2026 04:38:33 +0000 Subject: [PATCH] Refine hover reel audio fades and idle behavior --- frontend/src/components/MediaDetail.vue | 112 ++++++++++++++------- frontend/src/components/SeriesFullView.vue | 112 ++++++++++++++------- 2 files changed, 155 insertions(+), 69 deletions(-) diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index 51bb5ac..e6fe177 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -399,53 +399,94 @@ function startStaggeredPlayback() { // Volume fade animation tracking const volumeFadeIntervals = new Map>() +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 | 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 + const clampedTarget = Math.max(0, Math.min(1, targetVolume)) + if (clampedTarget > 0 && video.muted) { + video.volume = 0 + } + if (clampedTarget > 0) { 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 { - video.volume = 0 + } + + 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(fadeOut) - volumeFadeIntervals.delete(index) } - }, 30) - volumeFadeIntervals.set(index, fadeOut) + 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 diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 3fe277b..f9b94d2 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -921,6 +921,72 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) { // Volume fade animation tracking const volumeFadeIntervals = new Map>() +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 | 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) - } else { - video.volume = 1 - clearInterval(fadeIn) - volumeFadeIntervals.delete(key) - } - }, 30) - volumeFadeIntervals.set(key, fadeIn) + rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME) + scheduleEpisodeHoverAudioIdleFade(key) } 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) + if (hoveredEpisodeAudioKey === key) { + hoveredEpisodeAudioKey = null + } + 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)