added audio effects

This commit is contained in:
Dmitri 2026-06-01 17:14:34 +02:00
parent cd4ffb2c90
commit 0d39e916f6
14 changed files with 490 additions and 26 deletions

View File

@ -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

View File

@ -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) =>

View File

@ -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}
/>
)}
</>

View File

@ -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'>

View File

@ -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;

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -85,6 +85,8 @@ export const PRELOAD_CONFIG = {
'url',
'poster',
'thumbnail',
'hoverAudioUrl',
'clickAudioUrl',
] as const,
images: [
'iconUrl',

View File

@ -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),

View 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 };
}

View File

@ -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,
},
};
}

View File

@ -45,6 +45,9 @@ export const ELEMENT_EFFECT_PROPS = [
'hoverRevealDelay',
'hoverRevealPersist',
'hoverPersistOnClick',
'hoverAudioUrl',
'clickAudioUrl',
'audioVolume',
] as const;
/**

View File

@ -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.
*/