324 lines
10 KiB
TypeScript
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 };
|
|
}
|