extended background video functionality

This commit is contained in:
Dmitri 2026-04-04 09:55:13 +04:00
parent b5f8f30360
commit eb712e86f2
16 changed files with 640 additions and 27 deletions

View File

@ -70,6 +70,26 @@ class Tour_pagesDBApi extends GenericDBApi {
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_loop: data.background_loop || false, 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, requires_auth: data.requires_auth || false,
ui_schema_json: data.ui_schema_json || null, ui_schema_json: data.ui_schema_json || null,
}; };

View File

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

View File

@ -73,6 +73,36 @@ module.exports = function (sequelize, DataTypes) {
defaultValue: false, 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: { requires_auth: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,

File diff suppressed because one or more lines are too long

View File

@ -3,12 +3,21 @@
* *
* Compact editor for background image, video, or audio settings. * Compact editor for background image, video, or audio settings.
* Used in the element editor panel for background menu items. * 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 React from 'react';
import type { AssetOption } from './types'; import type { AssetOption } from './types';
import { addFallbackAssetOption } from '../../lib/constructorHelpers'; import { addFallbackAssetOption } from '../../lib/constructorHelpers';
export interface VideoPlaybackSettings {
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
startTime?: number | null;
endTime?: number | null;
}
interface BackgroundSettingsEditorProps { interface BackgroundSettingsEditorProps {
type: 'image' | 'video' | 'audio'; type: 'image' | 'video' | 'audio';
value: string; value: string;
@ -16,6 +25,13 @@ interface BackgroundSettingsEditorProps {
durationNote?: string; durationNote?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onClearImage?: () => 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> = { const LABELS: Record<string, string> = {
@ -31,6 +47,12 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
durationNote, durationNote,
onChange, onChange,
onClearImage, onClearImage,
videoAutoplay,
videoLoop,
videoMuted,
videoStartTime,
videoEndTime,
onVideoSettingsChange,
}) => { }) => {
const label = LABELS[type] || 'Background'; const label = LABELS[type] || 'Background';
const selectOptions = addFallbackAssetOption( const selectOptions = addFallbackAssetOption(
@ -39,6 +61,9 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
`Current ${type} · ${value}`, `Current ${type} · ${value}`,
); );
// Show video playback settings when type is video and a video is selected
const showVideoSettings = type === 'video' && value && onVideoSettingsChange;
return ( return (
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-gray-600'>
@ -66,6 +91,92 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
{durationNote && ( {durationNote && (
<p className='mt-1 text-[11px] text-gray-500'>{durationNote}</p> <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> </div>
); );
}; };

View File

@ -3,10 +3,12 @@
* *
* Background image, video, and audio for the constructor canvas. * Background image, video, and audio for the constructor canvas.
* Handles blob URLs, Next.js Image optimization, and previous background overlay. * 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 React from 'react';
import NextImage from 'next/image'; import NextImage from 'next/image';
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
interface CanvasBackgroundProps { interface CanvasBackgroundProps {
backgroundImageUrl?: string; backgroundImageUrl?: string;
@ -16,6 +18,12 @@ interface CanvasBackgroundProps {
isSwitching?: boolean; isSwitching?: boolean;
isNewBgReady?: boolean; isNewBgReady?: boolean;
onBackgroundReady?: () => void; onBackgroundReady?: () => void;
// Video playback settings
videoAutoplay?: boolean;
videoLoop?: boolean;
videoMuted?: boolean;
videoStartTime?: number | null;
videoEndTime?: number | null;
} }
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
@ -26,7 +34,22 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
isSwitching = false, isSwitching = false,
isNewBgReady = false, isNewBgReady = false,
onBackgroundReady, 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 = () => { const handleLoad = () => {
onBackgroundReady?.(); onBackgroundReady?.();
}; };
@ -35,18 +58,21 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
onBackgroundReady?.(); onBackgroundReady?.();
}; };
// When endTime is set, we disable native loop and handle it via the hook
const useNativeLoop = videoEndTime == null ? videoLoop : false;
return ( return (
<> <>
{/* Background image - z-1 keeps it below backdrop blur layer (z-5) */} {/* Background image - z-1 keeps it below backdrop blur layer (z-5) */}
{backgroundImageUrl && ( {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:') ? ( {backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
key={`bg_image_${backgroundImageUrl}`} key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl} src={backgroundImageUrl}
alt='Background' alt='Background'
className='absolute inset-0 w-full h-full object-cover' className='absolute inset-0 h-full w-full object-cover'
draggable={false} draggable={false}
onLoad={handleLoad} onLoad={handleLoad}
onError={handleError} onError={handleError}
@ -71,7 +97,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
{/* Previous background overlay - shows during page switch until new bg is ready */} {/* Previous background overlay - shows during page switch until new bg is ready */}
{previousBgImageUrl && isSwitching && !isNewBgReady && ( {previousBgImageUrl && isSwitching && !isNewBgReady && (
<div <div
className='absolute inset-0 pointer-events-none z-10' className='pointer-events-none absolute inset-0 z-10'
style={{ style={{
backgroundImage: `url("${previousBgImageUrl}")`, backgroundImage: `url("${previousBgImageUrl}")`,
backgroundSize: 'cover', backgroundSize: 'cover',
@ -83,12 +109,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */} {/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
{backgroundVideoUrl && ( {backgroundVideoUrl && (
<video <video
ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`} 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} src={backgroundVideoUrl}
autoPlay autoPlay={videoAutoplay}
loop loop={useNativeLoop}
muted muted={videoMuted}
playsInline playsInline
/> />
)} )}

View File

@ -94,6 +94,20 @@ interface ElementEditorPanelProps {
onBackgroundVideoChange: (value: string) => void; onBackgroundVideoChange: (value: string) => void;
onBackgroundAudioChange: (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 // Transition creation
newTransitionName: string; newTransitionName: string;
newTransitionVideoUrl: string; newTransitionVideoUrl: string;
@ -220,6 +234,12 @@ export function ElementEditorPanel({
onBackgroundImageChange, onBackgroundImageChange,
onBackgroundVideoChange, onBackgroundVideoChange,
onBackgroundAudioChange, onBackgroundAudioChange,
backgroundVideoAutoplay,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundVideoStartTime,
backgroundVideoEndTime,
onBackgroundVideoSettingsChange,
newTransitionName, newTransitionName,
newTransitionVideoUrl, newTransitionVideoUrl,
newTransitionSupportsReverse, newTransitionSupportsReverse,
@ -288,6 +308,12 @@ export function ElementEditorPanel({
onBackgroundVideoChange(value); onBackgroundVideoChange(value);
if (value) onBackgroundImageChange(''); if (value) onBackgroundImageChange('');
}} }}
videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop}
videoMuted={backgroundVideoMuted}
videoStartTime={backgroundVideoStartTime}
videoEndTime={backgroundVideoEndTime}
onVideoSettingsChange={onBackgroundVideoSettingsChange}
/> />
)} )}

View File

@ -50,6 +50,12 @@ export interface TourPage {
background_video_url?: string; background_video_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_loop?: boolean; 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; isSwitching?: boolean;
isNewBgReady?: boolean; isNewBgReady?: boolean;
onBackgroundReady?: () => void; 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; 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 * Background settings editor props
*/ */
@ -171,6 +194,13 @@ export interface BackgroundSettingsEditorProps {
durationNote?: string; durationNote?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onImageClear?: () => 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; backgroundVideoDurationNote: string;
backgroundAudioDurationNote: 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 // Transition form
newTransitionName: string; newTransitionName: string;
newTransitionVideoUrl: string; newTransitionVideoUrl: string;

View File

@ -333,23 +333,19 @@ export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
galleryTitleFontFamily: String(settings.galleryTitleFontFamily || ''), galleryTitleFontFamily: String(settings.galleryTitleFontFamily || ''),
galleryTextFontFamily: String(settings.galleryTextFontFamily || ''), galleryTextFontFamily: String(settings.galleryTextFontFamily || ''),
galleryCards: Array.isArray(settings.galleryCards) galleryCards: Array.isArray(settings.galleryCards)
? settings.galleryCards.map( ? settings.galleryCards.map((card: Record<string, unknown>) => ({
(card: Record<string, unknown>) => ({ id: String(card?.id || createLocalId()),
id: String(card?.id || createLocalId()), imageUrl: String(card?.imageUrl ?? ''),
imageUrl: String(card?.imageUrl ?? ''), title: String(card?.title ?? ''),
title: String(card?.title ?? ''), description: String(card?.description ?? ''),
description: String(card?.description ?? ''), }))
}),
)
: [], : [],
carouselSlides: Array.isArray(settings.carouselSlides) carouselSlides: Array.isArray(settings.carouselSlides)
? settings.carouselSlides.map( ? settings.carouselSlides.map((slide: Record<string, unknown>) => ({
(slide: Record<string, unknown>) => ({ id: String(slide?.id || createLocalId()),
id: String(slide?.id || createLocalId()), imageUrl: String(slide?.imageUrl ?? ''),
imageUrl: String(slide?.imageUrl ?? ''), caption: String(slide?.caption ?? ''),
caption: String(slide?.caption ?? ''), }))
}),
)
: [], : [],
}); });
}, },

View File

@ -30,6 +30,7 @@ import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch'; import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
import { useBackgroundVideoPlayback } from '../hooks/useBackgroundVideoPlayback';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { import {
@ -370,6 +371,32 @@ export default function RuntimePresentation({
const backgroundImageUrl = pageSwitch.currentBgImageUrl; const backgroundImageUrl = pageSwitch.currentBgImageUrl;
const backgroundVideoUrl = pageSwitch.currentBgVideoUrl; 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) { if (isLoading) {
return ( return (
<div className='flex items-center justify-center min-h-screen bg-gray-900'> <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) */} {/* Background video - z-1 keeps it below backdrop blur (z-5) */}
{backgroundVideoUrl && ( {backgroundVideoUrl && (
<video <video
ref={bgVideoRef}
key={backgroundVideoUrl} 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} src={backgroundVideoUrl}
autoPlay autoPlay={videoAutoplay}
loop loop={useNativeLoop}
muted muted={videoMuted}
playsInline playsInline
/> />
)} )}

View File

@ -26,6 +26,11 @@ export type {
UseBackgroundTransitionOptions, UseBackgroundTransitionOptions,
UseBackgroundTransitionResult, UseBackgroundTransitionResult,
} from './useBackgroundTransition'; } from './useBackgroundTransition';
export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback';
export type {
UseBackgroundVideoPlaybackOptions,
UseBackgroundVideoPlaybackResult,
} from './useBackgroundVideoPlayback';
export { usePageDataLoader } from './usePageDataLoader'; export { usePageDataLoader } from './usePageDataLoader';
export type { export type {
UsePageDataLoaderOptions, UsePageDataLoaderOptions,

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

View File

@ -25,6 +25,12 @@ interface TourPage {
background_video_url?: string; background_video_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_loop?: boolean; 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 { interface UseConstructorPageActionsOptions {
@ -42,6 +48,12 @@ interface UseConstructorPageActionsOptions {
backgroundImageUrl: string; backgroundImageUrl: string;
backgroundVideoUrl: string; backgroundVideoUrl: string;
backgroundAudioUrl: 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 */ /** Callback to reload data after operations */
onReload: (preservePageId?: string) => Promise<void>; onReload: (preservePageId?: string) => Promise<void>;
/** Callback to set active page ID */ /** Callback to set active page ID */
@ -111,6 +123,11 @@ export function useConstructorPageActions({
backgroundImageUrl, backgroundImageUrl,
backgroundVideoUrl, backgroundVideoUrl,
backgroundAudioUrl, backgroundAudioUrl,
backgroundVideoAutoplay,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundVideoStartTime,
backgroundVideoEndTime,
onReload, onReload,
onSetActivePageId, onSetActivePageId,
onSetMenuOpen, onSetMenuOpen,
@ -154,6 +171,11 @@ export function useConstructorPageActions({
background_video_url: backgroundVideoUrl, background_video_url: backgroundVideoUrl,
background_audio_url: backgroundAudioUrl, background_audio_url: backgroundAudioUrl,
background_loop: Boolean(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, backgroundAudioUrl,
backgroundImageUrl, backgroundImageUrl,
backgroundVideoUrl, backgroundVideoUrl,
backgroundVideoAutoplay,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundVideoStartTime,
backgroundVideoEndTime,
elements, elements,
onError, onError,
onReload, onReload,

View File

@ -29,6 +29,12 @@ export interface SwitchablePage {
background_image_url?: string; background_image_url?: string;
background_video_url?: string; background_video_url?: string;
background_audio_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;
} }
/** /**

View File

@ -98,6 +98,12 @@ type TourPage = {
background_video_url?: string; background_video_url?: string;
background_audio_url?: string; background_audio_url?: string;
background_loop?: boolean; 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< type NavigationElementType = Extract<
@ -165,6 +171,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const [backgroundImageUrl, setBackgroundImageUrl] = useState(''); const [backgroundImageUrl, setBackgroundImageUrl] = useState('');
const [backgroundVideoUrl, setBackgroundVideoUrl] = useState(''); const [backgroundVideoUrl, setBackgroundVideoUrl] = useState('');
const [backgroundAudioUrl, setBackgroundAudioUrl] = 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] = const [selectedMenuItem, setSelectedMenuItem] =
useState<EditorMenuItem>('none'); useState<EditorMenuItem>('none');
// Transition preview state managed by useTransitionPreview hook (below) // Transition preview state managed by useTransitionPreview hook (below)
@ -340,6 +356,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setBackgroundImageUrl(page?.background_image_url || ''); setBackgroundImageUrl(page?.background_image_url || '');
setBackgroundVideoUrl(page?.background_video_url || ''); setBackgroundVideoUrl(page?.background_video_url || '');
setBackgroundAudioUrl(page?.background_audio_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 // Use hook to resolve and set blob URLs for display
await pageSwitch.switchToPage( await pageSwitch.switchToPage(
@ -735,6 +766,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundImageUrl, backgroundImageUrl,
backgroundVideoUrl, backgroundVideoUrl,
backgroundAudioUrl, backgroundAudioUrl,
backgroundVideoAutoplay,
backgroundVideoLoop,
backgroundVideoMuted,
backgroundVideoStartTime,
backgroundVideoEndTime,
onReload: loadData, onReload: loadData,
onSetActivePageId: setActivePageId, onSetActivePageId: setActivePageId,
onSetMenuOpen: setIsMenuOpen, onSetMenuOpen: setIsMenuOpen,
@ -1023,6 +1059,21 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setBackgroundImageUrl(activePage.background_image_url || ''); setBackgroundImageUrl(activePage.background_image_url || '');
setBackgroundVideoUrl(activePage.background_video_url || ''); setBackgroundVideoUrl(activePage.background_video_url || '');
setBackgroundAudioUrl(activePage.background_audio_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) // 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 // Only call if this page wasn't already initialized via switchToPage function
if (lastInitializedPageIdRef.current !== activePage.id) { if (lastInitializedPageIdRef.current !== activePage.id) {
@ -1372,6 +1423,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isSwitching={pageSwitch.isSwitching} isSwitching={pageSwitch.isSwitching}
isNewBgReady={pageSwitch.isNewBgReady} isNewBgReady={pageSwitch.isNewBgReady}
onBackgroundReady={() => pageSwitch.markBackgroundReady()} onBackgroundReady={() => pageSwitch.markBackgroundReady()}
videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop}
videoMuted={backgroundVideoMuted}
videoStartTime={backgroundVideoStartTime}
videoEndTime={backgroundVideoEndTime}
/> />
{/* Elements container - z-10 ensures they appear above backdrop layer */} {/* Elements container - z-10 ensures they appear above backdrop layer */}
@ -1457,6 +1513,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onBackgroundImageChange={setBackgroundImageUrl} onBackgroundImageChange={setBackgroundImageUrl}
onBackgroundVideoChange={setBackgroundVideoUrl} onBackgroundVideoChange={setBackgroundVideoUrl}
onBackgroundAudioChange={setBackgroundAudioUrl} 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} newTransitionName={newTransitionName}
newTransitionVideoUrl={newTransitionVideoUrl} newTransitionVideoUrl={newTransitionVideoUrl}
newTransitionSupportsReverse={newTransitionSupportsReverse} newTransitionSupportsReverse={newTransitionSupportsReverse}

View File

@ -29,6 +29,12 @@ export interface RuntimePage extends PreloadPage {
sort_order?: number; sort_order?: number;
ui_schema_json?: string; ui_schema_json?: string;
environment?: 'dev' | 'stage' | 'production'; 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;
} }
/** /**