extended background video functionality
This commit is contained in:
parent
b5f8f30360
commit
eb712e86f2
@ -70,6 +70,26 @@ class Tour_pagesDBApi extends GenericDBApi {
|
||||
background_video_url: data.background_video_url || null,
|
||||
background_audio_url: data.background_audio_url || null,
|
||||
background_loop: data.background_loop || false,
|
||||
background_video_autoplay:
|
||||
data.background_video_autoplay !== undefined
|
||||
? data.background_video_autoplay
|
||||
: true,
|
||||
background_video_loop:
|
||||
data.background_video_loop !== undefined
|
||||
? data.background_video_loop
|
||||
: true,
|
||||
background_video_muted:
|
||||
data.background_video_muted !== undefined
|
||||
? data.background_video_muted
|
||||
: true,
|
||||
background_video_start_time:
|
||||
data.background_video_start_time !== undefined
|
||||
? data.background_video_start_time
|
||||
: null,
|
||||
background_video_end_time:
|
||||
data.background_video_end_time !== undefined
|
||||
? data.background_video_end_time
|
||||
: null,
|
||||
requires_auth: data.requires_auth || false,
|
||||
ui_schema_json: data.ui_schema_json || null,
|
||||
};
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('tour_pages', 'background_video_autoplay', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
await queryInterface.addColumn('tour_pages', 'background_video_loop', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
await queryInterface.addColumn('tour_pages', 'background_video_muted', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
await queryInterface.addColumn('tour_pages', 'background_video_start_time', {
|
||||
type: Sequelize.DECIMAL(10, 1),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
});
|
||||
await queryInterface.addColumn('tour_pages', 'background_video_end_time', {
|
||||
type: Sequelize.DECIMAL(10, 1),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, _Sequelize) {
|
||||
await queryInterface.removeColumn('tour_pages', 'background_video_autoplay');
|
||||
await queryInterface.removeColumn('tour_pages', 'background_video_loop');
|
||||
await queryInterface.removeColumn('tour_pages', 'background_video_muted');
|
||||
await queryInterface.removeColumn('tour_pages', 'background_video_start_time');
|
||||
await queryInterface.removeColumn('tour_pages', 'background_video_end_time');
|
||||
},
|
||||
};
|
||||
@ -73,6 +73,36 @@ module.exports = function (sequelize, DataTypes) {
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
background_video_autoplay: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
|
||||
background_video_loop: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
|
||||
background_video_muted: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
|
||||
background_video_start_time: {
|
||||
type: DataTypes.DECIMAL(10, 1),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
|
||||
background_video_end_time: {
|
||||
type: DataTypes.DECIMAL(10, 1),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
|
||||
requires_auth: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -3,12 +3,21 @@
|
||||
*
|
||||
* Compact editor for background image, video, or audio settings.
|
||||
* Used in the element editor panel for background menu items.
|
||||
* For video backgrounds, includes playback settings (autoplay, loop, muted, start/end time).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { AssetOption } from './types';
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
|
||||
export interface VideoPlaybackSettings {
|
||||
autoplay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
startTime?: number | null;
|
||||
endTime?: number | null;
|
||||
}
|
||||
|
||||
interface BackgroundSettingsEditorProps {
|
||||
type: 'image' | 'video' | 'audio';
|
||||
value: string;
|
||||
@ -16,6 +25,13 @@ interface BackgroundSettingsEditorProps {
|
||||
durationNote?: string;
|
||||
onChange: (value: string) => void;
|
||||
onClearImage?: () => void;
|
||||
// Video-specific playback settings (only used when type='video')
|
||||
videoAutoplay?: boolean;
|
||||
videoLoop?: boolean;
|
||||
videoMuted?: boolean;
|
||||
videoStartTime?: number | null;
|
||||
videoEndTime?: number | null;
|
||||
onVideoSettingsChange?: (settings: VideoPlaybackSettings) => void;
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
@ -31,6 +47,12 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||
durationNote,
|
||||
onChange,
|
||||
onClearImage,
|
||||
videoAutoplay,
|
||||
videoLoop,
|
||||
videoMuted,
|
||||
videoStartTime,
|
||||
videoEndTime,
|
||||
onVideoSettingsChange,
|
||||
}) => {
|
||||
const label = LABELS[type] || 'Background';
|
||||
const selectOptions = addFallbackAssetOption(
|
||||
@ -39,6 +61,9 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||
`Current ${type} · ${value}`,
|
||||
);
|
||||
|
||||
// Show video playback settings when type is video and a video is selected
|
||||
const showVideoSettings = type === 'video' && value && onVideoSettingsChange;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||
@ -66,6 +91,92 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||
{durationNote && (
|
||||
<p className='mt-1 text-[11px] text-gray-500'>{durationNote}</p>
|
||||
)}
|
||||
|
||||
{/* Video Playback Settings */}
|
||||
{showVideoSettings && (
|
||||
<div className='mt-3 space-y-2 border-t border-gray-200 pt-3'>
|
||||
<p className='text-[10px] font-semibold uppercase text-gray-500'>
|
||||
Playback Settings
|
||||
</p>
|
||||
|
||||
<label className='flex cursor-pointer items-center gap-2 text-[11px] text-gray-700'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='h-3 w-3 rounded border-gray-300'
|
||||
checked={videoAutoplay ?? true}
|
||||
onChange={(e) =>
|
||||
onVideoSettingsChange({ autoplay: e.target.checked })
|
||||
}
|
||||
/>
|
||||
Autoplay
|
||||
</label>
|
||||
|
||||
<label className='flex cursor-pointer items-center gap-2 text-[11px] text-gray-700'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='h-3 w-3 rounded border-gray-300'
|
||||
checked={videoLoop ?? true}
|
||||
onChange={(e) =>
|
||||
onVideoSettingsChange({ loop: e.target.checked })
|
||||
}
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
|
||||
<label className='flex cursor-pointer items-center gap-2 text-[11px] text-gray-700'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='h-3 w-3 rounded border-gray-300'
|
||||
checked={!(videoMuted ?? true)}
|
||||
onChange={(e) =>
|
||||
onVideoSettingsChange({ muted: !e.target.checked })
|
||||
}
|
||||
/>
|
||||
Sound
|
||||
</label>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex-1'>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
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={videoStartTime ?? ''}
|
||||
onChange={(e) =>
|
||||
onVideoSettingsChange({
|
||||
startTime: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
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={videoEndTime ?? ''}
|
||||
onChange={(e) =>
|
||||
onVideoSettingsChange({
|
||||
endTime: e.target.value ? parseFloat(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -3,10 +3,12 @@
|
||||
*
|
||||
* Background image, video, and audio for the constructor canvas.
|
||||
* Handles blob URLs, Next.js Image optimization, and previous background overlay.
|
||||
* Supports custom video playback settings (autoplay, loop, muted, start/end time).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import NextImage from 'next/image';
|
||||
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
||||
|
||||
interface CanvasBackgroundProps {
|
||||
backgroundImageUrl?: string;
|
||||
@ -16,6 +18,12 @@ interface CanvasBackgroundProps {
|
||||
isSwitching?: boolean;
|
||||
isNewBgReady?: boolean;
|
||||
onBackgroundReady?: () => void;
|
||||
// Video playback settings
|
||||
videoAutoplay?: boolean;
|
||||
videoLoop?: boolean;
|
||||
videoMuted?: boolean;
|
||||
videoStartTime?: number | null;
|
||||
videoEndTime?: number | null;
|
||||
}
|
||||
|
||||
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
@ -26,7 +34,22 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
isSwitching = false,
|
||||
isNewBgReady = false,
|
||||
onBackgroundReady,
|
||||
videoAutoplay = true,
|
||||
videoLoop = true,
|
||||
videoMuted = true,
|
||||
videoStartTime = null,
|
||||
videoEndTime = null,
|
||||
}) => {
|
||||
// Use background video playback hook for custom start/end time handling
|
||||
const { videoRef } = useBackgroundVideoPlayback({
|
||||
videoUrl: backgroundVideoUrl,
|
||||
autoplay: videoAutoplay,
|
||||
loop: videoLoop,
|
||||
muted: videoMuted,
|
||||
startTime: videoStartTime,
|
||||
endTime: videoEndTime,
|
||||
});
|
||||
|
||||
const handleLoad = () => {
|
||||
onBackgroundReady?.();
|
||||
};
|
||||
@ -35,18 +58,21 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
onBackgroundReady?.();
|
||||
};
|
||||
|
||||
// When endTime is set, we disable native loop and handle it via the hook
|
||||
const useNativeLoop = videoEndTime == null ? videoLoop : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background image - z-1 keeps it below backdrop blur layer (z-5) */}
|
||||
{backgroundImageUrl && (
|
||||
<div className='absolute inset-0 z-1 h-full w-full pointer-events-none select-none'>
|
||||
<div className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'>
|
||||
{backgroundImageUrl.startsWith('blob:') ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={`bg_image_${backgroundImageUrl}`}
|
||||
src={backgroundImageUrl}
|
||||
alt='Background'
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
className='absolute inset-0 h-full w-full object-cover'
|
||||
draggable={false}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
@ -71,7 +97,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
{/* Previous background overlay - shows during page switch until new bg is ready */}
|
||||
{previousBgImageUrl && isSwitching && !isNewBgReady && (
|
||||
<div
|
||||
className='absolute inset-0 pointer-events-none z-10'
|
||||
className='pointer-events-none absolute inset-0 z-10'
|
||||
style={{
|
||||
backgroundImage: `url("${previousBgImageUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
@ -83,12 +109,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
|
||||
{backgroundVideoUrl && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={`bg_video_${backgroundVideoUrl}`}
|
||||
className='absolute inset-0 z-1 w-full h-full object-cover'
|
||||
className='absolute inset-0 z-1 h-full w-full object-cover'
|
||||
src={backgroundVideoUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
autoPlay={videoAutoplay}
|
||||
loop={useNativeLoop}
|
||||
muted={videoMuted}
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -94,6 +94,20 @@ interface ElementEditorPanelProps {
|
||||
onBackgroundVideoChange: (value: string) => void;
|
||||
onBackgroundAudioChange: (value: string) => void;
|
||||
|
||||
// Background video playback settings
|
||||
backgroundVideoAutoplay: boolean;
|
||||
backgroundVideoLoop: boolean;
|
||||
backgroundVideoMuted: boolean;
|
||||
backgroundVideoStartTime: number | null;
|
||||
backgroundVideoEndTime: number | null;
|
||||
onBackgroundVideoSettingsChange: (settings: {
|
||||
autoplay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
startTime?: number | null;
|
||||
endTime?: number | null;
|
||||
}) => void;
|
||||
|
||||
// Transition creation
|
||||
newTransitionName: string;
|
||||
newTransitionVideoUrl: string;
|
||||
@ -220,6 +234,12 @@ export function ElementEditorPanel({
|
||||
onBackgroundImageChange,
|
||||
onBackgroundVideoChange,
|
||||
onBackgroundAudioChange,
|
||||
backgroundVideoAutoplay,
|
||||
backgroundVideoLoop,
|
||||
backgroundVideoMuted,
|
||||
backgroundVideoStartTime,
|
||||
backgroundVideoEndTime,
|
||||
onBackgroundVideoSettingsChange,
|
||||
newTransitionName,
|
||||
newTransitionVideoUrl,
|
||||
newTransitionSupportsReverse,
|
||||
@ -288,6 +308,12 @@ export function ElementEditorPanel({
|
||||
onBackgroundVideoChange(value);
|
||||
if (value) onBackgroundImageChange('');
|
||||
}}
|
||||
videoAutoplay={backgroundVideoAutoplay}
|
||||
videoLoop={backgroundVideoLoop}
|
||||
videoMuted={backgroundVideoMuted}
|
||||
videoStartTime={backgroundVideoStartTime}
|
||||
videoEndTime={backgroundVideoEndTime}
|
||||
onVideoSettingsChange={onBackgroundVideoSettingsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -50,6 +50,12 @@ export interface TourPage {
|
||||
background_video_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_loop?: boolean;
|
||||
// Background video playback settings
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -127,6 +133,12 @@ export interface CanvasBackgroundProps {
|
||||
isSwitching?: boolean;
|
||||
isNewBgReady?: boolean;
|
||||
onBackgroundReady?: () => void;
|
||||
// Video playback settings
|
||||
videoAutoplay?: boolean;
|
||||
videoLoop?: boolean;
|
||||
videoMuted?: boolean;
|
||||
videoStartTime?: number | null;
|
||||
videoEndTime?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -161,6 +173,17 @@ export interface ElementEditorHeaderProps {
|
||||
onDragStart: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video playback settings for background video
|
||||
*/
|
||||
export interface VideoPlaybackSettings {
|
||||
autoplay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
startTime?: number | null;
|
||||
endTime?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background settings editor props
|
||||
*/
|
||||
@ -171,6 +194,13 @@ export interface BackgroundSettingsEditorProps {
|
||||
durationNote?: string;
|
||||
onChange: (value: string) => void;
|
||||
onImageClear?: () => void;
|
||||
// Video-specific playback settings (only used when type='video')
|
||||
videoAutoplay?: boolean;
|
||||
videoLoop?: boolean;
|
||||
videoMuted?: boolean;
|
||||
videoStartTime?: number | null;
|
||||
videoEndTime?: number | null;
|
||||
onVideoSettingsChange?: (settings: VideoPlaybackSettings) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -209,6 +239,14 @@ export interface ElementEditorPanelProps {
|
||||
backgroundVideoDurationNote: string;
|
||||
backgroundAudioDurationNote: string;
|
||||
|
||||
// Background video playback settings
|
||||
backgroundVideoAutoplay: boolean;
|
||||
backgroundVideoLoop: boolean;
|
||||
backgroundVideoMuted: boolean;
|
||||
backgroundVideoStartTime: number | null;
|
||||
backgroundVideoEndTime: number | null;
|
||||
onBackgroundVideoSettingsChange: (settings: VideoPlaybackSettings) => void;
|
||||
|
||||
// Transition form
|
||||
newTransitionName: string;
|
||||
newTransitionVideoUrl: string;
|
||||
|
||||
@ -333,23 +333,19 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
|
||||
galleryTitleFontFamily: String(settings.galleryTitleFontFamily || ''),
|
||||
galleryTextFontFamily: String(settings.galleryTextFontFamily || ''),
|
||||
galleryCards: Array.isArray(settings.galleryCards)
|
||||
? settings.galleryCards.map(
|
||||
(card: Record<string, unknown>) => ({
|
||||
id: String(card?.id || createLocalId()),
|
||||
imageUrl: String(card?.imageUrl ?? ''),
|
||||
title: String(card?.title ?? ''),
|
||||
description: String(card?.description ?? ''),
|
||||
}),
|
||||
)
|
||||
? settings.galleryCards.map((card: Record<string, unknown>) => ({
|
||||
id: String(card?.id || createLocalId()),
|
||||
imageUrl: String(card?.imageUrl ?? ''),
|
||||
title: String(card?.title ?? ''),
|
||||
description: String(card?.description ?? ''),
|
||||
}))
|
||||
: [],
|
||||
carouselSlides: Array.isArray(settings.carouselSlides)
|
||||
? settings.carouselSlides.map(
|
||||
(slide: Record<string, unknown>) => ({
|
||||
id: String(slide?.id || createLocalId()),
|
||||
imageUrl: String(slide?.imageUrl ?? ''),
|
||||
caption: String(slide?.caption ?? ''),
|
||||
}),
|
||||
)
|
||||
? settings.carouselSlides.map((slide: Record<string, unknown>) => ({
|
||||
id: String(slide?.id || createLocalId()),
|
||||
imageUrl: String(slide?.imageUrl ?? ''),
|
||||
caption: String(slide?.caption ?? ''),
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
},
|
||||
|
||||
@ -30,6 +30,7 @@ import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||
import { useBackgroundVideoPlayback } from '../hooks/useBackgroundVideoPlayback';
|
||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { logger } from '../lib/logger';
|
||||
import {
|
||||
@ -370,6 +371,32 @@ export default function RuntimePresentation({
|
||||
const backgroundImageUrl = pageSwitch.currentBgImageUrl;
|
||||
const backgroundVideoUrl = pageSwitch.currentBgVideoUrl;
|
||||
|
||||
// Background video playback settings from selected page
|
||||
const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
|
||||
const videoLoop = selectedPage?.background_video_loop ?? true;
|
||||
const videoMuted = selectedPage?.background_video_muted ?? true;
|
||||
const videoStartTime =
|
||||
selectedPage?.background_video_start_time != null
|
||||
? parseFloat(String(selectedPage.background_video_start_time))
|
||||
: null;
|
||||
const videoEndTime =
|
||||
selectedPage?.background_video_end_time != null
|
||||
? parseFloat(String(selectedPage.background_video_end_time))
|
||||
: null;
|
||||
|
||||
// Use background video playback hook for custom start/end time handling
|
||||
const { videoRef: bgVideoRef } = useBackgroundVideoPlayback({
|
||||
videoUrl: backgroundVideoUrl,
|
||||
autoplay: videoAutoplay,
|
||||
loop: videoLoop,
|
||||
muted: videoMuted,
|
||||
startTime: videoStartTime,
|
||||
endTime: videoEndTime,
|
||||
});
|
||||
|
||||
// When endTime is set, we disable native loop and handle it via the hook
|
||||
const useNativeLoop = videoEndTime == null ? videoLoop : false;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
|
||||
@ -502,12 +529,13 @@ export default function RuntimePresentation({
|
||||
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
|
||||
{backgroundVideoUrl && (
|
||||
<video
|
||||
ref={bgVideoRef}
|
||||
key={backgroundVideoUrl}
|
||||
className='absolute inset-0 z-1 w-full h-full object-cover'
|
||||
className='absolute inset-0 z-1 h-full w-full object-cover'
|
||||
src={backgroundVideoUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
autoPlay={videoAutoplay}
|
||||
loop={useNativeLoop}
|
||||
muted={videoMuted}
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -26,6 +26,11 @@ export type {
|
||||
UseBackgroundTransitionOptions,
|
||||
UseBackgroundTransitionResult,
|
||||
} from './useBackgroundTransition';
|
||||
export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback';
|
||||
export type {
|
||||
UseBackgroundVideoPlaybackOptions,
|
||||
UseBackgroundVideoPlaybackResult,
|
||||
} from './useBackgroundVideoPlayback';
|
||||
export { usePageDataLoader } from './usePageDataLoader';
|
||||
export type {
|
||||
UsePageDataLoaderOptions,
|
||||
|
||||
180
frontend/src/hooks/useBackgroundVideoPlayback.ts
Normal file
180
frontend/src/hooks/useBackgroundVideoPlayback.ts
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* useBackgroundVideoPlayback Hook
|
||||
*
|
||||
* Manages background video playback with custom start/end times.
|
||||
* Follows patterns from useTransitionPlayback for video time control.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, type RefObject } from 'react';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface UseBackgroundVideoPlaybackOptions {
|
||||
/** URL of the video to play */
|
||||
videoUrl?: string;
|
||||
/** Whether to autoplay the video (default: true) */
|
||||
autoplay?: boolean;
|
||||
/** Whether to loop the video (default: true) */
|
||||
loop?: boolean;
|
||||
/** Whether the video is muted (default: true) */
|
||||
muted?: 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;
|
||||
}
|
||||
|
||||
export interface UseBackgroundVideoPlaybackResult {
|
||||
/** Ref to attach to the video element */
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing background video playback with custom start/end times.
|
||||
*
|
||||
* When startTime is set, the video will seek to that position on load.
|
||||
* When endTime is set, the video will either loop back to startTime or pause,
|
||||
* depending on the loop setting.
|
||||
*
|
||||
* @example
|
||||
* const { videoRef } = useBackgroundVideoPlayback({
|
||||
* videoUrl: 'https://example.com/video.mp4',
|
||||
* autoplay: true,
|
||||
* loop: true,
|
||||
* muted: true,
|
||||
* startTime: 2.5,
|
||||
* endTime: 10.0,
|
||||
* });
|
||||
*
|
||||
* return <video ref={videoRef} src={videoUrl} />;
|
||||
*/
|
||||
export function useBackgroundVideoPlayback({
|
||||
videoUrl,
|
||||
autoplay = true,
|
||||
loop = true,
|
||||
muted = true,
|
||||
startTime = null,
|
||||
endTime = null,
|
||||
}: UseBackgroundVideoPlaybackOptions): UseBackgroundVideoPlaybackResult {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
// Store current values in refs for event handlers to access
|
||||
const startTimeRef = useRef(startTime);
|
||||
const endTimeRef = useRef(endTime);
|
||||
const loopRef = useRef(loop);
|
||||
const autoplayRef = useRef(autoplay);
|
||||
|
||||
// Update refs when values change
|
||||
useEffect(() => {
|
||||
startTimeRef.current = startTime;
|
||||
}, [startTime]);
|
||||
|
||||
useEffect(() => {
|
||||
endTimeRef.current = endTime;
|
||||
}, [endTime]);
|
||||
|
||||
useEffect(() => {
|
||||
loopRef.current = loop;
|
||||
}, [loop]);
|
||||
|
||||
useEffect(() => {
|
||||
autoplayRef.current = autoplay;
|
||||
}, [autoplay]);
|
||||
|
||||
// Seek to start time when specified and video is ready
|
||||
const seekToStartTime = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
const st = startTimeRef.current;
|
||||
if (!video || st == null || st <= 0) return;
|
||||
|
||||
video.currentTime = st;
|
||||
logger.info('Background video: seeking to start time', { startTime: st });
|
||||
}, []);
|
||||
|
||||
// Handle start time changes - seek immediately when startTime changes
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !videoUrl) return;
|
||||
|
||||
// If video has loaded metadata, seek immediately
|
||||
if (video.readyState >= 1 && startTime != null && startTime > 0) {
|
||||
video.currentTime = startTime;
|
||||
logger.info('Background video: seeking to start time', { startTime });
|
||||
}
|
||||
|
||||
// Set up listener for initial load (if not loaded yet)
|
||||
const handleLoadedMetadata = () => {
|
||||
seekToStartTime();
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
};
|
||||
}, [videoUrl, startTime, seekToStartTime]);
|
||||
|
||||
// Handle autoplay state changes
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !videoUrl) return;
|
||||
|
||||
if (autoplay) {
|
||||
video.play().catch((error) => {
|
||||
// Autoplay blocked by browser - this is expected behavior
|
||||
logger.info('Background video autoplay blocked by browser', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [videoUrl, autoplay]);
|
||||
|
||||
// Handle end time enforcement via timeupdate
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !videoUrl) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
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 (video.currentTime >= currentEndTime) {
|
||||
if (currentLoop) {
|
||||
// Loop back to start time (or beginning)
|
||||
const loopTarget = currentStartTime ?? 0;
|
||||
video.currentTime = loopTarget;
|
||||
logger.info('Background video: looping back', {
|
||||
from: currentEndTime,
|
||||
to: loopTarget,
|
||||
});
|
||||
} else {
|
||||
// Pause at end time
|
||||
video.pause();
|
||||
logger.info('Background video: paused at end time', {
|
||||
endTime: currentEndTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
};
|
||||
}, [videoUrl]);
|
||||
|
||||
// Handle muted state changes
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.muted = muted;
|
||||
}, [muted]);
|
||||
|
||||
return { videoRef };
|
||||
}
|
||||
|
||||
export default useBackgroundVideoPlayback;
|
||||
@ -25,6 +25,12 @@ interface TourPage {
|
||||
background_video_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_loop?: boolean;
|
||||
// Background video playback settings
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
}
|
||||
|
||||
interface UseConstructorPageActionsOptions {
|
||||
@ -42,6 +48,12 @@ interface UseConstructorPageActionsOptions {
|
||||
backgroundImageUrl: string;
|
||||
backgroundVideoUrl: string;
|
||||
backgroundAudioUrl: string;
|
||||
/** Background video playback settings */
|
||||
backgroundVideoAutoplay: boolean;
|
||||
backgroundVideoLoop: boolean;
|
||||
backgroundVideoMuted: boolean;
|
||||
backgroundVideoStartTime: number | null;
|
||||
backgroundVideoEndTime: number | null;
|
||||
/** Callback to reload data after operations */
|
||||
onReload: (preservePageId?: string) => Promise<void>;
|
||||
/** Callback to set active page ID */
|
||||
@ -111,6 +123,11 @@ export function useConstructorPageActions({
|
||||
backgroundImageUrl,
|
||||
backgroundVideoUrl,
|
||||
backgroundAudioUrl,
|
||||
backgroundVideoAutoplay,
|
||||
backgroundVideoLoop,
|
||||
backgroundVideoMuted,
|
||||
backgroundVideoStartTime,
|
||||
backgroundVideoEndTime,
|
||||
onReload,
|
||||
onSetActivePageId,
|
||||
onSetMenuOpen,
|
||||
@ -154,6 +171,11 @@ export function useConstructorPageActions({
|
||||
background_video_url: backgroundVideoUrl,
|
||||
background_audio_url: backgroundAudioUrl,
|
||||
background_loop: Boolean(backgroundAudioUrl),
|
||||
background_video_autoplay: backgroundVideoAutoplay,
|
||||
background_video_loop: backgroundVideoLoop,
|
||||
background_video_muted: backgroundVideoMuted,
|
||||
background_video_start_time: backgroundVideoStartTime,
|
||||
background_video_end_time: backgroundVideoEndTime,
|
||||
},
|
||||
});
|
||||
|
||||
@ -186,6 +208,11 @@ export function useConstructorPageActions({
|
||||
backgroundAudioUrl,
|
||||
backgroundImageUrl,
|
||||
backgroundVideoUrl,
|
||||
backgroundVideoAutoplay,
|
||||
backgroundVideoLoop,
|
||||
backgroundVideoMuted,
|
||||
backgroundVideoStartTime,
|
||||
backgroundVideoEndTime,
|
||||
elements,
|
||||
onError,
|
||||
onReload,
|
||||
|
||||
@ -29,6 +29,12 @@ export interface SwitchablePage {
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_audio_url?: string;
|
||||
// Background video playback settings
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -98,6 +98,12 @@ type TourPage = {
|
||||
background_video_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_loop?: boolean;
|
||||
// Background video playback settings
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
};
|
||||
|
||||
type NavigationElementType = Extract<
|
||||
@ -165,6 +171,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
const [backgroundImageUrl, setBackgroundImageUrl] = useState('');
|
||||
const [backgroundVideoUrl, setBackgroundVideoUrl] = useState('');
|
||||
const [backgroundAudioUrl, setBackgroundAudioUrl] = useState('');
|
||||
// Background video playback settings
|
||||
const [backgroundVideoAutoplay, setBackgroundVideoAutoplay] = useState(true);
|
||||
const [backgroundVideoLoop, setBackgroundVideoLoop] = useState(true);
|
||||
const [backgroundVideoMuted, setBackgroundVideoMuted] = useState(true);
|
||||
const [backgroundVideoStartTime, setBackgroundVideoStartTime] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [backgroundVideoEndTime, setBackgroundVideoEndTime] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [selectedMenuItem, setSelectedMenuItem] =
|
||||
useState<EditorMenuItem>('none');
|
||||
// Transition preview state managed by useTransitionPreview hook (below)
|
||||
@ -340,6 +356,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
setBackgroundImageUrl(page?.background_image_url || '');
|
||||
setBackgroundVideoUrl(page?.background_video_url || '');
|
||||
setBackgroundAudioUrl(page?.background_audio_url || '');
|
||||
// Set background video playback settings
|
||||
setBackgroundVideoAutoplay(page?.background_video_autoplay ?? true);
|
||||
setBackgroundVideoLoop(page?.background_video_loop ?? true);
|
||||
setBackgroundVideoMuted(page?.background_video_muted ?? true);
|
||||
// Parse DECIMAL strings from database to numbers
|
||||
setBackgroundVideoStartTime(
|
||||
page?.background_video_start_time != null
|
||||
? parseFloat(String(page.background_video_start_time))
|
||||
: null,
|
||||
);
|
||||
setBackgroundVideoEndTime(
|
||||
page?.background_video_end_time != null
|
||||
? parseFloat(String(page.background_video_end_time))
|
||||
: null,
|
||||
);
|
||||
|
||||
// Use hook to resolve and set blob URLs for display
|
||||
await pageSwitch.switchToPage(
|
||||
@ -735,6 +766,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
backgroundImageUrl,
|
||||
backgroundVideoUrl,
|
||||
backgroundAudioUrl,
|
||||
backgroundVideoAutoplay,
|
||||
backgroundVideoLoop,
|
||||
backgroundVideoMuted,
|
||||
backgroundVideoStartTime,
|
||||
backgroundVideoEndTime,
|
||||
onReload: loadData,
|
||||
onSetActivePageId: setActivePageId,
|
||||
onSetMenuOpen: setIsMenuOpen,
|
||||
@ -1023,6 +1059,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
setBackgroundImageUrl(activePage.background_image_url || '');
|
||||
setBackgroundVideoUrl(activePage.background_video_url || '');
|
||||
setBackgroundAudioUrl(activePage.background_audio_url || '');
|
||||
// Set background video playback settings
|
||||
setBackgroundVideoAutoplay(activePage.background_video_autoplay ?? true);
|
||||
setBackgroundVideoLoop(activePage.background_video_loop ?? true);
|
||||
setBackgroundVideoMuted(activePage.background_video_muted ?? true);
|
||||
// Parse DECIMAL strings from database to numbers
|
||||
setBackgroundVideoStartTime(
|
||||
activePage.background_video_start_time != null
|
||||
? parseFloat(String(activePage.background_video_start_time))
|
||||
: null,
|
||||
);
|
||||
setBackgroundVideoEndTime(
|
||||
activePage.background_video_end_time != null
|
||||
? parseFloat(String(activePage.background_video_end_time))
|
||||
: null,
|
||||
);
|
||||
// Resolve blob URLs via hook for display (handles initial load and route changes)
|
||||
// Only call if this page wasn't already initialized via switchToPage function
|
||||
if (lastInitializedPageIdRef.current !== activePage.id) {
|
||||
@ -1372,6 +1423,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
isSwitching={pageSwitch.isSwitching}
|
||||
isNewBgReady={pageSwitch.isNewBgReady}
|
||||
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
|
||||
videoAutoplay={backgroundVideoAutoplay}
|
||||
videoLoop={backgroundVideoLoop}
|
||||
videoMuted={backgroundVideoMuted}
|
||||
videoStartTime={backgroundVideoStartTime}
|
||||
videoEndTime={backgroundVideoEndTime}
|
||||
/>
|
||||
|
||||
{/* Elements container - z-10 ensures they appear above backdrop layer */}
|
||||
@ -1457,6 +1513,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
onBackgroundImageChange={setBackgroundImageUrl}
|
||||
onBackgroundVideoChange={setBackgroundVideoUrl}
|
||||
onBackgroundAudioChange={setBackgroundAudioUrl}
|
||||
backgroundVideoAutoplay={backgroundVideoAutoplay}
|
||||
backgroundVideoLoop={backgroundVideoLoop}
|
||||
backgroundVideoMuted={backgroundVideoMuted}
|
||||
backgroundVideoStartTime={backgroundVideoStartTime}
|
||||
backgroundVideoEndTime={backgroundVideoEndTime}
|
||||
onBackgroundVideoSettingsChange={(settings) => {
|
||||
if (settings.autoplay !== undefined)
|
||||
setBackgroundVideoAutoplay(settings.autoplay);
|
||||
if (settings.loop !== undefined)
|
||||
setBackgroundVideoLoop(settings.loop);
|
||||
if (settings.muted !== undefined)
|
||||
setBackgroundVideoMuted(settings.muted);
|
||||
if (settings.startTime !== undefined)
|
||||
setBackgroundVideoStartTime(settings.startTime);
|
||||
if (settings.endTime !== undefined)
|
||||
setBackgroundVideoEndTime(settings.endTime);
|
||||
}}
|
||||
newTransitionName={newTransitionName}
|
||||
newTransitionVideoUrl={newTransitionVideoUrl}
|
||||
newTransitionSupportsReverse={newTransitionSupportsReverse}
|
||||
|
||||
@ -29,6 +29,12 @@ export interface RuntimePage extends PreloadPage {
|
||||
sort_order?: number;
|
||||
ui_schema_json?: string;
|
||||
environment?: 'dev' | 'stage' | 'production';
|
||||
// Background video playback settings
|
||||
background_video_autoplay?: boolean;
|
||||
background_video_loop?: boolean;
|
||||
background_video_muted?: boolean;
|
||||
background_video_start_time?: number | null;
|
||||
background_video_end_time?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user