added audio effects
This commit is contained in:
parent
cd4ffb2c90
commit
0d39e916f6
@ -124,7 +124,7 @@ app.use(
|
|||||||
crossOriginEmbedderPolicy: false,
|
crossOriginEmbedderPolicy: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use(cors({ origin: true }));
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
require('./auth/auth');
|
require('./auth/auth');
|
||||||
|
|
||||||
// Request logger applied early so all routes are logged
|
// Request logger applied early so all routes are logged
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import UiElementRenderer from '../UiElements/UiElementRenderer';
|
import UiElementRenderer from '../UiElements/UiElementRenderer';
|
||||||
import { useElementEffects } from '../../hooks/useElementEffects';
|
import { useElementEffects } from '../../hooks/useElementEffects';
|
||||||
|
import { useAudioEffects } from '../../hooks/useAudioEffects';
|
||||||
import {
|
import {
|
||||||
buildTransitionStyle,
|
buildTransitionStyle,
|
||||||
buildAppearAnimationStyle,
|
buildAppearAnimationStyle,
|
||||||
@ -90,14 +91,34 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
|||||||
hoverRevealDelay: element.hoverRevealDelay,
|
hoverRevealDelay: element.hoverRevealDelay,
|
||||||
hoverRevealPersist: element.hoverRevealPersist,
|
hoverRevealPersist: element.hoverRevealPersist,
|
||||||
hoverPersistOnClick: element.hoverPersistOnClick,
|
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
|
// Use effects hook - disabled in edit mode to avoid interfering with dragging
|
||||||
// Pass forceVisible when info panel is open to keep trigger visible
|
// Pass forceVisible when info panel is open to keep trigger visible
|
||||||
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
|
const {
|
||||||
isEditMode ? {} : effectProperties,
|
effectStyle,
|
||||||
{ forceVisible: isInfoPanelOpen },
|
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%)
|
// Clamp position to canvas bounds (0-100%)
|
||||||
const clamp = (value: number, min: number, max: number) =>
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
|||||||
@ -1682,6 +1682,10 @@ export function ElementEditorPanel({
|
|||||||
hoverPersistOnClick: selectedElement.hoverPersistOnClick
|
hoverPersistOnClick: selectedElement.hoverPersistOnClick
|
||||||
? 'true'
|
? 'true'
|
||||||
: '',
|
: '',
|
||||||
|
// Audio effects
|
||||||
|
hoverAudioUrl: selectedElement.hoverAudioUrl || '',
|
||||||
|
clickAudioUrl: selectedElement.clickAudioUrl || '',
|
||||||
|
audioVolume: selectedElement.audioVolume || '1',
|
||||||
// Slide transition values (gallery/carousel)
|
// Slide transition values (gallery/carousel)
|
||||||
slideTransitionType:
|
slideTransitionType:
|
||||||
selectedElement.type === 'gallery'
|
selectedElement.type === 'gallery'
|
||||||
@ -1792,6 +1796,7 @@ export function ElementEditorPanel({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
audioAssetOptions={assetOptions.audio}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -7,17 +7,18 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { EffectsSettingsFormValues } from './types';
|
import type { EffectsSettingsFormValues } from './types';
|
||||||
import type { CanvasElementType } from '../../types/constructor';
|
import type { CanvasElementType, AssetOption } from '../../types/constructor';
|
||||||
|
|
||||||
interface EffectsSettingsSectionCompactProps {
|
interface EffectsSettingsSectionCompactProps {
|
||||||
values: EffectsSettingsFormValues;
|
values: EffectsSettingsFormValues;
|
||||||
onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void;
|
onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void;
|
||||||
elementType?: CanvasElementType;
|
elementType?: CanvasElementType;
|
||||||
|
audioAssetOptions?: AssetOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EffectsSettingsSectionCompact: React.FC<
|
const EffectsSettingsSectionCompact: React.FC<
|
||||||
EffectsSettingsSectionCompactProps
|
EffectsSettingsSectionCompactProps
|
||||||
> = ({ values, onChange, elementType }) => {
|
> = ({ values, onChange, elementType, audioAssetOptions }) => {
|
||||||
const showSlideTransition =
|
const showSlideTransition =
|
||||||
elementType === 'gallery' || elementType === 'carousel';
|
elementType === 'gallery' || elementType === 'carousel';
|
||||||
return (
|
return (
|
||||||
@ -152,6 +153,24 @@ const EffectsSettingsSectionCompact: React.FC<
|
|||||||
placeholder='0.2'
|
placeholder='0.2'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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'>
|
<div className='col-span-2'>
|
||||||
<label className='mb-1 flex items-center gap-2 text-[10px] text-white/60'>
|
<label className='mb-1 flex items-center gap-2 text-[10px] text-white/60'>
|
||||||
<input
|
<input
|
||||||
@ -351,9 +370,50 @@ const EffectsSettingsSectionCompact: React.FC<
|
|||||||
placeholder='#131C22'
|
placeholder='#131C22'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</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 */}
|
{/* Slide Transition Override - Gallery/Carousel only */}
|
||||||
{showSlideTransition && (
|
{showSlideTransition && (
|
||||||
<div className='mt-3 border-t border-white/20 pt-3'>
|
<div className='mt-3 border-t border-white/20 pt-3'>
|
||||||
|
|||||||
@ -64,6 +64,10 @@ export interface EffectsSettingsFormValues {
|
|||||||
hoverRevealPersist?: string;
|
hoverRevealPersist?: string;
|
||||||
// Persist hover effects after click
|
// Persist hover effects after click
|
||||||
hoverPersistOnClick?: string;
|
hoverPersistOnClick?: string;
|
||||||
|
// Audio effects
|
||||||
|
hoverAudioUrl?: string;
|
||||||
|
clickAudioUrl?: string;
|
||||||
|
audioVolume?: string;
|
||||||
// Slide transition override (Gallery/Carousel only)
|
// Slide transition override (Gallery/Carousel only)
|
||||||
// These override page transition settings for this element's slides
|
// These override page transition settings for this element's slides
|
||||||
slideTransitionType?: string;
|
slideTransitionType?: string;
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import UiElementRenderer from './UiElements/UiElementRenderer';
|
import UiElementRenderer from './UiElements/UiElementRenderer';
|
||||||
import { useElementEffects } from '../hooks/useElementEffects';
|
import { useElementEffects } from '../hooks/useElementEffects';
|
||||||
|
import { useAudioEffects } from '../hooks/useAudioEffects';
|
||||||
import {
|
import {
|
||||||
buildTransitionStyle,
|
buildTransitionStyle,
|
||||||
buildAppearAnimationStyle,
|
buildAppearAnimationStyle,
|
||||||
@ -63,13 +64,27 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
|||||||
|
|
||||||
// Use effects hook for interactive states
|
// Use effects hook for interactive states
|
||||||
// Pass forceVisible when info panel is open to keep trigger visible
|
// Pass forceVisible when info panel is open to keep trigger visible
|
||||||
const { effectStyle, eventHandlers, onPersistClick } = useElementEffects(
|
const {
|
||||||
effectProperties,
|
effectStyle,
|
||||||
{
|
eventHandlers,
|
||||||
resetKey: element.id, // Reset reveal on element change
|
onPersistClick,
|
||||||
forceVisible: isInfoPanelOpen,
|
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
|
// Combined click handler
|
||||||
// Skip toggle for info panel elements (their visibility is tied to panel open state)
|
// Skip toggle for info panel elements (their visibility is tied to panel open state)
|
||||||
|
|||||||
@ -126,7 +126,10 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
document.removeEventListener(
|
||||||
|
'webkitfullscreenchange',
|
||||||
|
handleFullscreenChange,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -140,17 +143,35 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
// Enter fullscreen
|
// Enter fullscreen
|
||||||
if (panel.requestFullscreen) {
|
if (panel.requestFullscreen) {
|
||||||
await 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
|
// Safari fallback
|
||||||
await (panel as HTMLDivElement & { webkitRequestFullscreen: () => Promise<void> }).webkitRequestFullscreen();
|
await (
|
||||||
|
panel as HTMLDivElement & {
|
||||||
|
webkitRequestFullscreen: () => Promise<void>;
|
||||||
|
}
|
||||||
|
).webkitRequestFullscreen();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Exit fullscreen
|
// Exit fullscreen
|
||||||
if (document.exitFullscreen) {
|
if (document.exitFullscreen) {
|
||||||
await 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
|
// Safari fallback
|
||||||
await (document as Document & { webkitExitFullscreen: () => Promise<void> }).webkitExitFullscreen();
|
await (
|
||||||
|
document as Document & { webkitExitFullscreen: () => Promise<void> }
|
||||||
|
).webkitExitFullscreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -222,7 +243,8 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
// Zero doesn't need a unit
|
// Zero doesn't need a unit
|
||||||
if (trimmed === '0') return '0';
|
if (trimmed === '0') return '0';
|
||||||
// Already uses canvas units - return as-is
|
// 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
|
// CSS functions (calc, var, min, max, etc.) - return as-is
|
||||||
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
|
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
|
||||||
// Already has a unit suffix - return as-is
|
// Already has a unit suffix - return as-is
|
||||||
@ -420,7 +442,9 @@ const ImageDetailPanel: React.FC<ImageDetailPanelProps> = ({
|
|||||||
type='button'
|
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'
|
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}
|
onClick={toggleFullscreen}
|
||||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
aria-label={
|
||||||
|
isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isFullscreen ? (
|
{isFullscreen ? (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@ -251,7 +251,8 @@ const InfoPanelOverlay: React.FC<InfoPanelOverlayProps> = ({
|
|||||||
// Zero doesn't need a unit
|
// Zero doesn't need a unit
|
||||||
if (trimmed === '0') return '0';
|
if (trimmed === '0') return '0';
|
||||||
// Already uses canvas units - return as-is
|
// 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
|
// CSS functions (calc, var, min, max, etc.) - return as-is
|
||||||
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
|
if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed;
|
||||||
// Already has a unit suffix - return as-is
|
// Already has a unit suffix - return as-is
|
||||||
|
|||||||
@ -85,6 +85,8 @@ export const PRELOAD_CONFIG = {
|
|||||||
'url',
|
'url',
|
||||||
'poster',
|
'poster',
|
||||||
'thumbnail',
|
'thumbnail',
|
||||||
|
'hoverAudioUrl',
|
||||||
|
'clickAudioUrl',
|
||||||
] as const,
|
] as const,
|
||||||
images: [
|
images: [
|
||||||
'iconUrl',
|
'iconUrl',
|
||||||
|
|||||||
@ -103,8 +103,7 @@ export function useAssetOptions({
|
|||||||
() =>
|
() =>
|
||||||
assets
|
assets
|
||||||
.filter(
|
.filter(
|
||||||
(asset) =>
|
(asset) => asset.asset_type === 'embed' && getAssetSourceValue(asset),
|
||||||
asset.asset_type === 'embed' && getAssetSourceValue(asset),
|
|
||||||
)
|
)
|
||||||
.map((asset) => ({
|
.map((asset) => ({
|
||||||
value: getAssetSourceValue(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) */
|
/** Call this in onClick to toggle hover persistence (if enabled) */
|
||||||
onPersistClick: () => void;
|
onPersistClick: () => void;
|
||||||
|
/** Exposed state for audio effects integration */
|
||||||
|
state: {
|
||||||
|
isHovered: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -225,5 +230,9 @@ export function useElementEffects(
|
|||||||
onTouchEnd,
|
onTouchEnd,
|
||||||
},
|
},
|
||||||
onPersistClick: onClick,
|
onPersistClick: onClick,
|
||||||
|
state: {
|
||||||
|
isHovered: state.isHovered,
|
||||||
|
isActive: state.isActive,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,9 @@ export const ELEMENT_EFFECT_PROPS = [
|
|||||||
'hoverRevealDelay',
|
'hoverRevealDelay',
|
||||||
'hoverRevealPersist',
|
'hoverRevealPersist',
|
||||||
'hoverPersistOnClick',
|
'hoverPersistOnClick',
|
||||||
|
'hoverAudioUrl',
|
||||||
|
'clickAudioUrl',
|
||||||
|
'audioVolume',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -78,6 +78,10 @@ export interface ElementEffectProperties {
|
|||||||
hoverRevealPersist?: boolean;
|
hoverRevealPersist?: boolean;
|
||||||
// Persist hover effects after click (applies to all hover effects)
|
// Persist hover effects after click (applies to all hover effects)
|
||||||
hoverPersistOnClick?: boolean;
|
hoverPersistOnClick?: boolean;
|
||||||
|
// Audio effects
|
||||||
|
hoverAudioUrl?: string;
|
||||||
|
clickAudioUrl?: string;
|
||||||
|
audioVolume?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -107,6 +111,9 @@ export const EFFECT_PROPS = [
|
|||||||
'hoverRevealDelay',
|
'hoverRevealDelay',
|
||||||
'hoverRevealPersist',
|
'hoverRevealPersist',
|
||||||
'hoverPersistOnClick',
|
'hoverPersistOnClick',
|
||||||
|
'hoverAudioUrl',
|
||||||
|
'clickAudioUrl',
|
||||||
|
'audioVolume',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EffectPropName = (typeof EFFECT_PROPS)[number];
|
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(
|
export function hasAnyEffects(
|
||||||
effects: Partial<ElementEffectProperties>,
|
effects: Partial<ElementEffectProperties>,
|
||||||
@ -304,10 +311,20 @@ export function hasAnyEffects(
|
|||||||
hasHoverEffects(effects) ||
|
hasHoverEffects(effects) ||
|
||||||
hasFocusEffects(effects) ||
|
hasFocusEffects(effects) ||
|
||||||
hasActiveEffects(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.
|
* Check if element has hover reveal enabled.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user