improved background audio functionaliry

This commit is contained in:
Dmitri 2026-06-05 16:19:50 +02:00
parent a5a936fe73
commit d29ac7c8f0
21 changed files with 879 additions and 84 deletions

View File

@ -73,6 +73,22 @@ class Tour_pagesDBApi extends GenericDBApi {
background_image_url: data.background_image_url || null,
background_video_url: data.background_video_url || null,
background_audio_url: data.background_audio_url || null,
background_audio_autoplay:
data.background_audio_autoplay !== undefined
? data.background_audio_autoplay
: true,
background_audio_loop:
data.background_audio_loop !== undefined
? data.background_audio_loop
: true,
background_audio_start_time:
data.background_audio_start_time !== undefined
? data.background_audio_start_time
: null,
background_audio_end_time:
data.background_audio_end_time !== undefined
? data.background_audio_end_time
: null,
background_loop: data.background_loop || false,
background_video_autoplay:
data.background_video_autoplay !== undefined

View File

@ -0,0 +1,47 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tour_pages', 'background_audio_autoplay', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
await queryInterface.addColumn('tour_pages', 'background_audio_loop', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
await queryInterface.addColumn(
'tour_pages',
'background_audio_start_time',
{
type: Sequelize.DECIMAL(10, 1),
allowNull: true,
defaultValue: null,
},
);
await queryInterface.addColumn('tour_pages', 'background_audio_end_time', {
type: Sequelize.DECIMAL(10, 1),
allowNull: true,
defaultValue: null,
});
},
async down(queryInterface, _Sequelize) {
await queryInterface.removeColumn(
'tour_pages',
'background_audio_autoplay',
);
await queryInterface.removeColumn('tour_pages', 'background_audio_loop');
await queryInterface.removeColumn(
'tour_pages',
'background_audio_start_time',
);
await queryInterface.removeColumn(
'tour_pages',
'background_audio_end_time',
);
},
};

View File

@ -66,6 +66,30 @@ module.exports = function (sequelize, DataTypes) {
type: DataTypes.TEXT,
},
background_audio_autoplay: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
background_audio_loop: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
background_audio_start_time: {
type: DataTypes.DECIMAL(10, 1),
allowNull: true,
defaultValue: null,
},
background_audio_end_time: {
type: DataTypes.DECIMAL(10, 1),
allowNull: true,
defaultValue: null,
},
background_loop: {
type: DataTypes.BOOLEAN,

View File

@ -7,7 +7,11 @@
*/
import React from 'react';
import type { AssetOption, VideoPlaybackSettings } from './types';
import type {
AssetOption,
VideoPlaybackSettings,
AudioPlaybackSettings,
} from './types';
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
interface BackgroundSettingsEditorProps {
@ -24,12 +28,18 @@ interface BackgroundSettingsEditorProps {
videoStartTime?: number | null;
videoEndTime?: number | null;
onVideoSettingsChange?: (settings: VideoPlaybackSettings) => void;
// Audio-specific playback settings (only used when type='audio')
audioAutoplay?: boolean;
audioLoop?: boolean;
audioStartTime?: number | null;
audioEndTime?: number | null;
onAudioSettingsChange?: (settings: AudioPlaybackSettings) => void;
}
const LABELS: Record<string, string> = {
image: 'Background image',
video: 'Background video',
audio: 'Background audio (loop)',
audio: 'Background audio',
};
const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
@ -45,6 +55,11 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
videoStartTime,
videoEndTime,
onVideoSettingsChange,
audioAutoplay,
audioLoop,
audioStartTime,
audioEndTime,
onAudioSettingsChange,
}) => {
const label = LABELS[type] || 'Background';
const selectOptions = addFallbackAssetOption(
@ -56,6 +71,9 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
// Show video playback settings when type is video and a video is selected
const showVideoSettings = type === 'video' && value && onVideoSettingsChange;
// Show audio playback settings when type is audio and an audio is selected
const showAudioSettings = type === 'audio' && value && onAudioSettingsChange;
return (
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
@ -169,6 +187,80 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
</div>
</div>
)}
{/* Audio Playback Settings */}
{showAudioSettings && (
<div className='mt-3 space-y-2 border-t border-white/20 pt-3'>
<p className='text-[10px] font-semibold uppercase text-white/70'>
Playback Settings
</p>
<label className='flex cursor-pointer items-center gap-2 text-[11px] text-white/80'>
<input
type='checkbox'
className='h-3 w-3 rounded border-gray-300'
checked={audioAutoplay ?? true}
onChange={(e) =>
onAudioSettingsChange({ autoplay: e.target.checked })
}
/>
Autoplay
</label>
<label className='flex cursor-pointer items-center gap-2 text-[11px] text-white/80'>
<input
type='checkbox'
className='h-3 w-3 rounded border-gray-300'
checked={audioLoop ?? true}
onChange={(e) =>
onAudioSettingsChange({ loop: e.target.checked })
}
/>
Loop
</label>
<div className='flex gap-2'>
<div className='flex-1'>
<label className='mb-1 block text-[10px] text-white/60'>
Start (sec)
</label>
<input
type='number'
step='0.1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='0.0'
value={audioStartTime ?? ''}
onChange={(e) =>
onAudioSettingsChange({
startTime: e.target.value
? parseFloat(e.target.value)
: null,
})
}
/>
</div>
<div className='flex-1'>
<label className='mb-1 block text-[10px] text-white/60'>
End (sec)
</label>
<input
type='number'
step='0.1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='auto'
value={audioEndTime ?? ''}
onChange={(e) =>
onAudioSettingsChange({
endTime: e.target.value ? parseFloat(e.target.value) : null,
})
}
/>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -15,6 +15,7 @@ import React, {
} from 'react';
import NextImage from 'next/image';
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import { baseURLApi } from '../../config';
@ -62,6 +63,15 @@ interface CanvasBackgroundProps {
videoStoragePath?: string;
/** Pause video playback (e.g., during navigation to show frozen frame) */
pauseVideo?: boolean;
// Audio playback settings
audioAutoplay?: boolean;
audioLoop?: boolean;
audioStartTime?: number | null;
audioEndTime?: number | null;
/** Original storage path for audio - used for play-once tracking (not the resolved blob URL) */
audioStoragePath?: string;
/** Pause audio playback (e.g., during ducking) */
pauseAudio?: boolean;
}
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
@ -81,6 +91,12 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
videoEndTime = null,
videoStoragePath,
pauseVideo = false,
audioAutoplay = true,
audioLoop = true,
audioStartTime = null,
audioEndTime = null,
audioStoragePath,
pauseAudio = false,
}) => {
// During page switching with video paused, keep showing the previous video URL.
// This prevents black flash when the video element would remount with a new URL.
@ -104,6 +120,17 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
paused: pauseVideo,
});
// Use background audio playback hook for custom start/end time handling and ducking
const { audioRef } = useBackgroundAudioPlayback({
audioUrl: backgroundAudioUrl,
audioStoragePath: audioStoragePath || backgroundAudioUrl,
autoplay: audioAutoplay,
loop: audioLoop,
startTime: audioStartTime,
endTime: audioEndTime,
paused: pauseAudio,
});
// Block autoplay if: video already played this session OR externally paused
const effectiveAutoplay =
videoAutoplay && !shouldBlockAutoplay && !pauseVideo;
@ -465,13 +492,14 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
/>
)}
{/* Background audio */}
{/* Background audio - controlled by useBackgroundAudioPlayback hook for
custom start/end time handling and ducking (pauses when element audio plays) */}
{backgroundAudioUrl && (
<audio
ref={audioRef}
key={`bg_audio_${backgroundAudioUrl}`}
src={backgroundAudioUrl}
autoPlay
loop
preload='auto'
hidden
/>
)}

