Fixed play button appearing for background videos in iPhones.

This commit is contained in:
Dmitri 2026-04-30 12:14:34 +02:00
parent 0d6a8b17cf
commit 166dafc217
6 changed files with 231 additions and 35 deletions

View File

@ -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=''
/>
)}

View File

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

View File

@ -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 */}

View File

@ -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;
}

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

View File

@ -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={