From 804e082ed7cdb6c620f7eb3d78f155e71649eb07 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Tue, 14 Apr 2026 14:06:53 +0400 Subject: [PATCH] safari adaprivity improvement (flashlites betveen pages backgrounds and transitions covering --- .../src/components/RuntimePresentation.tsx | 44 ++++++ frontend/src/css/main.css | 27 +++- frontend/src/hooks/useBackgroundTransition.ts | 71 ++++++--- frontend/src/hooks/usePageSwitch.ts | 29 +++- frontend/src/hooks/useTransitionPlayback.ts | 26 +++- frontend/src/lib/browserUtils.ts | 138 ++++++++++++++++-- 6 files changed, 293 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index 9b3cc56..4f7ac92 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -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(''); + const transitionVideoRef = useRef(null); const lastInitializedPageIdRef = useRef(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({ }} > + {/* 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() && ( +
+ )} + {/* Previous background overlays - show during loading AND crossfade. Uses CSS animation for fade-out effect. Cleared by useBackgroundTransition after fade completes. */} diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index ef01248..af0b3e7 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -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 */ diff --git a/frontend/src/hooks/useBackgroundTransition.ts b/frontend/src/hooks/useBackgroundTransition.ts index 119d85d..4b2845b 100644 --- a/frontend/src/hooks/useBackgroundTransition.ts +++ b/frontend/src/hooks/useBackgroundTransition.ts @@ -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]); /** diff --git a/frontend/src/hooks/usePageSwitch.ts b/frontend/src/hooks/usePageSwitch.ts index 55f0a75..46ae6d4 100644 --- a/frontend/src/hooks/usePageSwitch.ts +++ b/frontend/src/hooks/usePageSwitch.ts @@ -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 => { 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); } }; }; diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index eae5159..f393516 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -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'), diff --git a/frontend/src/lib/browserUtils.ts b/frontend/src/lib/browserUtils.ts index 8da866c..b67e181 100644 --- a/frontend/src/lib/browserUtils.ts +++ b/frontend/src/lib/browserUtils.ts @@ -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);