added audio effects
This commit is contained in:
parent
cd4ffb2c90
commit
0d39e916f6
@ -124,7 +124,7 @@ app.use(
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}),
|
||||
);
|
||||
app.use(cors({ origin: true }));
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
require('./auth/auth');
|
||||
|
||||
// Request logger applied early so all routes are logged
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import UiElementRenderer from '../UiElements/UiElementRenderer';
|
||||
import { useElementEffects } from '../../hooks/useElementEffects';
|
||||
import { useAudioEffects } from '../../hooks/useAudioEffects';
|
||||
import {
|
||||
buildTransitionStyle,
|
||||
buildAppearAnimationStyle,
|
||||
@ -90,14 +91,34 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
hoverRevealDelay: element.hoverRevealDelay,
|
||||
hoverRevealPersist: element.hoverRevealPersist,
|
||||
hoverPersistOnClick: element.hoverPersistOnClick,
|
||||
// Audio effects
|
||||
hoverAudioUrl: element.hoverAudioUrl,
|
||||
clickAudioUrl: element.clickAudioUrl,
|
||||
audioVolume: element.audioVolume,
|
||||
};
|
||||
|
||||
// Use effects hook - disabled in edit mode to avoid interfering with dragging
|
||||
// Pass forceVisible when info panel is open to keep trigger visible
|
||||
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
|
||||
isEditMode ? {} : effectProperties,
|
||||
{ forceVisible: isInfoPanelOpen },
|
||||
);
|
||||
const {
|
||||
effectStyle,
|
||||
eventHandlers,
|
||||
onPersistClick,
|
||||
state: effectState,
|
||||
} = useElementEffects(isEditMode ? {} : effectProperties, {
|
||||
forceVisible: isInfoPanelOpen,
|
||||
});
|
||||
|
||||
// Audio effects - only active in preview mode (not edit mode)
|
||||
// Uses resolveUrl prop to resolve preloaded blob URLs
|
||||
useAudioEffects({
|
||||
hoverAudioUrl: isEditMode ? undefined : effectProperties.hoverAudioUrl,
|
||||
clickAudioUrl: isEditMode ? undefined : effectProperties.clickAudioUrl,
|
||||
volume: parseFloat(effectProperties.audioVolume || '1'),
|
||||
isHovered: effectState.isHovered,
|
||||
isActive: effectState.isActive,
|
||||
resolveUrl, // Already available as prop
|
||||
resetKey: element.id,
|
||||
});
|
||||
|
||||
// Clamp position to canvas bounds (0-100%)
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
|
||||
@ -1682,6 +1682,10 @@ export function ElementEditorPanel({
|
||||
hoverPersistOnClick: selectedElement.hoverPersistOnClick
|
||||
? 'true'
|
||||
: '',
|
||||
// Audio effects
|
||||
hoverAudioUrl: selectedElement.hoverAudioUrl || '',
|
||||
clickAudioUrl: selectedElement.clickAudioUrl || '',
|
||||
audioVolume: selectedElement.audioVolume || '1',
|
||||
// Slide transition values (gallery/carousel)
|
||||
slideTransitionType:
|
||||
selectedElement.type === 'gallery'
|
||||
@ -1792,6 +1796,7 @@ export function ElementEditorPanel({
|
||||
});
|
||||
}
|
||||
}}
|
||||
audioAssetOptions={assetOptions.audio}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -7,17 +7,18 @@
|
||||
|
||||
import React from 'react';
|
||||
import type { EffectsSettingsFormValues } from './types';
|
||||
import type { CanvasElementType } from '../../types/constructor';
|
||||
import type { CanvasElementType, AssetOption } from '../../types/constructor';
|
||||
|
||||
interface EffectsSettingsSectionCompactProps {
|
||||
values: EffectsSettingsFormValues;
|
||||
onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void;
|
||||
elementType?: CanvasElementType;
|
||||
audioAssetOptions?: AssetOption[];
|
||||
}
|
||||
|
||||
const EffectsSettingsSectionCompact: React.FC<
|
||||
EffectsSettingsSectionCompactProps
|
||||
> = ({ values, onChange, elementType }) => {
|
||||
> = ({ values, onChange, elementType, audioAssetOptions }) => {
|
||||
const showSlideTransition =
|
||||
elementType === 'gallery' || elementType === 'carousel';
|
||||
return (
|
||||
@ -152,6 +153,24 @@ const EffectsSettingsSectionCompact: React.FC<
|
||||
placeholder='0.2'
|
||||
/>
|
||||
</div>
|
||||
{/* Hover Sound */}
|
||||
<div className='col-span-2 mt-2 pt-2 border-t border-white/10'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Hover Sound
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values.hoverAudioUrl || ''}
|
||||
onChange={(e) => onChange('hoverAudioUrl', e.target.value)}
|
||||
>
|
||||
<option value=''>None</option>
|
||||
{audioAssetOptions?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className='col-span-2'>
|
||||
<label className='mb-1 flex items-center gap-2 text-[10px] text-white/60'>
|
||||
<input
|
||||
@ -351,9 +370,50 @@ const EffectsSettingsSectionCompact: React.FC<
|
||||
placeholder='#131C22'
|
||||
/>
|
||||
</div>
|
||||
{/* Click Sound */}
|
||||
<div className='col-span-2 mt-2 pt-2 border-t border-white/10'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Click Sound
|
||||
</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values.clickAudioUrl || ''}
|
||||
onChange={(e) => onChange('clickAudioUrl', e.target.value)}
|
||||
>
|
||||
<option value=''>None</option>
|
||||
{audioAssetOptions?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio Volume - only show if either audio is set */}
|
||||
{(values.hoverAudioUrl || values.clickAudioUrl) && (
|
||||
<div className='mt-3 pt-3 border-t border-white/20'>
|
||||
<p className='mb-2 text-[11px] font-semibold text-white/90'>
|
||||
Audio Volume
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<input
|
||||
type='range'
|
||||
min='0'
|
||||
max='1'
|
||||
step='0.1'
|
||||
className='flex-1'
|
||||
value={values.audioVolume || '1'}
|
||||
onChange={(e) => onChange('audioVolume', e.target.value)}
|
||||
/>
|
||||
<span className='text-[10px] text-white/60 w-8'>
|
||||
{Math.round(parseFloat(values.audioVolume || '1') * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slide Transition Override - Gallery/Carousel only */}
|
||||
{showSlideTransition && (
|
||||
<div className='mt-3 border-t border-white/20 pt-3'>
|
||||
|
||||
@ -64,6 +64,10 @@ export interface EffectsSettingsFormValues {
|
||||
hoverRevealPersist?: string;
|
||||
// Persist hover effects after click
|
||||
hoverPersistOnClick?: string;
|
||||
// Audio effects
|
||||
hoverAudioUrl?: string;
|
||||
clickAudioUrl?: string;
|
||||
audioVolume?: string;
|
||||
// Slide transition override (Gallery/Carousel only)
|
||||
// These override page transition settings for this element's slides
|
||||
slideTransitionType?: string;
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
import React from 'react';
|
||||
import UiElementRenderer from './UiElements/UiElementRenderer';
|
||||
import { useElementEffects } from '../hooks/useElementEffects';
|
||||
import { useAudioEffects } from '../hooks/useAudioEffects';
|
||||
import {
|
||||
buildTransitionStyle,
|
||||
buildAppearAnimationStyle,
|
||||
@ -63,13 +64,27 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
|
||||
// Use effects hook for interactive states
|
||||
// Pass forceVisible when info panel is open to keep trigger visible
|
||||
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
|
||||
effectProperties,
|
||||
{
|
||||
resetKey: element.id, // Reset reveal on element change
|
||||
forceVisible: isInfoPanelOpen,
|
||||
},
|
||||
);
|
||||
const {
|
||||
effectStyle,
|
||||
eventHandlers,
|
||||
onPersistClick,
|
||||
state: effectState,
|
||||
} = useElementEffects(effectProperties, {
|
||||
resetKey: element.id, // Reset reveal on element change
|
||||
forceVisible: isInfoPanelOpen,
|
||||
});
|
||||
|
||||
// Audio effects - uses exposed state from useElementEffects
|
||||
// resolveUrl prop resolves to preloaded blob URLs via RuntimePresentation
|
||||
useAudioEffects({
|
||||
hoverAudioUrl: effectProperties.hoverAudioUrl,
|
||||
clickAudioUrl: effectProperties.clickAudioUrl,
|
||||
volume: parseFloat(effectProperties.audioVolume || '1'),
|
||||
isHovered: effectState.isHovered,
|
||||
isActive: effectState.isActive,
|
||||
resolveUrl, // Resolves to cached blob URLs (passed from RuntimePresentation)
|
||||
resetKey: element.id,
|
||||
});
|
||||
|
||||
// Combined click handler
|
||||
// Skip toggle for info panel elements (their visibility is tied to panel open state)
|
||||
|
||||
@ -126,7 +126,10 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener(
|
||||
'webkitfullscreenchange',
|
||||
handleFullscreenChange,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -140,17 +143,35 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
// Enter fullscreen
|
||||
if (panel.requestFullscreen) {
|
||||
await panel.requestFullscreen();
|
||||
} else if ((panel as HTMLDivElement & { webkitRequestFullscreen?: () => Promise<void> }).webkitRequestFullscreen) {
|
||||
} else if (
|
||||
(
|
||||
panel as HTMLDivElement & {
|
||||
webkitRequestFullscreen?: () => Promise<void>;
|
||||
}
|
||||
).webkitRequestFullscreen
|
||||
) {
|
||||
// Safari fallback
|
||||
await (panel as HTMLDivElement & { webkitRequestFullscreen: () => Promise<void> }).webkitRequestFullscreen();
|
||||
await (
|
||||
panel as HTMLDivElement & {
|
||||
webkitRequestFullscreen: () => Promise<void>;
|
||||
}
|
||||
).webkitRequestFullscreen();
|
||||
}
|
||||
} else {
|
||||
// Exit fullscreen
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
} else if ((document as Document & { webkitExitFullscreen?: () => Promise<void> }).webkitExitFullscreen) {
|
||||
} else if (
|
||||
(
|
||||
document as Document & {
|
||||
webkitExitFullscreen?: () => Promise<void>;
|
||||
}
|
||||
).webkitExitFullscreen
|
||||
) {
|
||||
// Safari fallback
|
||||
await (document as Document & { webkitExitFullscreen: () => Promise<void> }).webkitExitFullscreen();
|
||||
await (
|
||||
document as Document & { webkitExitFullscreen: () => Promise<void> }
|
||||
).webkitExitFullscreen();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@ -222,7 +243,8 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
// Zero doesn't need a unit
|
||||
if (trimmed === '0') return '0';
|
||||
// Already uses canvas units - return as-is
|
||||
if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed;
|
||||
if (trimmed.includes('var(--cu') || trimmed.includes('--cu'))
|
||||
return trimmed;
|
||||
// CSS functions (calc, var, min, max, etc.) - return as-is
|
||||
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
|
||||
// Already has a unit suffix - return as-is
|
||||
@ -420,7 +442,9 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
||||
type='button'
|
||||
className='absolute top-2 right-10 z-10 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors'
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
aria-label={
|
||||
isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'
|
||||
}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg
|
||||
|
||||
@ -251,7 +251,8 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
||||
// Zero doesn't need a unit
|
||||
if (trimmed === '0') return '0';
|
||||
// Already uses canvas units - return as-is
|
||||
if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed;
|
||||
if (trimmed.includes('var(--cu') || trimmed.includes('--cu'))
|
||||
return trimmed;
|
||||
// CSS functions (calc, var, min, max, etc.) - return as-is
|
||||
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
|
||||
// Already has a unit suffix - return as-is
|
||||
|
||||
@ -85,6 +85,8 @@ export const PRELOAD_CONFIG = {
|
||||
'url',
|
||||
'poster',
|
||||
'thumbnail',
|
||||
'hoverAudioUrl',
|
||||
'clickAudioUrl',
|
||||
] as const,
|
||||
images: [
|
||||
'iconUrl',
|
||||
|
||||
@ -103,8 +103,7 @@ export function useAssetOptions({
|
||||
() =>
|
||||
assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.asset_type === 'embed' && getAssetSourceValue(asset),
|
||||
(asset) => asset.asset_type === 'embed' && getAssetSourceValue(asset),
|
||||
)
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
|
||||
304
frontend/src/hooks/useAudioEffects.ts
Normal file
304
frontend/src/hooks/useAudioEffects.ts
Normal file
@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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(() => {
|
||||
if (
|
||||
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;
|
||||
}, [isHovered, hoverAudioReady]);
|
||||
|
||||
// Play click audio when active state begins
|
||||
useEffect(() => {
|
||||
if (
|
||||
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;
|
||||
}, [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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset audio on key change (e.g., page navigation)
|
||||
useEffect(() => {
|
||||
stopAll();
|
||||
wasHoveredRef.current = false;
|
||||
wasActiveRef.current = false;
|
||||
}, [resetKey, stopAll]);
|
||||
|
||||
return { playClickAudio, stopAll };
|
||||
}
|
||||
@ -54,6 +54,11 @@ interface UseElementEffectsResult {
|
||||
};
|
||||
/** Call this in onClick to toggle hover persistence (if enabled) */
|
||||
onPersistClick: () => void;
|
||||
/** Exposed state for audio effects integration */
|
||||
state: {
|
||||
isHovered: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -225,5 +230,9 @@ export function useElementEffects(
|
||||
onTouchEnd,
|
||||
},
|
||||
onPersistClick: onClick,
|
||||
state: {
|
||||
isHovered: state.isHovered,
|
||||
isActive: state.isActive,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -45,6 +45,9 @@ export const ELEMENT_EFFECT_PROPS = [
|
||||
'hoverRevealDelay',
|
||||
'hoverRevealPersist',
|
||||
'hoverPersistOnClick',
|
||||
'hoverAudioUrl',
|
||||
'clickAudioUrl',
|
||||
'audioVolume',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@ -78,6 +78,10 @@ export interface ElementEffectProperties {
|
||||
hoverRevealPersist?: boolean;
|
||||
// Persist hover effects after click (applies to all hover effects)
|
||||
hoverPersistOnClick?: boolean;
|
||||
// Audio effects
|
||||
hoverAudioUrl?: string;
|
||||
clickAudioUrl?: string;
|
||||
audioVolume?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -107,6 +111,9 @@ export const EFFECT_PROPS = [
|
||||
'hoverRevealDelay',
|
||||
'hoverRevealPersist',
|
||||
'hoverPersistOnClick',
|
||||
'hoverAudioUrl',
|
||||
'clickAudioUrl',
|
||||
'audioVolume',
|
||||
] as const;
|
||||
|
||||
export type EffectPropName = (typeof EFFECT_PROPS)[number];
|
||||
@ -294,7 +301,7 @@ export function hasActiveEffects(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has any effects configured.
|
||||
* Check if element has any effects configured (visual or audio).
|
||||
*/
|
||||
export function hasAnyEffects(
|
||||
effects: Partial<ElementEffectProperties>,
|
||||
@ -304,10 +311,20 @@ export function hasAnyEffects(
|
||||
hasHoverEffects(effects) ||
|
||||
hasFocusEffects(effects) ||
|
||||
hasActiveEffects(effects) ||
|
||||
hasHoverReveal(effects)
|
||||
hasHoverReveal(effects) ||
|
||||
hasAudioEffects(effects)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has any audio effects configured.
|
||||
*/
|
||||
export function hasAudioEffects(
|
||||
effects: Partial<ElementEffectProperties>,
|
||||
): boolean {
|
||||
return Boolean(effects.hoverAudioUrl || effects.clickAudioUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has hover reveal enabled.
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user