View File

@ -155,6 +155,7 @@ export function ElementEditorPanel({
setBackgroundVideoUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
setBackgroundAudioSettings,
} = useConstructorBackground();
const { assetOptions } = useConstructorAssets();
@ -240,6 +241,11 @@ export function ElementEditorPanel({
options={assetOptions.audio}
durationNote={durationNotes.backgroundAudio}
onChange={setBackgroundAudioUrl}
audioAutoplay={pageBackground.audioSettings.autoplay}
audioLoop={pageBackground.audioSettings.loop}
audioStartTime={pageBackground.audioSettings.startTime}
audioEndTime={pageBackground.audioSettings.endTime}
onAudioSettingsChange={setBackgroundAudioSettings}
/>
)}

View File

@ -13,6 +13,7 @@ import type {
CarouselSlide,
NavigationButtonKind,
PageBackgroundVideoSettings,
PageBackgroundAudioSettings,
EditorMenuItem,
EditorTab,
} from '../../types/constructor';
@ -22,6 +23,11 @@ import type {
*/
export type VideoPlaybackSettings = Partial<PageBackgroundVideoSettings>;
/**
* Partial audio settings for update callbacks
*/
export type AudioPlaybackSettings = Partial<PageBackgroundAudioSettings>;
/**
* Constructor interaction mode
*/
@ -49,6 +55,11 @@ export interface TourPage {
background_video_muted?: boolean;
background_video_start_time?: number | null;
background_video_end_time?: number | null;
// Background audio playback settings
background_audio_autoplay?: boolean;
background_audio_loop?: boolean;
background_audio_start_time?: number | null;
background_audio_end_time?: number | null;
}
/**
@ -86,22 +97,6 @@ export interface InteractionModeToggleProps {
compact?: boolean;
}
/**
* Controls panel props
*/
export interface ConstructorControlsPanelProps {
projectId: string;
projectName: string;
pages: TourPage[];
activePageId: string;
interactionMode: ConstructorInteractionMode;
position: Position;
onPositionChange: (position: Position) => void;
onPageChange: (pageId: string) => void;
onModeChange: (mode: ConstructorInteractionMode) => void;
onDragStart: (event: React.MouseEvent) => void;
}
/**
* Canvas element props
*/
@ -115,45 +110,6 @@ export interface CanvasElementProps {
onMouseDown?: (event: React.MouseEvent, elementId: string) => void;
}
/**
* Canvas background props
*/
export interface CanvasBackgroundProps {
backgroundImageUrl?: string;
backgroundVideoUrl?: string;
backgroundAudioUrl?: string;
previousBgImageUrl?: string;
isSwitching?: boolean;
isNewBgReady?: boolean;
onBackgroundReady?: () => void;
// Video playback settings
videoAutoplay?: boolean;
videoLoop?: boolean;
videoMuted?: boolean;
videoStartTime?: number | null;
videoEndTime?: number | null;
}
/**
* Constructor canvas props
*/
export interface ConstructorCanvasProps {
canvasRef: React.RefObject<HTMLDivElement>;
elements: CanvasElement[];
selectedElementId: string;
isEditMode: boolean;
isLoading: boolean;
canvasElapsedSec: number;
preloadedIconUrlMap: Record<string, boolean>;
background: CanvasBackgroundProps;
transitionPreview: boolean;
isReverseBuffering: boolean;
onElementClick: (element: CanvasElement) => void;
onElementMouseDown: (event: React.MouseEvent, elementId: string) => void;
onCreatePage: () => void;
isCreatingPage: boolean;
}
/**
* Element editor header props
*/
@ -183,6 +139,12 @@ export interface BackgroundSettingsEditorProps {
videoStartTime?: number | null;
videoEndTime?: number | null;
onVideoSettingsChange?: (settings: VideoPlaybackSettings) => void;
// Audio-specific playback settings (only used when type='audio')
audioAutoplay?: boolean;
audioLoop?: boolean;
audioStartTime?: number | null;
audioEndTime?: number | null;
onAudioSettingsChange?: (settings: AudioPlaybackSettings) => void;
}
/**
@ -279,7 +241,6 @@ export interface ConstructorMenuProps {
/**
* Unified toolbar props - combines controls panel and menu functionality
* Replaces ConstructorControlsPanelProps and ConstructorMenuProps
*/
export interface ConstructorToolbarProps {
// Positioning (from useDraggable)

View File

@ -48,6 +48,7 @@ import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari } from '../lib/browserUtils';
import { logger } from '../lib/logger';
import { backgroundAudioController } from '../lib/backgroundAudioController';
import {
resolveNavigationTarget,
isTransitionBlocking,
@ -278,6 +279,7 @@ export default function RuntimePresentation({
const {
currentImageUrl: navCurrentBgImageUrl,
currentVideoUrl: navCurrentBgVideoUrl,
currentAudioUrl: navCurrentBgAudioUrl,
previousImageUrl: navPreviousBgImageUrl,
previousVideoUrl: navPreviousBgVideoUrl,
isSwitching: navIsSwitching,
@ -380,6 +382,13 @@ export default function RuntimePresentation({
navResetToIdle();
}, [navResetToIdle]);
// Handle first user interaction for background audio unlock
// CRITICAL: Use touchEnd, NOT touchStart - iOS Safari only unlocks audio
// when finger is LIFTED from screen (touchend), not when touched (touchstart)
const handleCanvasInteraction = useCallback(() => {
backgroundAudioController.notifyUserInteraction();
}, []);
const toggleFullscreen = useCallback(async () => {
try {
if (!document.fullscreenElement) {
@ -664,6 +673,7 @@ export default function RuntimePresentation({
// Background URLs come directly from navigation state (already resolved)
const backgroundImageUrl = navCurrentBgImageUrl;
const backgroundVideoUrl = navCurrentBgVideoUrl;
const backgroundAudioUrl = navCurrentBgAudioUrl;
// Background video playback settings from selected page
const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
@ -680,6 +690,18 @@ export default function RuntimePresentation({
? parseFloat(String(selectedPage.background_video_end_time))
: null;
// Background audio playback settings from selected page
const audioAutoplay = selectedPage?.background_audio_autoplay ?? true;
const audioLoop = selectedPage?.background_audio_loop ?? true;
const audioStartTime =
selectedPage?.background_audio_start_time != null
? parseFloat(String(selectedPage.background_audio_start_time))
: null;
const audioEndTime =
selectedPage?.background_audio_end_time != null
? parseFloat(String(selectedPage.background_audio_end_time))
: null;
// Sound control hook for iOS autoplay compatibility
// Videos start muted (for iOS autoplay), user can unmute via sound button
const soundControl = useVideoSoundControl({
@ -778,7 +800,12 @@ export default function RuntimePresentation({
</Head>
{/* Outer container: full viewport with black background for letterbox bars */}
<div className='relative w-screen h-screen overflow-hidden bg-black'>
{/* onClick/onTouchEnd: Notify audio controller of user interaction for autoplay unlock */}
<div
className='relative w-screen h-screen overflow-hidden bg-black'
onClick={handleCanvasInteraction}
onTouchEnd={handleCanvasInteraction}
>
{/* Inner canvas: maintains aspect ratio centered in viewport.
z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body. */}
<div
@ -817,6 +844,7 @@ export default function RuntimePresentation({
<CanvasBackground
backgroundImageUrl={backgroundImageUrl}
backgroundVideoUrl={backgroundVideoUrl}
backgroundAudioUrl={backgroundAudioUrl}
previousBgImageUrl={navPreviousBgImageUrl}
previousBgVideoUrl={navPreviousBgVideoUrl}
isSwitching={navIsSwitching}
@ -834,6 +862,11 @@ export default function RuntimePresentation({
pendingTransitionComplete ||
navIsSwitching
}
audioAutoplay={audioAutoplay}
audioLoop={audioLoop}
audioStartTime={audioStartTime}
audioEndTime={audioEndTime}
audioStoragePath={selectedPage?.background_audio_url}
/>
</div>
{/* End page background wrapper */}

View File

@ -2,13 +2,14 @@
* AudioPlayerElement Component
*
* Audio player element with controls.
* Renders with unified wrapper styling + content.
* Pauses background audio when playing (ducking).
*/
import React from 'react';
import React, { useRef, useEffect } from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
interface AudioPlayerElementProps {
element: CanvasElement;
@ -24,6 +25,30 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
style,
}) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const audioRef = useRef<HTMLAudioElement>(null);
// Ducking: pause background audio when this player plays
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handlePlay = () => backgroundAudioController.notifyForegroundStart();
const handlePause = () => backgroundAudioController.notifyForegroundEnd();
const handleEnded = () => backgroundAudioController.notifyForegroundEnd();
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('ended', handleEnded);
if (!audio.paused) {
backgroundAudioController.notifyForegroundEnd();
}
};
}, []);
if (!element.mediaUrl) {
return (
@ -36,6 +61,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
return (
<div className={className} style={style}>
<audio
ref={audioRef}
className='w-full'
src={resolve(element.mediaUrl)}
controls

View File

@ -2,17 +2,15 @@
* VideoPlayerElement Component
*
* Video player element with controls.
* Uses unified video hook for consistent behavior:
* - Multi-tier URL resolution (blob cached presigned proxy)
* - Safari decode error recovery
* - Buffering state indicator
* Pauses background audio when playing (ducking).
*/
import React from 'react';
import React, { useEffect } from 'react';
import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor';
import type { PreloadCacheProvider } from '../../../hooks/video';
import { useVideoPlayer } from '../../../hooks/video';
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
interface VideoPlayerElementProps {
element: CanvasElement;
@ -36,6 +34,29 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
trackBuffering: true,
});
// Ducking: pause background audio when this player plays
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlay = () => backgroundAudioController.notifyForegroundStart();
const handlePause = () => backgroundAudioController.notifyForegroundEnd();
const handleEnded = () => backgroundAudioController.notifyForegroundEnd();
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
return () => {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
if (!video.paused) {
backgroundAudioController.notifyForegroundEnd();
}
};
}, [videoRef]);
if (!element.mediaUrl) {
return (
<div className={className} style={style}>

View File

@ -21,6 +21,7 @@ import type {
CanvasElementType,
PageBackgroundState,
PageBackgroundVideoSettings,
PageBackgroundAudioSettings,
EditorMenuItem,
EditorTab,
GalleryCard,
@ -123,6 +124,9 @@ export interface ConstructorContextValue {
setBackgroundVideoSettings: (
settings: Partial<PageBackgroundVideoSettings>,
) => void;
setBackgroundAudioSettings: (
settings: Partial<PageBackgroundAudioSettings>,
) => void;
// Element state
elements: CanvasElement[];
@ -314,6 +318,7 @@ export function useConstructorBackground() {
setBackgroundVideoUrl: ctx.setBackgroundVideoUrl,
setBackgroundAudioUrl: ctx.setBackgroundAudioUrl,
setBackgroundVideoSettings: ctx.setBackgroundVideoSettings,
setBackgroundAudioSettings: ctx.setBackgroundAudioSettings,
}),
[
ctx.pageBackground,
@ -323,6 +328,7 @@ export function useConstructorBackground() {
ctx.setBackgroundVideoUrl,
ctx.setBackgroundAudioUrl,
ctx.setBackgroundVideoSettings,
ctx.setBackgroundAudioSettings,
],
);
}

View File

@ -0,0 +1,115 @@
/**
* useAudioEventManager Hook
*
* Manages audio element event listener setup and cleanup.
* Provides a declarative API for subscribing to audio events.
* Mirrors useVideoEventManager pattern for consistency.
*/
import { useEffect, type RefObject } from 'react';
export type AudioEventType =
| 'loadedmetadata'
| 'loadeddata'
| 'canplay'
| 'canplaythrough'
| 'playing'
| 'pause'
| 'ended'
| 'timeupdate'
| 'seeking'
| 'seeked'
| 'waiting'
| 'progress'
| 'stalled'
| 'error'
| 'abort';
export type AudioEventHandler = (event: Event) => void;
export interface AudioEventHandlers {
onLoadedMetadata?: AudioEventHandler;
onLoadedData?: AudioEventHandler;
onCanPlay?: AudioEventHandler;
onCanPlayThrough?: AudioEventHandler;
onPlaying?: AudioEventHandler;
onPause?: AudioEventHandler;
onEnded?: AudioEventHandler;
onTimeUpdate?: AudioEventHandler;
onSeeking?: AudioEventHandler;
onSeeked?: AudioEventHandler;
onWaiting?: AudioEventHandler;
onProgress?: AudioEventHandler;
onStalled?: AudioEventHandler;
onError?: AudioEventHandler;
onAbort?: AudioEventHandler;
}
export interface UseAudioEventManagerOptions {
audioRef: RefObject<HTMLAudioElement | null>;
enabled?: boolean;
handlers: AudioEventHandlers;
}
const EVENT_MAP: Record<keyof AudioEventHandlers, AudioEventType> = {
onLoadedMetadata: 'loadedmetadata',
onLoadedData: 'loadeddata',
onCanPlay: 'canplay',
onCanPlayThrough: 'canplaythrough',
onPlaying: 'playing',
onPause: 'pause',
onEnded: 'ended',
onTimeUpdate: 'timeupdate',
onSeeking: 'seeking',
onSeeked: 'seeked',
onWaiting: 'waiting',
onProgress: 'progress',
onStalled: 'stalled',
onError: 'error',
onAbort: 'abort',
};
/**
* Hook for managing audio element event listeners.
*
* @example
* useAudioEventManager({
* audioRef,
* enabled: true,
* handlers: {
* onPlaying: () => console.log('Audio started playing'),
* onWaiting: () => console.log('Audio is buffering'),
* onEnded: () => console.log('Audio ended'),
* },
* });
*/
export function useAudioEventManager({
audioRef,
enabled = true,
handlers,
}: UseAudioEventManagerOptions): void {
useEffect(() => {
const audio = audioRef.current;
if (!audio || !enabled) return;
const boundHandlers: Array<[AudioEventType, AudioEventHandler]> = [];
// Set up event listeners
for (const [handlerKey, eventType] of Object.entries(EVENT_MAP)) {
const handler = handlers[handlerKey as keyof AudioEventHandlers];
if (handler) {
audio.addEventListener(eventType, handler);
boundHandlers.push([eventType, handler]);
}
}
// Cleanup
return () => {
for (const [eventType, handler] of boundHandlers) {
audio.removeEventListener(eventType, handler);
}
};
}, [audioRef, enabled, handlers]);
}
export default useAudioEventManager;

View File

@ -16,6 +16,7 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { logger } from '../lib/logger';
import { backgroundAudioController } from '../lib/backgroundAudioController';
/**
* Fetch audio file with credentials and return a blob URL.
@ -237,37 +238,35 @@ export function useAudioEffects({
}, [volume]);
// Play hover audio when hover starts (plays to completion, not interrupted)
// Sound effects layer over background audio (no ducking)
useEffect(() => {
if (
isHovered &&
!wasHoveredRef.current &&
hoverAudioRef.current &&
hoverAudioReady
hoverAudioReady &&
backgroundAudioController.hasInteracted()
) {
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);
});
const audio = hoverAudioRef.current;
audio.currentTime = 0;
audio.play().catch(() => undefined);
}
wasHoveredRef.current = isHovered;
}, [isHovered, hoverAudioReady]);
// Play click audio when active state begins
// Sound effects layer over background audio (no ducking)
useEffect(() => {
if (
isActive &&
!wasActiveRef.current &&
clickAudioRef.current &&
clickAudioReady
clickAudioReady &&
backgroundAudioController.hasInteracted()
) {
clickAudioRef.current.currentTime = 0;
// Click implies user interaction, so this should rarely fail
clickAudioRef.current.play().catch(() => {
// Silent fail for edge cases
});
const audio = clickAudioRef.current;
audio.currentTime = 0;
audio.play().catch(() => undefined);
}
wasActiveRef.current = isActive;
}, [isActive, clickAudioReady]);
@ -281,7 +280,6 @@ export function useAudioEffects({
}
}, [clickAudioReady]);
// Stop all audio playback
const stopAll = useCallback(() => {
if (hoverAudioRef.current) {
hoverAudioRef.current.pause();

View File

@ -0,0 +1,218 @@
/**
* useBackgroundAudioPlayback Hook
*
* Manages background audio playback with custom start/end times.
* Built on top of audio primitives for consistent behavior.
* Mirrors useBackgroundVideoPlayback pattern.
*
* When loop is disabled, audio is tracked per-session so it only plays once
* and stops on subsequent page visits (until browser refresh).
*/
import { useEffect, useRef, useCallback, type RefObject } from 'react';
import { logger } from '../lib/logger';
import { useAudioEventManager } from './audio/useAudioEventManager';
import { backgroundAudioController } from '../lib/backgroundAudioController';
// Session-scoped tracking of audio that has finished playing (when loop=false)
// Key: audioUrl, cleared on browser refresh
const playedAudios = new Set<string>();
export interface UseBackgroundAudioPlaybackOptions {
/** URL of the audio to play (may be a blob URL) */
audioUrl?: string;
/** Original storage path for play-once tracking (stable across navigations) */
audioStoragePath?: string;
/** Whether to autoplay the audio (default: true) */
autoplay?: boolean;
/** Whether to loop the audio (default: true) */
loop?: boolean;
/** Start time in seconds (default: null = start from beginning) */
startTime?: number | null;
/** End time in seconds (default: null = play to end) */
endTime?: number | null;
/** External pause control (e.g., during ducking). Takes precedence over autoplay. */
paused?: boolean;
}
export interface UseBackgroundAudioPlaybackResult {
/** Ref to attach to the audio element */
audioRef: RefObject<HTMLAudioElement | null>;
/** Whether autoplay should be blocked (audio already played this session) */
shouldBlockAutoplay: boolean;
}
/**
* Hook for managing background audio playback with custom start/end times.
*
* When startTime is set, the audio will seek to that position on load.
* When endTime is set, the audio will either loop back to startTime or pause,
* depending on the loop setting.
*
* @example
* const { audioRef } = useBackgroundAudioPlayback({
* audioUrl: 'https://example.com/audio.mp3',
* autoplay: true,
* loop: true,
* startTime: 2.5,
* endTime: 10.0,
* });
*
* return <audio ref={audioRef} src={audioUrl} />;
*/
export function useBackgroundAudioPlayback({
audioUrl,
audioStoragePath,
autoplay = true,
loop = true,
startTime = null,
endTime = null,
paused = false,
}: UseBackgroundAudioPlaybackOptions): UseBackgroundAudioPlaybackResult {
const audioRef = useRef<HTMLAudioElement | null>(null);
// Use storage path for tracking (stable across blob URL changes)
// Falls back to audioUrl if no storage path provided
const trackingKey = audioStoragePath || audioUrl;
// Block autoplay if audio already played this session (only when loop=false)
const shouldBlockAutoplay =
!loop && trackingKey ? playedAudios.has(trackingKey) : false;
// Store current values in refs for event handlers to access
const startTimeRef = useRef(startTime);
const endTimeRef = useRef(endTime);
const loopRef = useRef(loop);
const pausedRef = useRef(paused);
const trackingKeyRef = useRef(trackingKey);
// Update refs when values change
useEffect(() => {
startTimeRef.current = startTime;
endTimeRef.current = endTime;
loopRef.current = loop;
pausedRef.current = paused;
trackingKeyRef.current = trackingKey;
}, [startTime, endTime, loop, paused, trackingKey]);
// Register audio element with background audio controller for ducking
useEffect(() => {
const audio = audioRef.current;
backgroundAudioController.register(audio);
return () => {
backgroundAudioController.register(null);
};
}, [audioUrl]);
// Seek to start time when audio metadata is loaded
const handleLoadedMetadata = useCallback(() => {
const audio = audioRef.current;
const st = startTimeRef.current;
if (!audio || st == null || st <= 0) return;
audio.currentTime = st;
logger.info('Background audio: seeking to start time', { startTime: st });
}, []);
// Handle time update for end time enforcement
const handleTimeUpdate = useCallback(() => {
const audio = audioRef.current;
if (!audio || pausedRef.current) return;
const currentEndTime = endTimeRef.current;
const currentLoop = loopRef.current;
const currentStartTime = startTimeRef.current;
// Skip if no end time is set
if (currentEndTime == null) return;
// Check if we've reached or passed the end time
if (audio.currentTime >= currentEndTime) {
if (currentLoop) {
// Loop back to start time (or beginning)
const loopTarget = currentStartTime ?? 0;
audio.currentTime = loopTarget;
logger.info('Background audio: looping back', {
from: currentEndTime,
to: loopTarget,
});
} else {
// Pause at end time
audio.pause();
logger.info('Background audio: paused at end time', {
endTime: currentEndTime,
});
}
}
}, []);
// Handle audio ended for play-once tracking
const handleEnded = useCallback(() => {
const key = trackingKeyRef.current;
if (key && !loopRef.current) {
playedAudios.add(key);
}
}, []);
// Use audio event manager for event handling
useAudioEventManager({
audioRef,
enabled: Boolean(audioUrl),
handlers: {
onLoadedMetadata: handleLoadedMetadata,
onTimeUpdate: handleTimeUpdate,
onEnded: handleEnded,
},
});
// Handle start time changes - seek immediately when startTime changes
useEffect(() => {
const audio = audioRef.current;
if (!audio || !audioUrl) return;
// If audio has loaded metadata, seek immediately
if (audio.readyState >= 1 && startTime != null && startTime > 0) {
audio.currentTime = startTime;
logger.info('Background audio: seeking to start time', { startTime });
}
}, [audioUrl, startTime]);
// Handle autoplay state changes (respects external pause control)
useEffect(() => {
const audio = audioRef.current;
if (!audio || !audioUrl) return;
// External pause takes precedence over autoplay
if (paused) {
audio.pause();
backgroundAudioController.setWaitingForInteraction(false);
return;
}
if (!shouldBlockAutoplay) {
// Always wait for user interaction before playing
backgroundAudioController.setWaitingForInteraction(true);
} else {
// Audio already played this session (loop=false)
audio.pause();
backgroundAudioController.setWaitingForInteraction(false);
}
}, [audioUrl, autoplay, paused, shouldBlockAutoplay]);
// Session-scoped "play once" behavior when loop is disabled
// Audio that has already played stops on revisit
useEffect(() => {
const audio = audioRef.current;
if (!audio || !audioUrl || !trackingKey || loop) return;
// If audio already played this session, keep it paused
if (playedAudios.has(trackingKey)) {
audio.pause();
}
}, [audioUrl, trackingKey, loop]);
return { audioRef, shouldBlockAutoplay };
}
export default useBackgroundAudioPlayback;

View File

@ -32,6 +32,11 @@ interface TourPage {
background_video_muted?: boolean;
background_video_start_time?: number | null;
background_video_end_time?: number | null;
// Background audio playback settings
background_audio_autoplay?: boolean;
background_audio_loop?: boolean;
background_audio_start_time?: number | null;
background_audio_end_time?: number | null;
}
interface Project {
@ -131,6 +136,12 @@ export function useConstructorPageActions({
startTime: backgroundVideoStartTime,
endTime: backgroundVideoEndTime,
},
audioSettings: {
autoplay: backgroundAudioAutoplay,
loop: backgroundAudioLoop,
startTime: backgroundAudioStartTime,
endTime: backgroundAudioEndTime,
},
} = pageBackground;
const [isSaving, setIsSaving] = useState(false);
const [isSavingToStage, setIsSavingToStage] = useState(false);
@ -206,6 +217,10 @@ export function useConstructorPageActions({
background_video_muted: backgroundVideoMuted,
background_video_start_time: backgroundVideoStartTime,
background_video_end_time: backgroundVideoEndTime,
background_audio_autoplay: backgroundAudioAutoplay,
background_audio_loop: backgroundAudioLoop,
background_audio_start_time: backgroundAudioStartTime,
background_audio_end_time: backgroundAudioEndTime,
// Copy project design dimensions to page for presentation isolation
design_width: project?.design_width ?? null,
design_height: project?.design_height ?? null,

View File

@ -19,6 +19,7 @@ import { useState, useCallback } from 'react';
import type {
PageBackgroundState,
PageBackgroundVideoSettings,
PageBackgroundAudioSettings,
} from '../types/constructor';
import {
DEFAULT_PAGE_BACKGROUND,
@ -34,6 +35,10 @@ interface TourPageData {
background_video_muted?: boolean;
background_video_start_time?: number | null;
background_video_end_time?: number | null;
background_audio_autoplay?: boolean;
background_audio_loop?: boolean;
background_audio_start_time?: number | null;
background_audio_end_time?: number | null;
}
export interface UsePageBackgroundOptions {
@ -59,6 +64,9 @@ export interface UsePageBackgroundResult {
/** Update video settings */
setVideoSettings: (settings: Partial<PageBackgroundVideoSettings>) => void;
/** Update audio settings */
setAudioSettings: (settings: Partial<PageBackgroundAudioSettings>) => void;
/** Reset to default state */
reset: () => void;
@ -72,6 +80,10 @@ export interface UsePageBackgroundResult {
backgroundVideoMuted: boolean;
backgroundVideoStartTime: number | null;
backgroundVideoEndTime: number | null;
backgroundAudioAutoplay: boolean;
backgroundAudioLoop: boolean;
backgroundAudioStartTime: number | null;
backgroundAudioEndTime: number | null;
}
/**
@ -146,6 +158,19 @@ export function usePageBackground(
[],
);
const setAudioSettings = useCallback(
(settings: Partial<PageBackgroundAudioSettings>) => {
setBackground((prev) => ({
...prev,
audioSettings: {
...prev.audioSettings,
...settings,
},
}));
},
[],
);
const reset = useCallback(() => {
setBackground({ ...DEFAULT_PAGE_BACKGROUND });
}, []);
@ -159,6 +184,7 @@ export function usePageBackground(
setVideoUrl,
setAudioUrl,
setVideoSettings,
setAudioSettings,
reset,
// Legacy compatibility: flat values for gradual migration
@ -170,6 +196,10 @@ export function usePageBackground(
backgroundVideoMuted: background.videoSettings.muted,
backgroundVideoStartTime: background.videoSettings.startTime,
backgroundVideoEndTime: background.videoSettings.endTime,
backgroundAudioAutoplay: background.audioSettings.autoplay,
backgroundAudioLoop: background.audioSettings.loop,
backgroundAudioStartTime: background.audioSettings.startTime,
backgroundAudioEndTime: background.audioSettings.endTime,
};
}

View File

@ -0,0 +1,80 @@
/**
* Background Audio Controller
*
* Singleton that manages:
* 1. Audio unlock - all audio waits for first user interaction (click/tap)
* 2. Ducking - background audio pauses when element audio plays
*
* Usage:
* - register() - set the background audio element
* - notifyUserInteraction() - call on first click/tap to unlock audio
* - hasInteracted() - check if audio is unlocked
* - notifyForegroundStart/End() - for ducking during element audio
*/
class BackgroundAudioController {
private audioElement: HTMLAudioElement | null = null;
private wasPaused = false;
private foregroundCount = 0;
private waitingForInteraction = false;
private hasUserInteracted = false;
register(audio: HTMLAudioElement | null): void {
this.audioElement = audio;
this.waitingForInteraction = false;
}
setWaitingForInteraction(waiting: boolean): void {
this.waitingForInteraction = waiting;
if (waiting && this.hasUserInteracted && this.audioElement) {
this.audioElement.play().catch(() => undefined);
this.waitingForInteraction = false;
}
}
notifyUserInteraction(): void {
if (this.hasUserInteracted) return;
this.hasUserInteracted = true;
if (this.waitingForInteraction && this.audioElement) {
this.audioElement.play().catch(() => undefined);
this.waitingForInteraction = false;
}
}
hasInteracted(): boolean {
return this.hasUserInteracted;
}
notifyForegroundStart(): void {
this.foregroundCount++;
if (this.foregroundCount === 1 && this.audioElement) {
this.wasPaused = this.audioElement.paused;
if (!this.wasPaused) {
this.audioElement.pause();
}
}
}
notifyForegroundEnd(): void {
if (this.foregroundCount > 0) {
this.foregroundCount--;
}
if (this.foregroundCount === 0 && this.audioElement && !this.wasPaused) {
this.audioElement.play().catch(() => undefined);
}
}
reset(): void {
this.foregroundCount = 0;
this.wasPaused = false;
this.waitingForInteraction = false;
// hasUserInteracted stays true for the session
}
}
// Export singleton instance
export const backgroundAudioController = new BackgroundAudioController();
export default backgroundAudioController;

View File

@ -42,6 +42,7 @@ import {
extractElementTransitionSettings,
} from '../types/transition';
import { logger } from '../lib/logger';
import { backgroundAudioController } from '../lib/backgroundAudioController';
import { isSafari } from '../lib/browserUtils';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
@ -209,6 +210,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setVideoUrl: setBackgroundVideoUrl,
setAudioUrl: setBackgroundAudioUrl,
setVideoSettings: setBackgroundVideoSettings,
setAudioSettings: setBackgroundAudioSettings,
// Legacy compatibility values for components that expect flat props
backgroundImageUrl,
backgroundVideoUrl,
@ -218,6 +220,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundVideoMuted,
backgroundVideoStartTime,
backgroundVideoEndTime,
backgroundAudioAutoplay,
backgroundAudioLoop,
backgroundAudioStartTime,
backgroundAudioEndTime,
} = usePageBackground();
// Sound control hook for iOS autoplay compatibility
@ -526,6 +532,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
navResetToIdle();
}, [navResetToIdle]);
// Handle first user interaction for background audio unlock
// Only active in 'interact' mode - edit mode keeps audio paused
// CRITICAL: Use touchEnd, NOT touchStart - iOS Safari only unlocks audio
// when finger is LIFTED from screen (touchend), not when touched (touchstart)
const handleCanvasInteraction = useCallback(() => {
// Only trigger audio unlock in interact mode
// Edit mode should keep audio paused to avoid bothering during configuration
if (!isConstructorEditMode) {
backgroundAudioController.notifyUserInteraction();
}
}, [isConstructorEditMode]);
// Helper to switch pages without flash
// Uses unified navigation state machine for blob URL resolution
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
@ -1606,6 +1624,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setBackgroundVideoUrl,
setBackgroundAudioUrl,
setBackgroundVideoSettings,
setBackgroundAudioSettings,
// Element state
elements,
@ -1786,6 +1805,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
)}
{/* Canvas container: z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body */}
{/* onClick/onTouchEnd: Notify audio controller of user interaction for autoplay unlock */}
<div
ref={canvasRef}
tabIndex={-1}
@ -1795,6 +1815,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
...letterboxStyles,
...canvasBackgroundStyle,
}}
onClick={handleCanvasInteraction}
onTouchEnd={handleCanvasInteraction}
>
<BackdropPortalProvider>
{/* Safari Black Flash Prevention (video transitions only):
@ -1841,6 +1863,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
pendingTransitionComplete ||
navIsSwitching
}
audioAutoplay={backgroundAudioAutoplay}
audioLoop={backgroundAudioLoop}
audioStartTime={backgroundAudioStartTime}
audioEndTime={backgroundAudioEndTime}
audioStoragePath={
backgroundAudioUrl || activePage?.background_audio_url
}
pauseAudio={isConstructorEditMode}
/>
</div>

View File

@ -797,6 +797,16 @@ export interface PageBackgroundVideoSettings {
endTime: number | null;
}
/**
* Audio playback settings for page background
*/
export interface PageBackgroundAudioSettings {
autoplay: boolean;
loop: boolean;
startTime: number | null;
endTime: number | null;
}
/**
* Consolidated page background state
* Replaces 8 separate useState hooks in constructor.tsx
@ -810,6 +820,8 @@ export interface PageBackgroundState {
audioUrl: string;
/** Video playback settings */
videoSettings: PageBackgroundVideoSettings;
/** Audio playback settings */
audioSettings: PageBackgroundAudioSettings;
}
/**
@ -823,6 +835,16 @@ export const DEFAULT_VIDEO_SETTINGS: PageBackgroundVideoSettings = {
endTime: null,
};
/**
* Default audio settings
*/
export const DEFAULT_AUDIO_SETTINGS: PageBackgroundAudioSettings = {
autoplay: true,
loop: true,
startTime: null,
endTime: null,
};
/**
* Default page background state
*/
@ -831,6 +853,7 @@ export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
videoUrl: '',
audioUrl: '',
videoSettings: { ...DEFAULT_VIDEO_SETTINGS },
audioSettings: { ...DEFAULT_AUDIO_SETTINGS },
};
/**
@ -846,6 +869,10 @@ export function createPageBackgroundFromPage(
background_video_muted?: boolean;
background_video_start_time?: number | null;
background_video_end_time?: number | null;
background_audio_autoplay?: boolean;
background_audio_loop?: boolean;
background_audio_start_time?: number | null;
background_audio_end_time?: number | null;
} | null,
): PageBackgroundState {
if (!page) {
@ -869,5 +896,17 @@ export function createPageBackgroundFromPage(
? parseFloat(String(page.background_video_end_time))
: null,
},
audioSettings: {
autoplay: page.background_audio_autoplay ?? true,
loop: page.background_audio_loop ?? true,
startTime:
page.background_audio_start_time != null
? parseFloat(String(page.background_audio_start_time))
: null,
endTime:
page.background_audio_end_time != null
? parseFloat(String(page.background_audio_end_time))
: null,
},
};
}

View File

@ -123,6 +123,11 @@ export interface TourPage extends BaseEntity {
background_video_muted?: boolean;
background_video_start_time?: number | null;
background_video_end_time?: number | null;
// Background audio playback settings
background_audio_autoplay?: boolean;
background_audio_loop?: boolean;
background_audio_start_time?: number | null;
background_audio_end_time?: number | null;
// Design canvas dimensions (copied from project on save for presentation isolation)
design_width?: number | null;
design_height?: number | null;

View File

@ -38,6 +38,11 @@ export interface RuntimePage extends PreloadPage {
background_video_muted?: boolean;
background_video_start_time?: number | null;
background_video_end_time?: number | null;
// Background audio playback settings
background_audio_autoplay?: boolean;
background_audio_loop?: boolean;
background_audio_start_time?: number | null;
background_audio_end_time?: number | null;
// Design canvas dimensions (copied from project on save for presentation isolation)
design_width?: number | null;
design_height?: number | null;