39948-vm/frontend/src/hooks/useAudioEffects.ts
2026-06-04 11:08:23 +02:00

324 lines
10 KiB
TypeScript

/**
* useAudioEffects Hook
*
* Manages audio playback for element hover and click effects.
* Uses HTML5 Audio API for lightweight, non-visual audio playback.
*
* Behavior:
* - Hover audio: Plays to completion once started (not interrupted by mouse leave)
* - Click audio: Interruptible - clicking again restarts from beginning
*
* Cross-browser notes:
* - iOS Safari: Volume property is read-only (always 1.0)
* - Mobile browsers: Require user interaction before audio can play
* - Touch on element counts as user gesture (unlocks audio context)
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { logger } from '../lib/logger';
/**
* Fetch audio file with credentials and return a blob URL.
* This handles authenticated URLs that require cookies/headers.
*/
async function fetchAudioAsBlobUrl(url: string): Promise<string | null> {
try {
const response = await fetch(url, {
credentials: 'include', // Include cookies for auth
});
if (!response.ok) {
logger.warn('[AudioEffects] Failed to fetch audio:', {
status: response.status,
});
return null;
}
const blob = await response.blob();
return URL.createObjectURL(blob);
} catch (error) {
logger.warn('[AudioEffects] Error fetching audio:', error);
return null;
}
}
export interface UseAudioEffectsOptions {
/** URL of audio to play on hover */
hoverAudioUrl?: string;
/** URL of audio to play on click/active */
clickAudioUrl?: string;
/** Volume level 0-1 (iOS ignores this) */
volume?: number;
/** Current hover state from useElementEffects */
isHovered: boolean;
/** Current active/pressed state from useElementEffects */
isActive: boolean;
/** Optional URL resolver for preloaded blob URLs */
resolveUrl?: (url: string | undefined) => string;
/** Reset key to stop audio (e.g., element ID or page slug) */
resetKey?: string | number;
}
export interface UseAudioEffectsResult {
/** Manually trigger click audio playback */
playClickAudio: () => void;
/** Stop all audio playback */
stopAll: () => void;
}
/**
* Hook for managing audio playback on element hover and click.
*/
export function useAudioEffects({
hoverAudioUrl,
clickAudioUrl,
volume = 1,
isHovered,
isActive,
resolveUrl,
resetKey,
}: UseAudioEffectsOptions): UseAudioEffectsResult {
const hoverAudioRef = useRef<HTMLAudioElement | null>(null);
const clickAudioRef = useRef<HTMLAudioElement | null>(null);
const hoverBlobUrlRef = useRef<string | null>(null);
const clickBlobUrlRef = useRef<string | null>(null);
const wasHoveredRef = useRef(false);
const wasActiveRef = useRef(false);
// Delay audio playback after mount/reset to avoid browser autoplay policy
// User can't hover during mount, so any true state within 100ms is stale
const [canPlay, setCanPlay] = useState(false);
// Track which URLs we've already fetched to prevent duplicate fetches
const lastFetchedHoverUrlRef = useRef<string | null>(null);
const lastFetchedClickUrlRef = useRef<string | null>(null);
// Store resolveUrl in ref to avoid dependency issues
const resolveUrlRef = useRef(resolveUrl);
resolveUrlRef.current = resolveUrl;
// Track loading state
const [hoverAudioReady, setHoverAudioReady] = useState(false);
const [clickAudioReady, setClickAudioReady] = useState(false);
// Enable audio playback after short delay to skip stale state at mount
// Any hover/active state within 100ms of mount is not from real user interaction
useEffect(() => {
setCanPlay(false);
wasHoveredRef.current = isHovered;
wasActiveRef.current = isActive;
const timer = setTimeout(() => setCanPlay(true), 100);
return () => clearTimeout(timer);
// Only reset on resetKey change, not on isHovered/isActive changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetKey]);
// Initialize hover audio element - fetch with credentials for auth
useEffect(() => {
if (!hoverAudioUrl) {
hoverAudioRef.current = null;
setHoverAudioReady(false);
lastFetchedHoverUrlRef.current = null;
return;
}
// Skip if we already fetched this URL
if (
lastFetchedHoverUrlRef.current === hoverAudioUrl &&
hoverAudioRef.current
) {
return;
}
let cancelled = false;
const resolve = resolveUrlRef.current;
const resolvedUrl = resolve ? resolve(hoverAudioUrl) : hoverAudioUrl;
logger.debug('[AudioEffects] Initializing hover audio:', {
original: hoverAudioUrl,
resolved: resolvedUrl,
});
// Check if URL is already a blob URL (from preload cache)
if (resolvedUrl.startsWith('blob:')) {
const audio = new Audio(resolvedUrl);
audio.volume = Math.max(0, Math.min(1, volume));
audio.preload = 'auto';
hoverAudioRef.current = audio;
setHoverAudioReady(true);
lastFetchedHoverUrlRef.current = hoverAudioUrl;
logger.debug('[AudioEffects] Hover audio ready (blob URL)');
return;
}
// Fetch with credentials and create blob URL
fetchAudioAsBlobUrl(resolvedUrl).then((blobUrl) => {
if (cancelled || !blobUrl) return;
// Clean up previous blob URL if any
if (hoverBlobUrlRef.current) {
URL.revokeObjectURL(hoverBlobUrlRef.current);
}
hoverBlobUrlRef.current = blobUrl;
const audio = new Audio(blobUrl);
audio.volume = Math.max(0, Math.min(1, volume));
audio.preload = 'auto';
hoverAudioRef.current = audio;
setHoverAudioReady(true);
lastFetchedHoverUrlRef.current = hoverAudioUrl;
logger.debug('[AudioEffects] Hover audio ready (fetched)');
});
return () => {
cancelled = true;
};
}, [hoverAudioUrl, volume]);
// Initialize click audio element - fetch with credentials for auth
useEffect(() => {
if (!clickAudioUrl) {
clickAudioRef.current = null;
setClickAudioReady(false);
lastFetchedClickUrlRef.current = null;
return;
}
// Skip if we already fetched this URL
if (
lastFetchedClickUrlRef.current === clickAudioUrl &&
clickAudioRef.current
) {
return;
}
let cancelled = false;
const resolve = resolveUrlRef.current;
const resolvedUrl = resolve ? resolve(clickAudioUrl) : clickAudioUrl;
// Check if URL is already a blob URL (from preload cache)
if (resolvedUrl.startsWith('blob:')) {
const audio = new Audio(resolvedUrl);
audio.volume = Math.max(0, Math.min(1, volume));
audio.preload = 'auto';
clickAudioRef.current = audio;
setClickAudioReady(true);
lastFetchedClickUrlRef.current = clickAudioUrl;
return;
}
// Fetch with credentials and create blob URL
fetchAudioAsBlobUrl(resolvedUrl).then((blobUrl) => {
if (cancelled || !blobUrl) return;
// Clean up previous blob URL if any
if (clickBlobUrlRef.current) {
URL.revokeObjectURL(clickBlobUrlRef.current);
}
clickBlobUrlRef.current = blobUrl;
const audio = new Audio(blobUrl);
audio.volume = Math.max(0, Math.min(1, volume));
audio.preload = 'auto';
clickAudioRef.current = audio;
setClickAudioReady(true);
lastFetchedClickUrlRef.current = clickAudioUrl;
logger.debug('[AudioEffects] Click audio ready (fetched)');
});
return () => {
cancelled = true;
};
}, [clickAudioUrl, volume]);
// Clean up blob URLs on unmount
useEffect(() => {
return () => {
if (hoverBlobUrlRef.current) {
URL.revokeObjectURL(hoverBlobUrlRef.current);
}
if (clickBlobUrlRef.current) {
URL.revokeObjectURL(clickBlobUrlRef.current);
}
};
}, []);
// Update volume on both audio elements when it changes
useEffect(() => {
const clampedVolume = Math.max(0, Math.min(1, volume));
if (hoverAudioRef.current) {
hoverAudioRef.current.volume = clampedVolume;
}
if (clickAudioRef.current) {
clickAudioRef.current.volume = clampedVolume;
}
}, [volume]);
// Play hover audio when hover starts (plays to completion, not interrupted)
useEffect(() => {
// Only play if: canPlay is true, hover changed from false→true, audio is ready
if (
canPlay &&
isHovered &&
!wasHoveredRef.current &&
hoverAudioRef.current &&
hoverAudioReady
) {
logger.debug('[AudioEffects] Playing hover audio...');
hoverAudioRef.current.currentTime = 0;
// Graceful autoplay handling - catch and ignore autoplay restrictions
// Audio will work after user's first click/tap on any element
hoverAudioRef.current.play().catch((err) => {
logger.warn('[AudioEffects] Play failed:', err);
});
}
wasHoveredRef.current = isHovered;
}, [canPlay, isHovered, hoverAudioReady]);
// Play click audio when active state begins
useEffect(() => {
// Only play if: canPlay is true, active changed from false→true, audio is ready
if (
canPlay &&
isActive &&
!wasActiveRef.current &&
clickAudioRef.current &&
clickAudioReady
) {
clickAudioRef.current.currentTime = 0;
// Click implies user interaction, so this should rarely fail
clickAudioRef.current.play().catch(() => {
// Silent fail for edge cases
});
}
wasActiveRef.current = isActive;
}, [canPlay, isActive, clickAudioReady]);
// Manual click audio trigger
const playClickAudio = useCallback(() => {
if (clickAudioRef.current && clickAudioReady) {
clickAudioRef.current.currentTime = 0;
// eslint-disable-next-line @typescript-eslint/no-empty-function
clickAudioRef.current.play().catch(() => {});
}
}, [clickAudioReady]);
// Stop all audio playback
const stopAll = useCallback(() => {
if (hoverAudioRef.current) {
hoverAudioRef.current.pause();
hoverAudioRef.current.currentTime = 0;
}
if (clickAudioRef.current) {
clickAudioRef.current.pause();
clickAudioRef.current.currentTime = 0;
}
}, []);
// Stop audio on key change (e.g., page navigation or element change)
// Note: canPlay and refs are reset in the canPlay effect above
useEffect(() => {
stopAll();
}, [resetKey, stopAll]);
return { playClickAudio, stopAll };
}