safari adaprivity improvement (flashlites betveen pages backgrounds and transitions covering
This commit is contained in:
parent
0987c87c1d
commit
804e082ed7
@ -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. */}
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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]);
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user