safari adaprivity improvement (flashlites betveen pages backgrounds and transitions covering

This commit is contained in:
Dmitri 2026-04-14 14:06:53 +04:00
parent 0987c87c1d
commit 804e082ed7
6 changed files with 293 additions and 42 deletions

View File

@ -50,6 +50,7 @@ import {
} from '../lib/navigationHelpers';
import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement } from '../types/constructor';
import { isSafari } from '../lib/browserUtils';
interface RuntimePresentationProps {
projectSlug: string;
@ -114,6 +115,11 @@ export default function RuntimePresentation({
initialIndex: number;
} | null>(null);
// Safari Black Flash Prevention:
// Track the last successfully displayed background to use as a "snapshot" layer.
// This layer sits behind everything and ensures we never see the black container.
const [lastKnownBgUrl, setLastKnownBgUrl] = useState<string>('');
const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
@ -330,6 +336,26 @@ export default function RuntimePresentation({
}
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
// Safari Black Flash Prevention:
// Update lastKnownBgUrl when a background is successfully displayed.
// This creates a "snapshot" that persists through transitions.
useEffect(() => {
if (isBackgroundReady && pageSwitch.isNewBgReady) {
// Prioritize image over video for snapshot (image is more reliable)
if (pageSwitch.currentBgImageUrl) {
setLastKnownBgUrl(pageSwitch.currentBgImageUrl);
} else if (pageSwitch.currentBgVideoUrl) {
// For video backgrounds, we can't easily snapshot, so keep the last image
// The video element itself provides visual continuity
}
}
}, [
isBackgroundReady,
pageSwitch.isNewBgReady,
pageSwitch.currentBgImageUrl,
pageSwitch.currentBgVideoUrl,
]);
const navigateToPage = useCallback(
async (
targetPageId: string,
@ -546,6 +572,24 @@ export default function RuntimePresentation({
}}
>
<BackdropPortalProvider>
{/* Safari Black Flash Prevention:
Persistent snapshot layer that NEVER gets removed during transitions.
Shows the last successfully displayed background, ensuring we never
see the black outer container during page switches.
z-[-1] keeps it behind all dynamic content layers. */}
{lastKnownBgUrl && isSafari() && (
<div
className='absolute inset-0 pointer-events-none'
style={{
zIndex: -1,
backgroundImage: `url("${lastKnownBgUrl}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
)}
{/* Previous background overlays - show during loading AND crossfade.
Uses CSS animation for fade-out effect.
Cleared by useBackgroundTransition after fade completes. */}

View File

@ -85,19 +85,42 @@
/* Crossfade animation classes - GPU accelerated for Safari */
.animate-crossfade-in {
/* Explicit initial state prevents Safari flash during animation setup */
opacity: 0;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
-webkit-animation: page-crossfade-in 300ms ease-out forwards;
animation: page-crossfade-in 300ms ease-out forwards;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
will-change: opacity, transform;
/* Only animate opacity - transform is for GPU layer promotion */
will-change: opacity;
}
.animate-crossfade-out {
/* Explicit initial state prevents Safari flash during animation setup */
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
-webkit-animation: page-crossfade-out 300ms ease-out forwards;
animation: page-crossfade-out 300ms ease-out forwards;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
will-change: opacity, transform;
/* Only animate opacity - transform is for GPU layer promotion */
will-change: opacity;
}
/* Safari-specific GPU compositing optimization */
@supports (-webkit-touch-callout: none) {
.animate-crossfade-in,
.animate-crossfade-out {
/* Force GPU layer creation in Safari */
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
/* Prevent Safari from optimizing away the GPU layer */
-webkit-perspective: 1000;
perspective: 1000;
}
}
/* Element appear animation keyframes - Safari optimized */

View File

@ -22,6 +22,7 @@ import {
useRef,
} from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config';
import { isSafari, scheduleAfterPaintSafari } from '../lib/browserUtils';
/**
* Fade-out duration from config (for transition video overlay)
@ -150,8 +151,9 @@ export function useBackgroundTransition({
* Sequence:
* 1. Transition video finishes playing (pendingTransitionComplete = true)
* 2. New background image loads (isBackgroundReady = true)
* 3. Start fade-out animation (isOverlayFadingOut = true)
* 4. After fade completes, clean up video and clear transition state
* 3. Safari: Wait extra frame to ensure background is painted
* 4. Start fade-out animation (isOverlayFadingOut = true)
* 5. After fade completes, clean up video and clear transition state
*/
useEffect(() => {
if (!fadeOut) return;
@ -164,29 +166,52 @@ export function useBackgroundTransition({
} = fadeOut;
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
// Start fade-out animation
setIsOverlayFadingOut(true);
// Safari Black Flash Prevention:
// Wait an extra frame in Safari to ensure the new background is truly painted
// before starting the fade-out animation. This prevents showing black between
// the transition video and the new page content.
const startFadeOut = () => {
// Start fade-out animation
setIsOverlayFadingOut(true);
};
// After fade completes, remove the overlay
const fadeTimer = setTimeout(() => {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
// Clear previous background from shared hook
pageSwitch.clearPreviousBackground();
// Notify caller to clear transition state
onTransitionCleanup();
// Reset fade-out state
setIsOverlayFadingOut(false);
}, FADE_OUT_DURATION_MS);
return () => clearTimeout(fadeTimer);
// Safari: verify paint completion with extra frame wait
if (isSafari()) {
scheduleAfterPaintSafari(startFadeOut);
} else {
startFadeOut();
}
}
}, [fadeOut, isOverlayFadingOut]);
/**
* Effect: Complete fade-out and cleanup after animation duration.
* Separated from the start effect to ensure proper cleanup timing.
*/
useEffect(() => {
if (!fadeOut || !isOverlayFadingOut) return;
const { transitionVideoRef, onTransitionCleanup } = fadeOut;
// After fade completes, remove the overlay
const fadeTimer = setTimeout(() => {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
// Clear previous background from shared hook
pageSwitch.clearPreviousBackground();
// Notify caller to clear transition state
onTransitionCleanup();
// Reset fade-out state
setIsOverlayFadingOut(false);
}, FADE_OUT_DURATION_MS);
return () => clearTimeout(fadeTimer);
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
/**

View File

@ -21,7 +21,11 @@ import {
buildProxyUrl,
} from '../lib/assetUrl';
import { logger } from '../lib/logger';
import { scheduleAfterPaint } from '../lib/browserUtils';
import {
scheduleAfterPaint,
scheduleAfterPaintSafari,
isSafari,
} from '../lib/browserUtils';
/**
* Minimal page interface for page switching
@ -102,6 +106,11 @@ export interface UsePageSwitchResult {
/**
* Load and decode an image with presigned URL fallback.
* Returns the URL that successfully loaded.
*
* Safari-specific handling:
* Safari's img.decode() can resolve before pixels are actually ready for painting.
* For Safari, we add an extra frame wait after decode to ensure the image is
* truly ready to display, preventing black flash during page transitions.
*/
const loadImageWithFallback = (
url: string,
@ -109,6 +118,16 @@ const loadImageWithFallback = (
): Promise<string> => {
return new Promise((resolve) => {
const img = new window.Image();
const safariMode = isSafari();
const onImageReady = (srcUrl: string) => {
if (safariMode) {
// Safari: wait an extra frame after decode to ensure pixels are ready
scheduleAfterPaintSafari(() => resolve(srcUrl));
} else {
resolve(srcUrl);
}
};
const tryLoad = (srcUrl: string, isRetry = false) => {
img.src = srcUrl;
@ -117,10 +136,10 @@ const loadImageWithFallback = (
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve(srcUrl))
.catch(() => resolve(srcUrl));
.then(() => onImageReady(srcUrl))
.catch(() => onImageReady(srcUrl));
} else {
resolve(srcUrl);
onImageReady(srcUrl);
}
};
@ -135,7 +154,7 @@ const loadImageWithFallback = (
tryLoad(proxyUrl, true);
} else {
// Give up but still resolve to not block navigation
resolve(srcUrl);
onImageReady(srcUrl);
}
};
};

View File

@ -24,6 +24,7 @@ import {
extractStoragePath,
} from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari, isFirefox } from '../lib/browserUtils';
export type ReverseMode = 'none' | 'separate';
@ -84,6 +85,28 @@ const DEFAULT_TIMEOUTS = {
hardTimeoutMs: 45000,
};
/**
* 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.
*
* @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;
}
if (isFirefox()) {
// Firefox: slightly larger buffer for safety
return 60;
}
// Chrome and other browsers: standard timing
return 50;
};
function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean {
if (useBlobUrlOption === false) return false;
if (useBlobUrlOption === true) return true;
@ -364,7 +387,8 @@ export function useTransitionPlayback(
) {
return;
}
const finishBeforeEndMs = 50;
// Use browser-specific offset to prevent black flash at video end
const finishBeforeEndMs = getFinishBeforeEndMs();
const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs);
finishTimerRef.current = setTimeout(
() => finishPlayback('duration-timer'),

View File

@ -3,36 +3,146 @@
*
* Centralized browser detection and cross-browser timing utilities.
* Follows patterns from useNetworkAware.ts for vendor-prefixed API detection.
*
* Safari Black Flash Fix:
* Safari can execute nested requestAnimationFrame calls in the same frame,
* breaking the "double-RAF" pattern used in React for post-paint scheduling.
* This module provides Safari-specific scheduling to ensure state updates
* occur on the correct paint cycle.
*/
/**
* Browser information for feature detection and behavior adjustments.
*/
export interface BrowserInfo {
isSafari: boolean;
isIOSSafari: boolean;
isChrome: boolean;
isFirefox: boolean;
isEdge: boolean;
supportsCreateImageBitmap: boolean;
supportsOffscreenCanvas: boolean;
}
// Cache browser info to avoid repeated UA parsing
let cachedBrowserInfo: BrowserInfo | null = null;
/**
* Get detailed browser information.
* Results are cached for performance.
*/
export const getBrowserInfo = (): BrowserInfo => {
if (cachedBrowserInfo) return cachedBrowserInfo;
if (typeof navigator === 'undefined' || typeof window === 'undefined') {
return {
isSafari: false,
isIOSSafari: false,
isChrome: false,
isFirefox: false,
isEdge: false,
supportsCreateImageBitmap: false,
supportsOffscreenCanvas: false,
};
}
const ua = navigator.userAgent;
// Safari detection: Safari UA but not Chrome/Chromium-based
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
// iOS Safari detection
const isIOSSafari =
/iPad|iPhone|iPod/.test(ua) && !('MSStream' in window as unknown);
// Chrome detection (includes Chromium-based browsers except Edge)
const isChrome = /chrome/i.test(ua) && !/edg/i.test(ua);
// Firefox detection
const isFirefox = /firefox/i.test(ua);
// Edge detection (Chromium-based Edge)
const isEdge = /edg/i.test(ua);
// Feature detection for image processing capabilities
const supportsCreateImageBitmap = 'createImageBitmap' in window;
const supportsOffscreenCanvas = 'OffscreenCanvas' in window;
cachedBrowserInfo = {
isSafari,
isIOSSafari,
isChrome,
isFirefox,
isEdge,
supportsCreateImageBitmap,
supportsOffscreenCanvas,
};
return cachedBrowserInfo;
};
/**
* Detect Safari browser (macOS and iOS).
* Uses feature detection pattern from useNetworkAware.ts.
*/
export const isSafari = (): boolean => {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent;
// Safari but not Chrome/Chromium-based
return /^((?!chrome|android).)*safari/i.test(ua);
return getBrowserInfo().isSafari;
};
/**
* Detect iOS Safari specifically.
*/
export const isIOSSafari = (): boolean => {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent;
return /iPad|iPhone|iPod/.test(ua) && !('MSStream' in window);
return getBrowserInfo().isIOSSafari;
};
/**
* Detect Chrome browser.
*/
export const isChrome = (): boolean => {
return getBrowserInfo().isChrome;
};
/**
* Detect Firefox browser.
*/
export const isFirefox = (): boolean => {
return getBrowserInfo().isFirefox;
};
/**
* Safari-specific paint scheduling with guaranteed frame separation.
*
* Safari's compositor can execute nested RAFs in the same frame, which breaks
* the typical "wait for paint" pattern. This function uses double setTimeout
* plus RAF to create multiple macrotask boundaries, ensuring the callback
* runs after Safari has truly completed the paint cycle.
*
* @param callback - Function to run after paint is guaranteed complete
*/
export const scheduleAfterPaintSafari = (callback: () => void): void => {
if (typeof window === 'undefined') {
callback();
return;
}
// Double setTimeout creates two macrotask boundaries
// This ensures Safari's compositor has completed the current frame
setTimeout(() => {
setTimeout(() => {
requestAnimationFrame(callback);
}, 0);
}, 0);
};
/**
* Schedule a callback to run after the next browser paint.
* Safari-compatible timing that ensures state updates occur on the correct paint cycle.
* Automatically uses Safari-specific timing when needed.
*
* Safari's RAF scheduler can fire multiple nested RAFs in the same frame,
* defeating the double-RAF pattern commonly used in React.
* This uses setTimeout to create a macrotask boundary, ensuring the callback
* runs after the current paint cycle completes.
* For Safari, we use additional macrotask boundaries.
* For other browsers, a single setTimeout + RAF is sufficient.
*
* @param callback - Function to run after paint
*/
@ -42,9 +152,15 @@ export const scheduleAfterPaint = (callback: () => void): void => {
return;
}
// Use Safari-specific timing for Safari browsers
if (isSafari()) {
scheduleAfterPaintSafari(callback);
return;
}
// Standard timing for Chrome, Firefox, Edge, etc.
// setTimeout(0) creates a macrotask boundary
// RAF ensures we're synced with the next paint cycle
// This pattern works reliably across all browsers including Safari
setTimeout(() => {
requestAnimationFrame(callback);
}, 0);