fixed smooth transitions issue for safari browser
This commit is contained in:
parent
fe82dbb318
commit
7e54cc8858
@ -6,10 +6,16 @@
|
||||
* Supports custom video playback settings (autoplay, loop, muted, start/end time).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import NextImage from 'next/image';
|
||||
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
||||
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
||||
import { scheduleAfterPaint } from '../../lib/browserUtils';
|
||||
|
||||
// Type for requestVideoFrameCallback (Safari 15.4+)
|
||||
interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
|
||||
requestVideoFrameCallback: (callback: () => void) => number;
|
||||
}
|
||||
|
||||
interface CanvasBackgroundProps {
|
||||
backgroundImageUrl?: string;
|
||||
@ -59,13 +65,58 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay;
|
||||
|
||||
const handleLoad = () => {
|
||||
onBackgroundReady?.();
|
||||
// Wait for paint to ensure background is actually rendered before reporting ready.
|
||||
// This prevents the transition overlay from being removed before the background is visible.
|
||||
scheduleAfterPaint(() => {
|
||||
onBackgroundReady?.();
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
onBackgroundReady?.();
|
||||
};
|
||||
|
||||
// Track if we've already called onBackgroundReady to avoid double calls
|
||||
const didReportReadyRef = useRef(false);
|
||||
|
||||
// Reset ready flag when video URL changes
|
||||
useEffect(() => {
|
||||
didReportReadyRef.current = false;
|
||||
}, [backgroundVideoUrl]);
|
||||
|
||||
// Handle video first frame ready using requestVideoFrameCallback
|
||||
// This ensures the video's first frame is actually painted before we report ready
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!backgroundVideoUrl || !video || didReportReadyRef.current) return;
|
||||
|
||||
const reportVideoReady = () => {
|
||||
if (didReportReadyRef.current) return;
|
||||
didReportReadyRef.current = true;
|
||||
onBackgroundReady?.();
|
||||
};
|
||||
|
||||
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
|
||||
if ('requestVideoFrameCallback' in video) {
|
||||
const rvfc = (video as HTMLVideoElementWithRVFC).requestVideoFrameCallback.bind(video);
|
||||
rvfc(() => {
|
||||
reportVideoReady();
|
||||
});
|
||||
} else {
|
||||
// Fallback: use playing event + scheduleAfterPaint
|
||||
const onPlaying = () => {
|
||||
scheduleAfterPaint(() => {
|
||||
reportVideoReady();
|
||||
});
|
||||
};
|
||||
|
||||
video.addEventListener('playing', onPlaying, { once: true });
|
||||
return () => {
|
||||
video.removeEventListener('playing', onPlaying);
|
||||
};
|
||||
}
|
||||
}, [backgroundVideoUrl, onBackgroundReady]);
|
||||
|
||||
// When endTime is set, we disable native loop and handle it via the hook
|
||||
const useNativeLoop = videoEndTime == null ? videoLoop : false;
|
||||
|
||||
|
||||
@ -41,10 +41,11 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
|
||||
const containerOpacity = isBuffering ? 0 : (opacity ?? 1);
|
||||
|
||||
return (
|
||||
// Outer: full viewport with black background (letterbox bars)
|
||||
// Hidden while buffering to show old page content underneath
|
||||
// Outer: full viewport, transparent background
|
||||
// Transparent ensures if Safari clears video frame when paused,
|
||||
// the new page background shows through instead of black flash
|
||||
<div
|
||||
className='fixed inset-0 z-50 overflow-hidden pointer-events-none bg-black'
|
||||
className='fixed inset-0 z-50 overflow-hidden pointer-events-none'
|
||||
style={{ opacity: containerOpacity }}
|
||||
>
|
||||
{/* Inner: respects letterbox dimensions when provided */}
|
||||
|
||||
@ -42,7 +42,7 @@ import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
|
||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { isSafari } from '../lib/browserUtils';
|
||||
import { isSafari, scheduleAfterPaint } from '../lib/browserUtils';
|
||||
import { logger } from '../lib/logger';
|
||||
import {
|
||||
resolveNavigationTarget,
|
||||
@ -321,36 +321,50 @@ export default function RuntimePresentation({
|
||||
pageSwitch.switchToPage,
|
||||
]);
|
||||
|
||||
// Handle background ready state for pages without images or with videos
|
||||
// Handle background ready state for pages without any background
|
||||
useEffect(() => {
|
||||
// If no background image, or if there's a video (video takes over), mark as ready
|
||||
if (
|
||||
!selectedPage?.background_image_url ||
|
||||
selectedPage?.background_video_url
|
||||
) {
|
||||
// Only mark ready immediately if there's no background media at all.
|
||||
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
||||
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
|
||||
if (!selectedPage?.background_image_url && !selectedPage?.background_video_url) {
|
||||
setIsBackgroundReady(true);
|
||||
}
|
||||
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
|
||||
|
||||
// Video transition overlay removal - instant (no fade) when background is ready
|
||||
// Uses double RAF to ensure browser has painted the new background before removing overlay
|
||||
// Uses scheduleAfterPaint to ensure browser has painted the new background before removing overlay
|
||||
// Note: Double RAF doesn't work in Safari (nested RAFs can execute in same frame)
|
||||
// CRITICAL: Must wait for BOTH isBackgroundReady AND pageSwitch.isNewBgReady
|
||||
// - isBackgroundReady: RuntimePresentation state (image onLoad or immediate for video pages)
|
||||
// - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility
|
||||
// If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing
|
||||
useEffect(() => {
|
||||
if (pendingTransitionComplete && isBackgroundReady) {
|
||||
if (pendingTransitionComplete && isBackgroundReady && pageSwitch.isNewBgReady) {
|
||||
// Wait for paint cycle to complete before removing overlay
|
||||
// Double RAF ensures the new background is fully rendered
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// scheduleAfterPaint handles Safari's RAF quirks automatically
|
||||
scheduleAfterPaint(() => {
|
||||
// CRITICAL: Remove overlay from DOM FIRST, then clear video src
|
||||
// If we clear src before removing overlay, Safari shows black frame
|
||||
// because video.removeAttribute('src') immediately clears the frame
|
||||
setTransitionPreview(null);
|
||||
setPendingTransitionComplete(false);
|
||||
|
||||
// Clear previous background now that transition is complete
|
||||
// This resets isSwitching state for next navigation
|
||||
pageSwitch.clearPreviousBackground();
|
||||
|
||||
// Clear video src AFTER overlay is removed from DOM
|
||||
// Use another scheduleAfterPaint to ensure React has unmounted the overlay
|
||||
scheduleAfterPaint(() => {
|
||||
const video = transitionVideoRef.current;
|
||||
if (video) {
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
}
|
||||
setTransitionPreview(null);
|
||||
setPendingTransitionComplete(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [pendingTransitionComplete, isBackgroundReady]);
|
||||
}, [pendingTransitionComplete, isBackgroundReady, pageSwitch.isNewBgReady, pageSwitch.clearPreviousBackground]);
|
||||
|
||||
// Safari Black Flash Prevention (video transitions only):
|
||||
// Update lastKnownBgUrl whenever we have a valid background image.
|
||||
@ -692,7 +706,15 @@ export default function RuntimePresentation({
|
||||
<TransitionPreviewOverlay
|
||||
videoRef={transitionVideoRef}
|
||||
isActive={true}
|
||||
isBuffering={transitionPhase === 'preparing' || isBuffering}
|
||||
isBuffering={
|
||||
// Hide overlay until video first frame is painted:
|
||||
// - 'idle': React render cycle before hook effect runs
|
||||
// - 'preparing': Video loading/buffering
|
||||
// - isBuffering: Waiting for first frame paint (from hook)
|
||||
transitionPhase === 'idle' ||
|
||||
transitionPhase === 'preparing' ||
|
||||
isBuffering
|
||||
}
|
||||
letterboxStyles={letterboxStyles}
|
||||
opacity={1}
|
||||
/>
|
||||
|
||||
@ -264,8 +264,18 @@ export function useBackgroundTransition({
|
||||
*
|
||||
* The previous background stays visible during the entire fade animation,
|
||||
* providing a smooth crossfade effect. Only cleared after fade ends.
|
||||
*
|
||||
* IMPORTANT: Skip this for video transitions - the transition overlay handles
|
||||
* the visual transition, and we'll clear the previous background manually
|
||||
* after the overlay is removed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Read from ref to get current hasActiveTransition value
|
||||
const hasActiveTransition = fadeInRef.current?.hasActiveTransition ?? false;
|
||||
|
||||
// Skip clearing during video transitions - let RuntimePresentation handle it
|
||||
if (hasActiveTransition) return;
|
||||
|
||||
if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
|
||||
// Fade is complete - clear the previous background overlay
|
||||
// This also resets isSwitching state so next navigation triggers fade-in
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
extractStoragePath,
|
||||
} from '../lib/assetUrl';
|
||||
import { downloadManager } from '../lib/offline/DownloadManager';
|
||||
import { isSafari, isFirefox } from '../lib/browserUtils';
|
||||
import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils';
|
||||
|
||||
export type ReverseMode = 'none' | 'separate';
|
||||
|
||||
@ -87,24 +87,18 @@ const DEFAULT_TIMEOUTS = {
|
||||
|
||||
/**
|
||||
* Get browser-specific finish offset for transition videos.
|
||||
* Safari needs more buffer time to ensure the last frame stays visible
|
||||
* until the new page background is ready to display.
|
||||
* This is a backup timer - requestVideoFrameCallback is primary for modern browsers.
|
||||
*
|
||||
* @returns Finish offset in milliseconds before video end
|
||||
*/
|
||||
const getFinishBeforeEndMs = (): number => {
|
||||
if (isSafari()) {
|
||||
// Safari: larger buffer to prevent black flash
|
||||
// Safari's compositor timing can cause the video to clear
|
||||
// before the new page background is fully composited
|
||||
return 100;
|
||||
return 350;
|
||||
}
|
||||
if (isFirefox()) {
|
||||
// Firefox: slightly larger buffer for safety
|
||||
return 60;
|
||||
return 300;
|
||||
}
|
||||
// Chrome and other browsers: standard timing
|
||||
return 50;
|
||||
return 300;
|
||||
};
|
||||
|
||||
function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean {
|
||||
@ -169,6 +163,7 @@ export function useTransitionPlayback(
|
||||
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
|
||||
|
||||
const [phase, setPhase] = useState<PlaybackPhase>('idle');
|
||||
const [isVideoReady, setIsVideoReady] = useState(false);
|
||||
|
||||
const didFinishRef = useRef(false);
|
||||
const didStartPlaybackRef = useRef(false);
|
||||
@ -241,15 +236,10 @@ export function useTransitionPlayback(
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
// Just pause - don't seek. We've already stopped at a safe frame
|
||||
// (triggered by rvfc/timeupdate well before any black frames).
|
||||
// Seeking is async and can cause a flash before the seek completes.
|
||||
video.pause();
|
||||
// Seek back slightly to ensure last frame is visible
|
||||
if (
|
||||
video.duration &&
|
||||
Number.isFinite(video.duration) &&
|
||||
video.currentTime >= video.duration - 0.1
|
||||
) {
|
||||
video.currentTime = Math.max(0, video.duration - 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTransition = transitionRef.current;
|
||||
@ -558,6 +548,7 @@ export function useTransitionPlayback(
|
||||
sourceUrl,
|
||||
});
|
||||
|
||||
setIsVideoReady(false); // Reset for new playback
|
||||
didStartPlaybackRef.current = false;
|
||||
|
||||
if (startWatchdogTimerRef.current) {
|
||||
@ -616,6 +607,45 @@ export function useTransitionPlayback(
|
||||
startWatchdogTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
|
||||
// This fires when a frame is actually sent to the compositor - no guesswork
|
||||
if ('requestVideoFrameCallback' in video) {
|
||||
const rvfc = video.requestVideoFrameCallback.bind(video);
|
||||
|
||||
// First callback: frame is composited, safe to show overlay
|
||||
rvfc((_now, _metadata) => {
|
||||
if (!didFinishRef.current) {
|
||||
setIsVideoReady(true);
|
||||
}
|
||||
|
||||
// Monitor video position to finish before end (prevents END flash)
|
||||
// Note: rvfc fires AFTER frame is composited, so we need extra buffer
|
||||
const monitorEnd = (_now2: number, metadata: VideoFrameCallbackMetadata) => {
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
const duration = video.duration;
|
||||
// Finish 300ms before end - gives margin for black/fade frames
|
||||
// that some videos have in the last 100-200ms
|
||||
if (Number.isFinite(duration) && metadata.mediaTime >= duration - 0.3) {
|
||||
finishPlayback('rvfc-end');
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue monitoring each frame
|
||||
rvfc(monitorEnd);
|
||||
};
|
||||
|
||||
rvfc(monitorEnd);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers without requestVideoFrameCallback
|
||||
scheduleAfterPaint(() => {
|
||||
if (!didFinishRef.current) {
|
||||
setIsVideoReady(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const mediaDurationSec = Number(video.duration);
|
||||
const durationSec =
|
||||
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
|
||||
@ -632,6 +662,23 @@ export function useTransitionPlayback(
|
||||
finishPlayback('ended');
|
||||
};
|
||||
|
||||
// Backup handler for browsers without requestVideoFrameCallback
|
||||
// Note: timeupdate fires infrequently (~250ms in Safari), so this is just a fallback
|
||||
const onTimeUpdate = () => {
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
const duration = video.duration;
|
||||
if (!Number.isFinite(duration)) return;
|
||||
|
||||
// Large buffer since timeupdate is infrequent
|
||||
// Safari: 600ms, Others: 400ms
|
||||
const safetyBuffer = isSafari() ? 0.6 : 0.4;
|
||||
|
||||
if (video.currentTime >= duration - safetyBuffer) {
|
||||
finishPlayback('timeupdate-end');
|
||||
}
|
||||
};
|
||||
|
||||
const onVideoError = async () => {
|
||||
if (didFinishRef.current) return;
|
||||
logIssue('video-error');
|
||||
@ -663,6 +710,7 @@ export function useTransitionPlayback(
|
||||
video.addEventListener('canplay', onCanPlay);
|
||||
video.addEventListener('playing', onPlaying);
|
||||
video.addEventListener('ended', onEnded);
|
||||
video.addEventListener('timeupdate', onTimeUpdate);
|
||||
video.addEventListener('error', onVideoError);
|
||||
video.addEventListener('abort', onAbort);
|
||||
video.addEventListener('stalled', onStalled);
|
||||
@ -680,6 +728,7 @@ export function useTransitionPlayback(
|
||||
video.removeEventListener('canplay', onCanPlay);
|
||||
video.removeEventListener('playing', onPlaying);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||
video.removeEventListener('error', onVideoError);
|
||||
video.removeEventListener('abort', onAbort);
|
||||
video.removeEventListener('stalled', onStalled);
|
||||
@ -701,6 +750,7 @@ export function useTransitionPlayback(
|
||||
useEffect(() => {
|
||||
if (!transition) {
|
||||
setPhase('idle');
|
||||
setIsVideoReady(false);
|
||||
activeSourceUrlRef.current = null;
|
||||
didFinishRef.current = false;
|
||||
didStartPlaybackRef.current = false;
|
||||
@ -709,7 +759,8 @@ export function useTransitionPlayback(
|
||||
|
||||
return {
|
||||
phase,
|
||||
isBuffering: false, // No longer have buffering from frame-stepping
|
||||
// Show buffering until video first frame is painted (prevents START black flash)
|
||||
isBuffering: phase === 'preparing' || (phase === 'playing' && !isVideoReady),
|
||||
isReversing: false, // No longer support frame-stepping reverse
|
||||
cancel,
|
||||
forceComplete,
|
||||
|
||||
@ -393,10 +393,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
}
|
||||
}, [pendingTransitionComplete, isBackgroundReady, closeTransitionPreview]);
|
||||
|
||||
// Handle background ready state for pages without images or with videos
|
||||
// Handle background ready state for pages without any background
|
||||
useEffect(() => {
|
||||
// If no background image, or if there's a video (video takes over), mark as ready
|
||||
if (!activePage?.background_image_url || activePage?.background_video_url) {
|
||||
// Only mark ready immediately if there's no background media at all.
|
||||
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
||||
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
|
||||
if (!activePage?.background_image_url && !activePage?.background_video_url) {
|
||||
setIsBackgroundReady(true);
|
||||
}
|
||||
}, [activePage?.background_image_url, activePage?.background_video_url]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user