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_image_url: data.background_image_url || null,
|
||||||
background_video_url: data.background_video_url || null,
|
background_video_url: data.background_video_url || null,
|
||||||
background_audio_url: data.background_audio_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_loop: data.background_loop || false,
|
||||||
background_video_autoplay:
|
background_video_autoplay:
|
||||||
data.background_video_autoplay !== undefined
|
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,
|
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: {
|
background_loop: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { AssetOption, VideoPlaybackSettings } from './types';
|
import type {
|
||||||
|
AssetOption,
|
||||||
|
VideoPlaybackSettings,
|
||||||
|
AudioPlaybackSettings,
|
||||||
|
} from './types';
|
||||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||||
|
|
||||||
interface BackgroundSettingsEditorProps {
|
interface BackgroundSettingsEditorProps {
|
||||||
@ -24,12 +28,18 @@ interface BackgroundSettingsEditorProps {
|
|||||||
videoStartTime?: number | null;
|
videoStartTime?: number | null;
|
||||||
videoEndTime?: number | null;
|
videoEndTime?: number | null;
|
||||||
onVideoSettingsChange?: (settings: VideoPlaybackSettings) => void;
|
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> = {
|
const LABELS: Record<string, string> = {
|
||||||
image: 'Background image',
|
image: 'Background image',
|
||||||
video: 'Background video',
|
video: 'Background video',
|
||||||
audio: 'Background audio (loop)',
|
audio: 'Background audio',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||||
@ -45,6 +55,11 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
|||||||
videoStartTime,
|
videoStartTime,
|
||||||
videoEndTime,
|
videoEndTime,
|
||||||
onVideoSettingsChange,
|
onVideoSettingsChange,
|
||||||
|
audioAutoplay,
|
||||||
|
audioLoop,
|
||||||
|
audioStartTime,
|
||||||
|
audioEndTime,
|
||||||
|
onAudioSettingsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const label = LABELS[type] || 'Background';
|
const label = LABELS[type] || 'Background';
|
||||||
const selectOptions = addFallbackAssetOption(
|
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
|
// Show video playback settings when type is video and a video is selected
|
||||||
const showVideoSettings = type === 'video' && value && onVideoSettingsChange;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
|
||||||
@ -169,6 +187,80 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import NextImage from 'next/image';
|
import NextImage from 'next/image';
|
||||||
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
||||||
|
import { useBackgroundAudioPlayback } from '../../hooks/useBackgroundAudioPlayback';
|
||||||
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
||||||
import { baseURLApi } from '../../config';
|
import { baseURLApi } from '../../config';
|
||||||
|
|
||||||
@ -62,6 +63,15 @@ interface CanvasBackgroundProps {
|
|||||||
videoStoragePath?: string;
|
videoStoragePath?: string;
|
||||||
/** Pause video playback (e.g., during navigation to show frozen frame) */
|
/** Pause video playback (e.g., during navigation to show frozen frame) */
|
||||||
pauseVideo?: boolean;
|
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> = ({
|
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||||
@ -81,6 +91,12 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
videoEndTime = null,
|
videoEndTime = null,
|
||||||
videoStoragePath,
|
videoStoragePath,
|
||||||
pauseVideo = false,
|
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.
|
// 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.
|
// 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,
|
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
|
// Block autoplay if: video already played this session OR externally paused
|
||||||
const effectiveAutoplay =
|
const effectiveAutoplay =
|
||||||
videoAutoplay && !shouldBlockAutoplay && !pauseVideo;
|
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 && (
|
{backgroundAudioUrl && (
|
||||||
<audio
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
key={`bg_audio_${backgroundAudioUrl}`}
|
key={`bg_audio_${backgroundAudioUrl}`}
|
||||||
src={backgroundAudioUrl}
|
src={backgroundAudioUrl}
|
||||||
autoPlay
|
preload='auto'
|
||||||
loop
|
|
||||||
hidden
|
hidden
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -155,6 +155,7 @@ export function ElementEditorPanel({
|
|||||||
setBackgroundVideoUrl,
|
setBackgroundVideoUrl,
|
||||||
setBackgroundAudioUrl,
|
setBackgroundAudioUrl,
|
||||||
setBackgroundVideoSettings,
|
setBackgroundVideoSettings,
|
||||||
|
setBackgroundAudioSettings,
|
||||||
} = useConstructorBackground();
|
} = useConstructorBackground();
|
||||||
|
|
||||||
const { assetOptions } = useConstructorAssets();
|
const { assetOptions } = useConstructorAssets();
|
||||||
@ -240,6 +241,11 @@ export function ElementEditorPanel({
|
|||||||
options={assetOptions.audio}
|
options={assetOptions.audio}
|
||||||
durationNote={durationNotes.backgroundAudio}
|
durationNote={durationNotes.backgroundAudio}
|
||||||
onChange={setBackgroundAudioUrl}
|
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,
|
CarouselSlide,
|
||||||
NavigationButtonKind,
|
NavigationButtonKind,
|
||||||
PageBackgroundVideoSettings,
|
PageBackgroundVideoSettings,
|
||||||
|
PageBackgroundAudioSettings,
|
||||||
EditorMenuItem,
|
EditorMenuItem,
|
||||||
EditorTab,
|
EditorTab,
|
||||||
} from '../../types/constructor';
|
} from '../../types/constructor';
|
||||||
@ -22,6 +23,11 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export type VideoPlaybackSettings = Partial<PageBackgroundVideoSettings>;
|
export type VideoPlaybackSettings = Partial<PageBackgroundVideoSettings>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial audio settings for update callbacks
|
||||||
|
*/
|
||||||
|
export type AudioPlaybackSettings = Partial<PageBackgroundAudioSettings>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor interaction mode
|
* Constructor interaction mode
|
||||||
*/
|
*/
|
||||||
@ -49,6 +55,11 @@ export interface TourPage {
|
|||||||
background_video_muted?: boolean;
|
background_video_muted?: boolean;
|
||||||
background_video_start_time?: number | null;
|
background_video_start_time?: number | null;
|
||||||
background_video_end_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;
|
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
|
* Canvas element props
|
||||||
*/
|
*/
|
||||||
@ -115,45 +110,6 @@ export interface CanvasElementProps {
|
|||||||
onMouseDown?: (event: React.MouseEvent, elementId: string) => void;
|
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
|
* Element editor header props
|
||||||
*/
|
*/
|
||||||
@ -183,6 +139,12 @@ export interface BackgroundSettingsEditorProps {
|
|||||||
videoStartTime?: number | null;
|
videoStartTime?: number | null;
|
||||||
videoEndTime?: number | null;
|
videoEndTime?: number | null;
|
||||||
onVideoSettingsChange?: (settings: VideoPlaybackSettings) => void;
|
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
|
* Unified toolbar props - combines controls panel and menu functionality
|
||||||
* Replaces ConstructorControlsPanelProps and ConstructorMenuProps
|
|
||||||
*/
|
*/
|
||||||
export interface ConstructorToolbarProps {
|
export interface ConstructorToolbarProps {
|
||||||
// Positioning (from useDraggable)
|
// Positioning (from useDraggable)
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
|||||||
import { downloadManager } from '../lib/offline/DownloadManager';
|
import { downloadManager } from '../lib/offline/DownloadManager';
|
||||||
import { isSafari } from '../lib/browserUtils';
|
import { isSafari } from '../lib/browserUtils';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||||
import {
|
import {
|
||||||
resolveNavigationTarget,
|
resolveNavigationTarget,
|
||||||
isTransitionBlocking,
|
isTransitionBlocking,
|
||||||
@ -278,6 +279,7 @@ export default function RuntimePresentation({
|
|||||||
const {
|
const {
|
||||||
currentImageUrl: navCurrentBgImageUrl,
|
currentImageUrl: navCurrentBgImageUrl,
|
||||||
currentVideoUrl: navCurrentBgVideoUrl,
|
currentVideoUrl: navCurrentBgVideoUrl,
|
||||||
|
currentAudioUrl: navCurrentBgAudioUrl,
|
||||||
previousImageUrl: navPreviousBgImageUrl,
|
previousImageUrl: navPreviousBgImageUrl,
|
||||||
previousVideoUrl: navPreviousBgVideoUrl,
|
previousVideoUrl: navPreviousBgVideoUrl,
|
||||||
isSwitching: navIsSwitching,
|
isSwitching: navIsSwitching,
|
||||||
@ -380,6 +382,13 @@ export default function RuntimePresentation({
|
|||||||
navResetToIdle();
|
navResetToIdle();
|
||||||
}, [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 () => {
|
const toggleFullscreen = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
@ -664,6 +673,7 @@ export default function RuntimePresentation({
|
|||||||
// Background URLs come directly from navigation state (already resolved)
|
// Background URLs come directly from navigation state (already resolved)
|
||||||
const backgroundImageUrl = navCurrentBgImageUrl;
|
const backgroundImageUrl = navCurrentBgImageUrl;
|
||||||
const backgroundVideoUrl = navCurrentBgVideoUrl;
|
const backgroundVideoUrl = navCurrentBgVideoUrl;
|
||||||
|
const backgroundAudioUrl = navCurrentBgAudioUrl;
|
||||||
|
|
||||||
// Background video playback settings from selected page
|
// Background video playback settings from selected page
|
||||||
const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
|
const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
|
||||||
@ -680,6 +690,18 @@ export default function RuntimePresentation({
|
|||||||
? parseFloat(String(selectedPage.background_video_end_time))
|
? parseFloat(String(selectedPage.background_video_end_time))
|
||||||
: null;
|
: 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
|
// Sound control hook for iOS autoplay compatibility
|
||||||
// Videos start muted (for iOS autoplay), user can unmute via sound button
|
// Videos start muted (for iOS autoplay), user can unmute via sound button
|
||||||
const soundControl = useVideoSoundControl({
|
const soundControl = useVideoSoundControl({
|
||||||
@ -778,7 +800,12 @@ export default function RuntimePresentation({
|
|||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
{/* Outer container: full viewport with black background for letterbox bars */}
|
{/* 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.
|
{/* Inner canvas: maintains aspect ratio centered in viewport.
|
||||||
z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body. */}
|
z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body. */}
|
||||||
<div
|
<div
|
||||||
@ -817,6 +844,7 @@ export default function RuntimePresentation({
|
|||||||
<CanvasBackground
|
<CanvasBackground
|
||||||
backgroundImageUrl={backgroundImageUrl}
|
backgroundImageUrl={backgroundImageUrl}
|
||||||
backgroundVideoUrl={backgroundVideoUrl}
|
backgroundVideoUrl={backgroundVideoUrl}
|
||||||
|
backgroundAudioUrl={backgroundAudioUrl}
|
||||||
previousBgImageUrl={navPreviousBgImageUrl}
|
previousBgImageUrl={navPreviousBgImageUrl}
|
||||||
previousBgVideoUrl={navPreviousBgVideoUrl}
|
previousBgVideoUrl={navPreviousBgVideoUrl}
|
||||||
isSwitching={navIsSwitching}
|
isSwitching={navIsSwitching}
|
||||||
@ -834,6 +862,11 @@ export default function RuntimePresentation({
|
|||||||
pendingTransitionComplete ||
|
pendingTransitionComplete ||
|
||||||
navIsSwitching
|
navIsSwitching
|
||||||
}
|
}
|
||||||
|
audioAutoplay={audioAutoplay}
|
||||||
|
audioLoop={audioLoop}
|
||||||
|
audioStartTime={audioStartTime}
|
||||||
|
audioEndTime={audioEndTime}
|
||||||
|
audioStoragePath={selectedPage?.background_audio_url}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* End page background wrapper */}
|
{/* End page background wrapper */}
|
||||||
|
|||||||
@ -2,13 +2,14 @@
|
|||||||
* AudioPlayerElement Component
|
* AudioPlayerElement Component
|
||||||
*
|
*
|
||||||
* Audio player element with controls.
|
* 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 { CSSProperties } from 'react';
|
||||||
import type { CanvasElement } from '../../../types/constructor';
|
import type { CanvasElement } from '../../../types/constructor';
|
||||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||||
|
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
|
||||||
|
|
||||||
interface AudioPlayerElementProps {
|
interface AudioPlayerElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -24,6 +25,30 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
|
|||||||
style,
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
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) {
|
if (!element.mediaUrl) {
|
||||||
return (
|
return (
|
||||||
@ -36,6 +61,7 @@ const AudioPlayerElement: React.FC<AudioPlayerElementProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={className} style={style}>
|
<div className={className} style={style}>
|
||||||
<audio
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
className='w-full'
|
className='w-full'
|
||||||
src={resolve(element.mediaUrl)}
|
src={resolve(element.mediaUrl)}
|
||||||
controls
|
controls
|
||||||
|
|||||||
@ -2,17 +2,15 @@
|
|||||||
* VideoPlayerElement Component
|
* VideoPlayerElement Component
|
||||||
*
|
*
|
||||||
* Video player element with controls.
|
* Video player element with controls.
|
||||||
* Uses unified video hook for consistent behavior:
|
* Pauses background audio when playing (ducking).
|
||||||
* - Multi-tier URL resolution (blob → cached → presigned → proxy)
|
|
||||||
* - Safari decode error recovery
|
|
||||||
* - Buffering state indicator
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import type { CanvasElement } from '../../../types/constructor';
|
import type { CanvasElement } from '../../../types/constructor';
|
||||||
import type { PreloadCacheProvider } from '../../../hooks/video';
|
import type { PreloadCacheProvider } from '../../../hooks/video';
|
||||||
import { useVideoPlayer } from '../../../hooks/video';
|
import { useVideoPlayer } from '../../../hooks/video';
|
||||||
|
import { backgroundAudioController } from '../../../lib/backgroundAudioController';
|
||||||
|
|
||||||
interface VideoPlayerElementProps {
|
interface VideoPlayerElementProps {
|
||||||
element: CanvasElement;
|
element: CanvasElement;
|
||||||
@ -36,6 +34,29 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
|
|||||||
trackBuffering: true,
|
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) {
|
if (!element.mediaUrl) {
|
||||||
return (
|
return (
|
||||||
<div className={className} style={style}>
|
<div className={className} style={style}>
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import type {
|
|||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
PageBackgroundState,
|
PageBackgroundState,
|
||||||
PageBackgroundVideoSettings,
|
PageBackgroundVideoSettings,
|
||||||
|
PageBackgroundAudioSettings,
|
||||||
EditorMenuItem,
|
EditorMenuItem,
|
||||||
EditorTab,
|
EditorTab,
|
||||||
GalleryCard,
|
GalleryCard,
|
||||||
@ -123,6 +124,9 @@ export interface ConstructorContextValue {
|
|||||||
setBackgroundVideoSettings: (
|
setBackgroundVideoSettings: (
|
||||||
settings: Partial<PageBackgroundVideoSettings>,
|
settings: Partial<PageBackgroundVideoSettings>,
|
||||||
) => void;
|
) => void;
|
||||||
|
setBackgroundAudioSettings: (
|
||||||
|
settings: Partial<PageBackgroundAudioSettings>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
// Element state
|
// Element state
|
||||||
elements: CanvasElement[];
|
elements: CanvasElement[];
|
||||||
@ -314,6 +318,7 @@ export function useConstructorBackground() {
|
|||||||
setBackgroundVideoUrl: ctx.setBackgroundVideoUrl,
|
setBackgroundVideoUrl: ctx.setBackgroundVideoUrl,
|
||||||
setBackgroundAudioUrl: ctx.setBackgroundAudioUrl,
|
setBackgroundAudioUrl: ctx.setBackgroundAudioUrl,
|
||||||
setBackgroundVideoSettings: ctx.setBackgroundVideoSettings,
|
setBackgroundVideoSettings: ctx.setBackgroundVideoSettings,
|
||||||
|
setBackgroundAudioSettings: ctx.setBackgroundAudioSettings,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
ctx.pageBackground,
|
ctx.pageBackground,
|
||||||
@ -323,6 +328,7 @@ export function useConstructorBackground() {
|
|||||||
ctx.setBackgroundVideoUrl,
|
ctx.setBackgroundVideoUrl,
|
||||||
ctx.setBackgroundAudioUrl,
|
ctx.setBackgroundAudioUrl,
|
||||||
ctx.setBackgroundVideoSettings,
|
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 { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch audio file with credentials and return a blob URL.
|
* Fetch audio file with credentials and return a blob URL.
|
||||||
@ -237,37 +238,35 @@ export function useAudioEffects({
|
|||||||
}, [volume]);
|
}, [volume]);
|
||||||
|
|
||||||
// Play hover audio when hover starts (plays to completion, not interrupted)
|
// Play hover audio when hover starts (plays to completion, not interrupted)
|
||||||
|
// Sound effects layer over background audio (no ducking)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
isHovered &&
|
isHovered &&
|
||||||
!wasHoveredRef.current &&
|
!wasHoveredRef.current &&
|
||||||
hoverAudioRef.current &&
|
hoverAudioRef.current &&
|
||||||
hoverAudioReady
|
hoverAudioReady &&
|
||||||
|
backgroundAudioController.hasInteracted()
|
||||||
) {
|
) {
|
||||||
logger.debug('[AudioEffects] Playing hover audio...');
|
const audio = hoverAudioRef.current;
|
||||||
hoverAudioRef.current.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
// Graceful autoplay handling - catch and ignore autoplay restrictions
|
audio.play().catch(() => undefined);
|
||||||
// Audio will work after user's first click/tap on any element
|
|
||||||
hoverAudioRef.current.play().catch((err) => {
|
|
||||||
logger.warn('[AudioEffects] Play failed:', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
wasHoveredRef.current = isHovered;
|
wasHoveredRef.current = isHovered;
|
||||||
}, [isHovered, hoverAudioReady]);
|
}, [isHovered, hoverAudioReady]);
|
||||||
|
|
||||||
// Play click audio when active state begins
|
// Play click audio when active state begins
|
||||||
|
// Sound effects layer over background audio (no ducking)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
isActive &&
|
isActive &&
|
||||||
!wasActiveRef.current &&
|
!wasActiveRef.current &&
|
||||||
clickAudioRef.current &&
|
clickAudioRef.current &&
|
||||||
clickAudioReady
|
clickAudioReady &&
|
||||||
|
backgroundAudioController.hasInteracted()
|
||||||
) {
|
) {
|
||||||
clickAudioRef.current.currentTime = 0;
|
const audio = clickAudioRef.current;
|
||||||
// Click implies user interaction, so this should rarely fail
|
audio.currentTime = 0;
|
||||||
clickAudioRef.current.play().catch(() => {
|
audio.play().catch(() => undefined);
|
||||||
// Silent fail for edge cases
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
wasActiveRef.current = isActive;
|
wasActiveRef.current = isActive;
|
||||||
}, [isActive, clickAudioReady]);
|
}, [isActive, clickAudioReady]);
|
||||||
@ -281,7 +280,6 @@ export function useAudioEffects({
|
|||||||
}
|
}
|
||||||
}, [clickAudioReady]);
|
}, [clickAudioReady]);
|
||||||
|
|
||||||
// Stop all audio playback
|
|
||||||
const stopAll = useCallback(() => {
|
const stopAll = useCallback(() => {
|
||||||
if (hoverAudioRef.current) {
|
if (hoverAudioRef.current) {
|
||||||
hoverAudioRef.current.pause();
|
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_muted?: boolean;
|
||||||
background_video_start_time?: number | null;
|
background_video_start_time?: number | null;
|
||||||
background_video_end_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 {
|
interface Project {
|
||||||
@ -131,6 +136,12 @@ export function useConstructorPageActions({
|
|||||||
startTime: backgroundVideoStartTime,
|
startTime: backgroundVideoStartTime,
|
||||||
endTime: backgroundVideoEndTime,
|
endTime: backgroundVideoEndTime,
|
||||||
},
|
},
|
||||||
|
audioSettings: {
|
||||||
|
autoplay: backgroundAudioAutoplay,
|
||||||
|
loop: backgroundAudioLoop,
|
||||||
|
startTime: backgroundAudioStartTime,
|
||||||
|
endTime: backgroundAudioEndTime,
|
||||||
|
},
|
||||||
} = pageBackground;
|
} = pageBackground;
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
||||||
@ -206,6 +217,10 @@ export function useConstructorPageActions({
|
|||||||
background_video_muted: backgroundVideoMuted,
|
background_video_muted: backgroundVideoMuted,
|
||||||
background_video_start_time: backgroundVideoStartTime,
|
background_video_start_time: backgroundVideoStartTime,
|
||||||
background_video_end_time: backgroundVideoEndTime,
|
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
|
// Copy project design dimensions to page for presentation isolation
|
||||||
design_width: project?.design_width ?? null,
|
design_width: project?.design_width ?? null,
|
||||||
design_height: project?.design_height ?? null,
|
design_height: project?.design_height ?? null,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import type {
|
import type {
|
||||||
PageBackgroundState,
|
PageBackgroundState,
|
||||||
PageBackgroundVideoSettings,
|
PageBackgroundVideoSettings,
|
||||||
|
PageBackgroundAudioSettings,
|
||||||
} from '../types/constructor';
|
} from '../types/constructor';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PAGE_BACKGROUND,
|
DEFAULT_PAGE_BACKGROUND,
|
||||||
@ -34,6 +35,10 @@ interface TourPageData {
|
|||||||
background_video_muted?: boolean;
|
background_video_muted?: boolean;
|
||||||
background_video_start_time?: number | null;
|
background_video_start_time?: number | null;
|
||||||
background_video_end_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 {
|
export interface UsePageBackgroundOptions {
|
||||||
@ -59,6 +64,9 @@ export interface UsePageBackgroundResult {
|
|||||||
/** Update video settings */
|
/** Update video settings */
|
||||||
setVideoSettings: (settings: Partial<PageBackgroundVideoSettings>) => void;
|
setVideoSettings: (settings: Partial<PageBackgroundVideoSettings>) => void;
|
||||||
|
|
||||||
|
/** Update audio settings */
|
||||||
|
setAudioSettings: (settings: Partial<PageBackgroundAudioSettings>) => void;
|
||||||
|
|
||||||
/** Reset to default state */
|
/** Reset to default state */
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
|
||||||
@ -72,6 +80,10 @@ export interface UsePageBackgroundResult {
|
|||||||
backgroundVideoMuted: boolean;
|
backgroundVideoMuted: boolean;
|
||||||
backgroundVideoStartTime: number | null;
|
backgroundVideoStartTime: number | null;
|
||||||
backgroundVideoEndTime: 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(() => {
|
const reset = useCallback(() => {
|
||||||
setBackground({ ...DEFAULT_PAGE_BACKGROUND });
|
setBackground({ ...DEFAULT_PAGE_BACKGROUND });
|
||||||
}, []);
|
}, []);
|
||||||
@ -159,6 +184,7 @@ export function usePageBackground(
|
|||||||
setVideoUrl,
|
setVideoUrl,
|
||||||
setAudioUrl,
|
setAudioUrl,
|
||||||
setVideoSettings,
|
setVideoSettings,
|
||||||
|
setAudioSettings,
|
||||||
reset,
|
reset,
|
||||||
|
|
||||||
// Legacy compatibility: flat values for gradual migration
|
// Legacy compatibility: flat values for gradual migration
|
||||||
@ -170,6 +196,10 @@ export function usePageBackground(
|
|||||||
backgroundVideoMuted: background.videoSettings.muted,
|
backgroundVideoMuted: background.videoSettings.muted,
|
||||||
backgroundVideoStartTime: background.videoSettings.startTime,
|
backgroundVideoStartTime: background.videoSettings.startTime,
|
||||||
backgroundVideoEndTime: background.videoSettings.endTime,
|
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,
|
extractElementTransitionSettings,
|
||||||
} from '../types/transition';
|
} from '../types/transition';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { backgroundAudioController } from '../lib/backgroundAudioController';
|
||||||
import { isSafari } from '../lib/browserUtils';
|
import { isSafari } from '../lib/browserUtils';
|
||||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||||
import { downloadManager } from '../lib/offline/DownloadManager';
|
import { downloadManager } from '../lib/offline/DownloadManager';
|
||||||
@ -209,6 +210,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
setVideoUrl: setBackgroundVideoUrl,
|
setVideoUrl: setBackgroundVideoUrl,
|
||||||
setAudioUrl: setBackgroundAudioUrl,
|
setAudioUrl: setBackgroundAudioUrl,
|
||||||
setVideoSettings: setBackgroundVideoSettings,
|
setVideoSettings: setBackgroundVideoSettings,
|
||||||
|
setAudioSettings: setBackgroundAudioSettings,
|
||||||
// Legacy compatibility values for components that expect flat props
|
// Legacy compatibility values for components that expect flat props
|
||||||
backgroundImageUrl,
|
backgroundImageUrl,
|
||||||
backgroundVideoUrl,
|
backgroundVideoUrl,
|
||||||
@ -218,6 +220,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
backgroundVideoMuted,
|
backgroundVideoMuted,
|
||||||
backgroundVideoStartTime,
|
backgroundVideoStartTime,
|
||||||
backgroundVideoEndTime,
|
backgroundVideoEndTime,
|
||||||
|
backgroundAudioAutoplay,
|
||||||
|
backgroundAudioLoop,
|
||||||
|
backgroundAudioStartTime,
|
||||||
|
backgroundAudioEndTime,
|
||||||
} = usePageBackground();
|
} = usePageBackground();
|
||||||
|
|
||||||
// Sound control hook for iOS autoplay compatibility
|
// Sound control hook for iOS autoplay compatibility
|
||||||
@ -526,6 +532,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
navResetToIdle();
|
navResetToIdle();
|
||||||
}, [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
|
// Helper to switch pages without flash
|
||||||
// Uses unified navigation state machine for blob URL resolution
|
// Uses unified navigation state machine for blob URL resolution
|
||||||
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
|
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
|
||||||
@ -1606,6 +1624,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
setBackgroundVideoUrl,
|
setBackgroundVideoUrl,
|
||||||
setBackgroundAudioUrl,
|
setBackgroundAudioUrl,
|
||||||
setBackgroundVideoSettings,
|
setBackgroundVideoSettings,
|
||||||
|
setBackgroundAudioSettings,
|
||||||
|
|
||||||
// Element state
|
// Element state
|
||||||
elements,
|
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 */}
|
{/* 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
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@ -1795,6 +1815,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
...letterboxStyles,
|
...letterboxStyles,
|
||||||
...canvasBackgroundStyle,
|
...canvasBackgroundStyle,
|
||||||
}}
|
}}
|
||||||
|
onClick={handleCanvasInteraction}
|
||||||
|
onTouchEnd={handleCanvasInteraction}
|
||||||
>
|
>
|
||||||
<BackdropPortalProvider>
|
<BackdropPortalProvider>
|
||||||
{/* Safari Black Flash Prevention (video transitions only):
|
{/* Safari Black Flash Prevention (video transitions only):
|
||||||
@ -1841,6 +1863,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
pendingTransitionComplete ||
|
pendingTransitionComplete ||
|
||||||
navIsSwitching
|
navIsSwitching
|
||||||
}
|
}
|
||||||
|
audioAutoplay={backgroundAudioAutoplay}
|
||||||
|
audioLoop={backgroundAudioLoop}
|
||||||
|
audioStartTime={backgroundAudioStartTime}
|
||||||
|
audioEndTime={backgroundAudioEndTime}
|
||||||
|
audioStoragePath={
|
||||||
|
backgroundAudioUrl || activePage?.background_audio_url
|
||||||
|
}
|
||||||
|
pauseAudio={isConstructorEditMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -797,6 +797,16 @@ export interface PageBackgroundVideoSettings {
|
|||||||
endTime: number | null;
|
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
|
* Consolidated page background state
|
||||||
* Replaces 8 separate useState hooks in constructor.tsx
|
* Replaces 8 separate useState hooks in constructor.tsx
|
||||||
@ -810,6 +820,8 @@ export interface PageBackgroundState {
|
|||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
/** Video playback settings */
|
/** Video playback settings */
|
||||||
videoSettings: PageBackgroundVideoSettings;
|
videoSettings: PageBackgroundVideoSettings;
|
||||||
|
/** Audio playback settings */
|
||||||
|
audioSettings: PageBackgroundAudioSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -823,6 +835,16 @@ export const DEFAULT_VIDEO_SETTINGS: PageBackgroundVideoSettings = {
|
|||||||
endTime: null,
|
endTime: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default audio settings
|
||||||
|
*/
|
||||||
|
export const DEFAULT_AUDIO_SETTINGS: PageBackgroundAudioSettings = {
|
||||||
|
autoplay: true,
|
||||||
|
loop: true,
|
||||||
|
startTime: null,
|
||||||
|
endTime: null,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default page background state
|
* Default page background state
|
||||||
*/
|
*/
|
||||||
@ -831,6 +853,7 @@ export const DEFAULT_PAGE_BACKGROUND: PageBackgroundState = {
|
|||||||
videoUrl: '',
|
videoUrl: '',
|
||||||
audioUrl: '',
|
audioUrl: '',
|
||||||
videoSettings: { ...DEFAULT_VIDEO_SETTINGS },
|
videoSettings: { ...DEFAULT_VIDEO_SETTINGS },
|
||||||
|
audioSettings: { ...DEFAULT_AUDIO_SETTINGS },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -846,6 +869,10 @@ export function createPageBackgroundFromPage(
|
|||||||
background_video_muted?: boolean;
|
background_video_muted?: boolean;
|
||||||
background_video_start_time?: number | null;
|
background_video_start_time?: number | null;
|
||||||
background_video_end_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,
|
} | null,
|
||||||
): PageBackgroundState {
|
): PageBackgroundState {
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@ -869,5 +896,17 @@ export function createPageBackgroundFromPage(
|
|||||||
? parseFloat(String(page.background_video_end_time))
|
? parseFloat(String(page.background_video_end_time))
|
||||||
: null,
|
: 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_muted?: boolean;
|
||||||
background_video_start_time?: number | null;
|
background_video_start_time?: number | null;
|
||||||
background_video_end_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 canvas dimensions (copied from project on save for presentation isolation)
|
||||||
design_width?: number | null;
|
design_width?: number | null;
|
||||||
design_height?: number | null;
|
design_height?: number | null;
|
||||||
|
|||||||
@ -38,6 +38,11 @@ export interface RuntimePage extends PreloadPage {
|
|||||||
background_video_muted?: boolean;
|
background_video_muted?: boolean;
|
||||||
background_video_start_time?: number | null;
|
background_video_start_time?: number | null;
|
||||||
background_video_end_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 canvas dimensions (copied from project on save for presentation isolation)
|
||||||
design_width?: number | null;
|
design_width?: number | null;
|
||||||
design_height?: number | null;
|
design_height?: number | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user