Fixed play button appearing for background videos in iPhones.
This commit is contained in:
parent
0d6a8b17cf
commit
166dafc217
@ -176,7 +176,11 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
isFadingIn={isFadingIn}
|
||||
/>
|
||||
|
||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
|
||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5)
|
||||
Note: muted attribute is always true for iOS autoplay compatibility.
|
||||
Actual muted state is controlled via useBackgroundVideoPlayback hook
|
||||
which sets video.muted property via JavaScript (useEffect).
|
||||
webkit-playsinline is legacy attribute for older iOS versions. */}
|
||||
{backgroundVideoUrl && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
@ -187,6 +191,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
loop={useNativeLoop}
|
||||
muted={videoMuted}
|
||||
playsInline
|
||||
webkit-playsinline=''
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -19,6 +19,8 @@ import {
|
||||
mdiFullscreen,
|
||||
mdiFullscreenExit,
|
||||
mdiDelete,
|
||||
mdiVolumeHigh,
|
||||
mdiVolumeOff,
|
||||
} from '@mdi/js';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useOfflineMode } from '../../hooks/useOfflineMode';
|
||||
@ -38,6 +40,12 @@ interface RuntimeControlsProps {
|
||||
canvasWidth?: number;
|
||||
/** Canvas height in pixels (for positioning relative to canvas) */
|
||||
canvasHeight?: number;
|
||||
/** Whether to show the sound toggle button (video has sound enabled) */
|
||||
showSoundButton?: boolean;
|
||||
/** Current muted state of the video */
|
||||
isMuted?: boolean;
|
||||
/** Callback to toggle sound on/off */
|
||||
onSoundToggle?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -109,7 +117,9 @@ function ControlIcon({
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
flexShrink: 0,
|
||||
animation: spinning ? 'runtime-controls-spin 1s linear infinite' : undefined,
|
||||
animation: spinning
|
||||
? 'runtime-controls-spin 1s linear infinite'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<path fill={fill} d={path} />
|
||||
@ -420,6 +430,9 @@ export default function RuntimeControls({
|
||||
toggleFullscreen,
|
||||
canvasWidth = 0,
|
||||
canvasHeight = 0,
|
||||
showSoundButton = false,
|
||||
isMuted = true,
|
||||
onSoundToggle,
|
||||
}: RuntimeControlsProps) {
|
||||
// Counter-scale to resist pinch-zoom
|
||||
const counterScale = useCounterZoom();
|
||||
@ -429,12 +442,14 @@ export default function RuntimeControls({
|
||||
// Canvas is centered with: left: 50%, top: 50%, transform: translate(-50%, -50%)
|
||||
// So we offset from viewport edge by (viewport - canvas) / 2 + padding
|
||||
const padding = 16;
|
||||
const rightOffset = canvasWidth > 0 && viewport.width > 0
|
||||
? (viewport.width - canvasWidth) / 2 + padding
|
||||
: padding;
|
||||
const topOffset = canvasHeight > 0 && viewport.height > 0
|
||||
? (viewport.height - canvasHeight) / 2 + padding
|
||||
: padding;
|
||||
const rightOffset =
|
||||
canvasWidth > 0 && viewport.width > 0
|
||||
? (viewport.width - canvasWidth) / 2 + padding
|
||||
: padding;
|
||||
const topOffset =
|
||||
canvasHeight > 0 && viewport.height > 0
|
||||
? (viewport.height - canvasHeight) / 2 + padding
|
||||
: padding;
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
position: 'fixed',
|
||||
@ -463,6 +478,14 @@ export default function RuntimeControls({
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
/>
|
||||
{showSoundButton && onSoundToggle && (
|
||||
<ControlButton
|
||||
icon={isMuted ? mdiVolumeOff : mdiVolumeHigh}
|
||||
color='info'
|
||||
onClick={onSoundToggle}
|
||||
title={isMuted ? 'Unmute sound' : 'Mute sound'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import { useCanvasScale } from '../hooks/useCanvasScale';
|
||||
import { CANVAS_CONFIG } from '../config/canvas.config';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
|
||||
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
||||
import { useProjectAssets } from '../hooks/useProjectAssets';
|
||||
import { usePageNavigation } from '../hooks/usePageNavigation';
|
||||
@ -92,11 +93,17 @@ export default function RuntimePresentation({
|
||||
|
||||
// Canvas scale for responsive UI elements and letterbox mode
|
||||
// Uses page's design dimensions (saved at constructor save time) for presentation isolation
|
||||
const { cssVars, letterboxStyles, isPortrait, showRotatePrompt, canvasWidth, canvasHeight } =
|
||||
useCanvasScale({
|
||||
designWidth: currentPage?.design_width ?? undefined,
|
||||
designHeight: currentPage?.design_height ?? undefined,
|
||||
});
|
||||
const {
|
||||
cssVars,
|
||||
letterboxStyles,
|
||||
isPortrait,
|
||||
showRotatePrompt,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
} = useCanvasScale({
|
||||
designWidth: currentPage?.design_width ?? undefined,
|
||||
designHeight: currentPage?.design_height ?? undefined,
|
||||
});
|
||||
|
||||
const [transitionPreview, setTransitionPreview] = useState<{
|
||||
targetPageId: string;
|
||||
@ -534,7 +541,9 @@ export default function RuntimePresentation({
|
||||
// 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;
|
||||
// Note: pageVideoMuted is the page setting, but we use soundControl.isMuted for actual muted state
|
||||
// This allows iOS to autoplay (starts muted) while giving user control via sound button
|
||||
const pageVideoMuted = selectedPage?.background_video_muted ?? true;
|
||||
const videoStartTime =
|
||||
selectedPage?.background_video_start_time != null
|
||||
? parseFloat(String(selectedPage.background_video_start_time))
|
||||
@ -544,6 +553,14 @@ export default function RuntimePresentation({
|
||||
? parseFloat(String(selectedPage.background_video_end_time))
|
||||
: null;
|
||||
|
||||
// Sound control hook for iOS autoplay compatibility
|
||||
// Videos start muted (for iOS autoplay), user can unmute via sound button
|
||||
const soundControl = useVideoSoundControl({
|
||||
pageHasSound: pageVideoMuted === false, // Show button when page allows sound
|
||||
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
||||
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
|
||||
});
|
||||
|
||||
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
|
||||
|
||||
if (isLoading) {
|
||||
@ -589,7 +606,10 @@ export default function RuntimePresentation({
|
||||
{/* Dark theme for browser UI and background */}
|
||||
<meta name='theme-color' content='#000000' />
|
||||
<meta name='color-scheme' content='dark' />
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='black-translucent' />
|
||||
<meta
|
||||
name='apple-mobile-web-app-status-bar-style'
|
||||
content='black-translucent'
|
||||
/>
|
||||
<style>{`
|
||||
html, body { background-color: #000000 !important; }
|
||||
`}</style>
|
||||
@ -681,7 +701,7 @@ export default function RuntimePresentation({
|
||||
}}
|
||||
videoAutoplay={videoAutoplay}
|
||||
videoLoop={videoLoop}
|
||||
videoMuted={videoMuted}
|
||||
videoMuted={soundControl.isMuted}
|
||||
videoStartTime={videoStartTime}
|
||||
videoEndTime={videoEndTime}
|
||||
videoStoragePath={selectedPage?.background_video_url}
|
||||
@ -786,7 +806,7 @@ export default function RuntimePresentation({
|
||||
</div>
|
||||
{/* End inner canvas container */}
|
||||
|
||||
{/* Controls: Offline toggle and Fullscreen button */}
|
||||
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
|
||||
{/* Positioned outside canvas to avoid scaling with canvas transform */}
|
||||
<RuntimeControls
|
||||
projectId={project?.id || null}
|
||||
@ -797,6 +817,9 @@ export default function RuntimePresentation({
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
showSoundButton={soundControl.showSoundButton}
|
||||
isMuted={soundControl.isMuted}
|
||||
onSoundToggle={soundControl.toggleSound}
|
||||
/>
|
||||
|
||||
{/* Toast notifications for offline download status */}
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
@import "tailwind/_base.css";
|
||||
@import "tailwind/_components.css";
|
||||
@import "tailwind/_utilities.css";
|
||||
@import 'tailwind/_base.css';
|
||||
@import 'tailwind/_components.css';
|
||||
@import 'tailwind/_utilities.css';
|
||||
@import 'intro.js/introjs.css';
|
||||
@import "_checkbox-radio-switch.css";
|
||||
@import "_progress.css";
|
||||
@import "_scrollbars.css";
|
||||
@import "_table.css";
|
||||
@import "_helper.css";
|
||||
@import '_checkbox-radio-switch.css';
|
||||
@import '_progress.css';
|
||||
@import '_scrollbars.css';
|
||||
@import '_table.css';
|
||||
@import '_helper.css';
|
||||
@import '_calendar.css';
|
||||
@import '_select-dropdown.css';
|
||||
@import "_theme.css";
|
||||
@import '_theme.css';
|
||||
@import '_rich-text.css';
|
||||
|
||||
/* Page transition timing - single source of truth */
|
||||
@ -37,7 +37,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.introjs-tooltip {
|
||||
@apply min-w-[400px] max-w-[480px] p-2 !important;
|
||||
}
|
||||
@ -55,7 +54,7 @@
|
||||
.introjs-bullets ul li a.active {
|
||||
@apply bg-blue-600 !important;
|
||||
}
|
||||
.introjs-prevbutton{
|
||||
.introjs-prevbutton {
|
||||
@apply bg-transparent border border-blue-600 text-blue-600 !important;
|
||||
}
|
||||
|
||||
@ -120,8 +119,14 @@
|
||||
animation-name: page-crossfade-in;
|
||||
-webkit-animation-duration: var(--crossfade-duration, 700ms);
|
||||
animation-duration: var(--crossfade-duration, 700ms);
|
||||
-webkit-animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
-webkit-animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
);
|
||||
animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
);
|
||||
-webkit-animation-fill-mode: forwards;
|
||||
animation-fill-mode: forwards;
|
||||
-webkit-animation-play-state: running;
|
||||
@ -143,8 +148,14 @@
|
||||
animation-name: page-crossfade-out;
|
||||
-webkit-animation-duration: var(--crossfade-duration, 700ms);
|
||||
animation-duration: var(--crossfade-duration, 700ms);
|
||||
-webkit-animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
-webkit-animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
);
|
||||
animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
);
|
||||
-webkit-animation-fill-mode: forwards;
|
||||
animation-fill-mode: forwards;
|
||||
-webkit-animation-play-state: running;
|
||||
@ -183,8 +194,10 @@
|
||||
/* Use this for better Safari stability - transitions don't have the "snap" issue
|
||||
when state changes because they interpolate between current and target values */
|
||||
.crossfade-transition {
|
||||
transition: opacity var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
-webkit-transition: opacity var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
transition: opacity var(--crossfade-duration, 700ms)
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
-webkit-transition: opacity var(--crossfade-duration, 700ms)
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
-webkit-backface-visibility: hidden;
|
||||
@ -355,3 +368,26 @@
|
||||
font-family: 'Instrument Sans Variable', sans-serif;
|
||||
font-stretch: 100%;
|
||||
}
|
||||
|
||||
/* iOS Safari: Hide native video play button overlay
|
||||
These selectors target WebKit-specific pseudo-elements that appear
|
||||
when autoplay is blocked or when iOS detects an unmuted video */
|
||||
video::-webkit-media-controls-start-playback-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
video::-webkit-media-controls-overlay-play-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Prevent controls from appearing on autoplay fail */
|
||||
video::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
100
frontend/src/hooks/useVideoSoundControl.ts
Normal file
100
frontend/src/hooks/useVideoSoundControl.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* useVideoSoundControl Hook
|
||||
*
|
||||
* Manages video sound state with iOS autoplay compatibility.
|
||||
* Videos start muted to ensure autoplay works on iOS WebKit browsers,
|
||||
* then can be unmuted by user interaction.
|
||||
*
|
||||
* iOS WebKit autoplay policy:
|
||||
* - Videos CAN autoplay if they have `playsinline` AND `muted` attributes
|
||||
* - Unmuted videos require user interaction → native play button appears
|
||||
* - By forcing muted start + custom sound button, we avoid native controls
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface UseVideoSoundControlOptions {
|
||||
/** Whether page settings allow sound (background_video_muted === false) */
|
||||
pageHasSound: boolean;
|
||||
/** Whether page has a background video */
|
||||
hasBackgroundVideo: boolean;
|
||||
/** Current video URL - used to detect page changes (optional) */
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
export interface UseVideoSoundControlResult {
|
||||
/** Current muted state (always starts true for iOS compatibility) */
|
||||
isMuted: boolean;
|
||||
/** Whether to show sound toggle button */
|
||||
showSoundButton: boolean;
|
||||
/** Toggle muted state */
|
||||
toggleSound: () => void;
|
||||
/** Force muted state (for page changes) */
|
||||
setMuted: (muted: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing video sound state with iOS autoplay compatibility.
|
||||
*
|
||||
* @example
|
||||
* const { isMuted, showSoundButton, toggleSound } = useVideoSoundControl({
|
||||
* pageHasSound: selectedPage?.background_video_muted === false,
|
||||
* hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
||||
* });
|
||||
*
|
||||
* // Pass to RuntimeControls
|
||||
* <RuntimeControls
|
||||
* showSoundButton={showSoundButton}
|
||||
* isMuted={isMuted}
|
||||
* onSoundToggle={toggleSound}
|
||||
* />
|
||||
*
|
||||
* // Pass to CanvasBackground (always muted for autoplay)
|
||||
* <CanvasBackground videoMuted={isMuted} />
|
||||
*/
|
||||
export function useVideoSoundControl({
|
||||
pageHasSound,
|
||||
hasBackgroundVideo,
|
||||
videoUrl,
|
||||
}: UseVideoSoundControlOptions): UseVideoSoundControlResult {
|
||||
// Always start muted for iOS autoplay compatibility
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
|
||||
// Track previous video URL to detect page changes
|
||||
const prevVideoUrl = useRef(videoUrl);
|
||||
|
||||
// Reset to muted when video changes (page navigation)
|
||||
// This ensures iOS autoplay works on every page - new videos always start muted
|
||||
useEffect(() => {
|
||||
// Only reset if there's a new video (URL changed and we have a video)
|
||||
if (prevVideoUrl.current !== videoUrl && videoUrl) {
|
||||
setIsMuted(true);
|
||||
logger.debug('[useVideoSoundControl] Video changed, resetting to muted', {
|
||||
from: prevVideoUrl.current?.slice(-20),
|
||||
to: videoUrl?.slice(-20),
|
||||
});
|
||||
}
|
||||
prevVideoUrl.current = videoUrl;
|
||||
}, [videoUrl]);
|
||||
|
||||
const toggleSound = useCallback(() => {
|
||||
setIsMuted((prev) => {
|
||||
logger.debug('[useVideoSoundControl] Toggle sound:', {
|
||||
from: prev,
|
||||
to: !prev,
|
||||
});
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isMuted,
|
||||
// Show button only if page allows sound AND has a background video
|
||||
showSoundButton: pageHasSound && hasBackgroundVideo,
|
||||
toggleSound,
|
||||
setMuted: setIsMuted,
|
||||
};
|
||||
}
|
||||
|
||||
export default useVideoSoundControl;
|
||||
@ -88,6 +88,7 @@ import {
|
||||
type NavigationElementType,
|
||||
} from '../context/ConstructorContext';
|
||||
import { useCanvasScale } from '../hooks/useCanvasScale';
|
||||
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
|
||||
import { CANVAS_CONFIG } from '../config/canvas.config';
|
||||
|
||||
// Constructor helpers (extracted utilities)
|
||||
@ -195,6 +196,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
backgroundVideoEndTime,
|
||||
} = usePageBackground();
|
||||
|
||||
// Sound control hook for iOS autoplay compatibility
|
||||
// Videos start muted (for iOS autoplay), can be controlled via page settings
|
||||
const soundControl = useVideoSoundControl({
|
||||
pageHasSound: backgroundVideoMuted === false, // Show button when page allows sound
|
||||
hasBackgroundVideo: Boolean(backgroundVideoUrl),
|
||||
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
|
||||
});
|
||||
|
||||
const [selectedMenuItem, setSelectedMenuItem] =
|
||||
useState<EditorMenuItem>('none');
|
||||
// Transition preview state managed by useTransitionPreview hook (below)
|
||||
@ -1580,7 +1589,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
}}
|
||||
videoAutoplay={backgroundVideoAutoplay}
|
||||
videoLoop={backgroundVideoLoop}
|
||||
videoMuted={backgroundVideoMuted}
|
||||
videoMuted={soundControl.isMuted}
|
||||
videoStartTime={backgroundVideoStartTime}
|
||||
videoEndTime={backgroundVideoEndTime}
|
||||
videoStoragePath={
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user