improved background audio functionaliry
This commit is contained in:
parent
a5a936fe73
commit
d29ac7c8f0
@ -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
|
||||
|
||||
@ -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',
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
115
frontend/src/hooks/audio/useAudioEventManager.ts
Normal file
115
frontend/src/hooks/audio/useAudioEventManager.ts
Normal 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;
|
||||
@ -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();
|
||||
|
||||
218
frontend/src/hooks/useBackgroundAudioPlayback.ts
Normal file
218
frontend/src/hooks/useBackgroundAudioPlayback.ts
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
80
frontend/src/lib/backgroundAudioController.ts
Normal file
80
frontend/src/lib/backgroundAudioController.ts
Normal 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;
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user