fixed smooth transitions issue for safari browser

This commit is contained in:
Dmitri 2026-04-23 11:34:28 +04:00
parent fe82dbb318
commit 7e54cc8858
6 changed files with 181 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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