changed animations to transitions for better Safari stability

This commit is contained in:
Dmitri 2026-04-14 18:40:05 +04:00
parent 4d189346d9
commit 6fecb68941
3 changed files with 163 additions and 71 deletions

View File

@ -242,15 +242,38 @@ export default function RuntimePresentation({
// Use shared background transition hook for crossfade effects // Use shared background transition hook for crossfade effects
// NOTE: fadeOut config is NOT used for video transitions. // NOTE: fadeOut config is NOT used for video transitions.
// Video transitions end instantly (last frame = new page, then overlay removed). // Video transitions end instantly (last frame = new page, then overlay removed).
// fadeIn is used for non-video navigation (crossfade 500ms). // fadeIn is used for non-video navigation (crossfade 700ms via CSS transitions).
const { isFadingIn, onFadeInAnimationEnd, resetFadeIn } = const {
useBackgroundTransition({ isFadingIn,
pageSwitch, crossfadePhase,
// No fadeOut - video transitions don't use fade onFadeInAnimationEnd,
fadeIn: { onTransitionEnd,
hasActiveTransition: Boolean(transitionPreview), resetFadeIn,
}, } = useBackgroundTransition({
}); pageSwitch,
// No fadeOut - video transitions don't use fade
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
});
// Helper to generate crossfade classes for CSS transitions
// Two-phase approach: 'starting' = opacity 0, 'running' = opacity 1 (transition animates)
const getCrossfadeInClasses = () => {
if (!isFadingIn) return '';
const base = 'crossfade-layer';
if (crossfadePhase === 'starting') return `${base} crossfade-in-start`;
if (crossfadePhase === 'running') return `${base} crossfade-in-end`;
return '';
};
const getCrossfadeOutClasses = () => {
if (!isFadingIn) return '';
const base = 'crossfade-layer';
if (crossfadePhase === 'starting') return `${base} crossfade-out-start`;
if (crossfadePhase === 'running') return `${base} crossfade-out-end`;
return '';
};
const toggleFullscreen = useCallback(async () => { const toggleFullscreen = useCallback(async () => {
try { try {
@ -603,40 +626,43 @@ export default function RuntimePresentation({
)} )}
{/* Previous background overlays - show during loading AND crossfade. {/* Previous background overlays - show during loading AND crossfade.
Uses CSS animation for fade-out effect. Uses CSS transitions for fade-out effect (more reliable in Safari).
Cleared by useBackgroundTransition after fade completes. */} Cleared by useBackgroundTransition after fade completes. */}
{pageSwitch.previousBgImageUrl && {pageSwitch.previousBgImageUrl &&
(isFadingIn || (isFadingIn ||
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && ( (pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
<div <div
className={`absolute inset-0 pointer-events-none z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`} className={`absolute inset-0 pointer-events-none z-0 ${getCrossfadeOutClasses()}`}
style={{ style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`, backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'contain', backgroundSize: 'contain',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
}} }}
onTransitionEnd={onTransitionEnd}
/> />
)} )}
{pageSwitch.previousBgVideoUrl && {pageSwitch.previousBgVideoUrl &&
(isFadingIn || (isFadingIn ||
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && ( (pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
<video <video
className={`absolute inset-0 h-full w-full object-contain pointer-events-none z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`} className={`absolute inset-0 h-full w-full object-contain pointer-events-none z-0 ${getCrossfadeOutClasses()}`}
src={pageSwitch.previousBgVideoUrl} src={pageSwitch.previousBgVideoUrl}
autoPlay autoPlay
loop loop
muted muted
playsInline playsInline
onTransitionEnd={onTransitionEnd}
/> />
)} )}
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10). {/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
Fades in for non-transition navigation. Fades in for non-transition navigation.
onAnimationEnd resets isFadingIn when CSS animation completes. */} Uses CSS transitions (more reliable in Safari than animations). */}
<div <div
data-testid='page-background-wrapper' data-testid='page-background-wrapper'
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`} className={`absolute inset-0 z-5 ${getCrossfadeInClasses()}`}
onTransitionEnd={onTransitionEnd}
onAnimationEnd={onFadeInAnimationEnd} onAnimationEnd={onFadeInAnimationEnd}
> >
{/* Background image element */} {/* Background image element */}
@ -699,10 +725,10 @@ export default function RuntimePresentation({
{/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45). {/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
UI controls (z-50) remain on top. UI controls (z-50) remain on top.
Fades in together with background. */} Fades in together with background using CSS transitions. */}
<div <div
data-testid='page-elements-wrapper' data-testid='page-elements-wrapper'
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`} className={`absolute inset-0 z-[46] ${getCrossfadeInClasses()}`}
> >
{pageElements.map((element: CanvasElement) => ( {pageElements.map((element: CanvasElement) => (
<RuntimeElement <RuntimeElement

View File

@ -108,56 +108,76 @@
} }
} }
/* Crossfade animation classes - GPU accelerated for all browsers */ /* =============================================================================
/* Duration controlled by --crossfade-duration CSS variable (single source of truth) */ CROSSFADE TRANSITION SYSTEM
Uses CSS transitions (not animations) for maximum Safari compatibility.
Safari handles transitions more reliably than keyframe animations.
Two-phase approach:
1. .crossfade-layer: Base class with transition setup (always present)
2. .crossfade-in / .crossfade-out: Trigger classes that change opacity
The transition automatically interpolates between opacity values.
============================================================================= */
/* Base layer class - sets up GPU acceleration and transition */
.crossfade-layer {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
will-change: opacity;
/* Transition on opacity only */
-webkit-transition: opacity var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
transition: opacity var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
}
/* Fade-in: starts at 0, transitions to 1 */
.crossfade-in-start {
opacity: 0;
}
.crossfade-in-end {
opacity: 1;
}
/* Fade-out: starts at 1, transitions to 0 */
.crossfade-out-start {
opacity: 1;
}
.crossfade-out-end {
opacity: 0;
}
/* Legacy animation classes - kept for backwards compatibility */
/* These use CSS animations (less reliable in Safari but kept as fallback) */
.animate-crossfade-in { .animate-crossfade-in {
/* Explicit initial state prevents flash during animation setup */
opacity: 0; opacity: 0;
-webkit-transform: translate3d(0, 0, 0); -webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
/* Full animation property for maximum browser compatibility */ -webkit-animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
-webkit-animation-name: page-crossfade-in; animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
animation-name: page-crossfade-in;
-webkit-animation-duration: var(--crossfade-duration, 700ms);
animation-duration: var(--crossfade-duration, 700ms);
-webkit-animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
-webkit-animation-play-state: running;
animation-play-state: running;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
/* Optimize compositing */
will-change: opacity; will-change: opacity;
contain: layout style paint;
} }
.animate-crossfade-out { .animate-crossfade-out {
/* Explicit initial state prevents flash during animation setup */
opacity: 1; opacity: 1;
-webkit-transform: translate3d(0, 0, 0); -webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
/* Full animation property for maximum browser compatibility */ -webkit-animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
-webkit-animation-name: page-crossfade-out; animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
animation-name: page-crossfade-out;
-webkit-animation-duration: var(--crossfade-duration, 700ms);
animation-duration: var(--crossfade-duration, 700ms);
-webkit-animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
-webkit-animation-play-state: running;
animation-play-state: running;
-webkit-backface-visibility: hidden; -webkit-backface-visibility: hidden;
backface-visibility: hidden; backface-visibility: hidden;
/* Optimize compositing */
will-change: opacity; will-change: opacity;
contain: layout style paint;
} }
/* Safari-specific GPU compositing optimization */ /* Safari-specific GPU compositing optimization */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.crossfade-layer,
.animate-crossfade-in, .animate-crossfade-in,
.animate-crossfade-out { .animate-crossfade-out {
/* Force GPU layer creation in Safari */ /* Force GPU layer creation in Safari */
@ -166,20 +186,6 @@
/* Prevent Safari from optimizing away the GPU layer */ /* Prevent Safari from optimizing away the GPU layer */
-webkit-perspective: 1000px; -webkit-perspective: 1000px;
perspective: 1000px; perspective: 1000px;
/* Safari performs better with explicit transform-style */
-webkit-transform-style: preserve-3d;
transform-style: preserve-3d;
/* Isolate the layer for better compositing */
isolation: isolate;
}
}
/* Firefox-specific optimizations */
@-moz-document url-prefix() {
.animate-crossfade-in,
.animate-crossfade-out {
/* Firefox handles animations well but benefits from layer isolation */
will-change: opacity, transform;
} }
} }

View File

@ -71,6 +71,12 @@ export interface UseBackgroundTransitionOptions {
fadeIn?: FadeInConfig; fadeIn?: FadeInConfig;
} }
/**
* Crossfade phase for transition-based approach.
* Safari requires two-phase: first render initial opacity, then transition to final.
*/
export type CrossfadePhase = 'idle' | 'starting' | 'running' | 'complete';
export interface UseBackgroundTransitionResult { export interface UseBackgroundTransitionResult {
/** Whether the overlay is currently fading out */ /** Whether the overlay is currently fading out */
isOverlayFadingOut: boolean; isOverlayFadingOut: boolean;
@ -78,8 +84,12 @@ export interface UseBackgroundTransitionResult {
resetFadeOut: () => void; resetFadeOut: () => void;
/** Whether page content is currently fading (crossfade in progress) */ /** Whether page content is currently fading (crossfade in progress) */
isFadingIn: boolean; isFadingIn: boolean;
/** Current crossfade phase for transition-based approach */
crossfadePhase: CrossfadePhase;
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */ /** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
onFadeInAnimationEnd: (e?: React.AnimationEvent) => void; onFadeInAnimationEnd: (e?: React.AnimationEvent) => void;
/** Handler for transition end (alternative to animation end) */
onTransitionEnd: (e?: React.TransitionEvent) => void;
/** Reset fade-in state (for cleanup or cancellation) */ /** Reset fade-in state (for cleanup or cancellation) */
resetFadeIn: () => void; resetFadeIn: () => void;
} }
@ -122,6 +132,8 @@ export function useBackgroundTransition({
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult { }: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false); const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
const [isFadingIn, setIsFadingIn] = useState(false); const [isFadingIn, setIsFadingIn] = useState(false);
// Crossfade phase for CSS transitions (more reliable in Safari)
const [crossfadePhase, setCrossfadePhase] = useState<CrossfadePhase>('idle');
// Track previous isSwitching state to detect transition start // Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false); const wasSwitchingRef = useRef(false);
@ -130,6 +142,8 @@ export function useBackgroundTransition({
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track if animation was already completed (by event or timer) // Track if animation was already completed (by event or timer)
const fadeInCompletedRef = useRef(false); const fadeInCompletedRef = useRef(false);
// RAF handle for two-phase transition approach
const rafRef = useRef<number | null>(null);
/** /**
* Reset fade-out state before starting a new transition. * Reset fade-out state before starting a new transition.
@ -144,16 +158,21 @@ export function useBackgroundTransition({
*/ */
const resetFadeIn = useCallback(() => { const resetFadeIn = useCallback(() => {
setIsFadingIn(false); setIsFadingIn(false);
setCrossfadePhase('idle');
fadeInCompletedRef.current = false; fadeInCompletedRef.current = false;
if (fadeInTimerRef.current) { if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current); clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null; fadeInTimerRef.current = null;
} }
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}, []); }, []);
/** /**
* Complete fade-in animation. * Complete fade-in animation.
* Called either by onAnimationEnd or by timer fallback. * Called either by onAnimationEnd, onTransitionEnd, or timer fallback.
* Uses ref to prevent double-completion. * Uses ref to prevent double-completion.
*/ */
const completeFadeIn = useCallback(() => { const completeFadeIn = useCallback(() => {
@ -165,7 +184,12 @@ export function useBackgroundTransition({
clearTimeout(fadeInTimerRef.current); clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null; fadeInTimerRef.current = null;
} }
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
setCrossfadePhase('complete');
setIsFadingIn(false); setIsFadingIn(false);
}, []); }, []);
@ -183,6 +207,19 @@ export function useBackgroundTransition({
[completeFadeIn], [completeFadeIn],
); );
/**
* Handler for onTransitionEnd event.
* Called when CSS transition completes (more reliable in Safari than animations).
*/
const onTransitionEnd = useCallback(
(e?: React.TransitionEvent) => {
// Only handle opacity transitions, not other properties
if (e && e.propertyName !== 'opacity') return;
completeFadeIn();
},
[completeFadeIn],
);
/** /**
* Effect: Fade out and remove transition overlay when background is ready. * Effect: Fade out and remove transition overlay when background is ready.
* Only runs when fadeOut config is provided. * Only runs when fadeOut config is provided.
@ -281,8 +318,10 @@ export function useBackgroundTransition({
* IMPORTANT: Skip this for transitions - transition video IS the effect. * IMPORTANT: Skip this for transitions - transition video IS the effect.
* *
* Cross-browser handling: * Cross-browser handling:
* - Sets up JS timer fallback for Safari (unreliable onAnimationEnd) * - Uses two-phase CSS transitions (more reliable in Safari than animations)
* - Chrome/Firefox rely on CSS onAnimationEnd event * - Phase 1 (starting): Apply initial opacity
* - Phase 2 (running): After one frame, apply final opacity - CSS transition animates
* - Fallback timer ensures completion even if events don't fire
*/ */
useLayoutEffect(() => { useLayoutEffect(() => {
if (!fadeIn) { if (!fadeIn) {
@ -302,21 +341,36 @@ export function useBackgroundTransition({
// Reset completion flag for new animation // Reset completion flag for new animation
fadeInCompletedRef.current = false; fadeInCompletedRef.current = false;
// Clear any existing timer // Clear any existing timers/rafs
if (fadeInTimerRef.current) { if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current); clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null; fadeInTimerRef.current = null;
} }
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
setIsFadingIn(true); setIsFadingIn(true);
// Safari/Firefox fallback: Use JS timer as backup since onAnimationEnd // Two-phase approach for Safari:
// can be unreliable or fire on wrong animations. // 1. First, set 'starting' phase with initial opacity
// Timer is slightly longer than CSS duration to let CSS complete first. // 2. After browser paints, set 'running' phase - CSS transition animates
// Chrome typically fires onAnimationEnd reliably, but timer is harmless backup. setCrossfadePhase('starting');
// Use double-RAF for Safari to ensure initial state is painted
// Safari needs to see the starting state before transitioning
rafRef.current = requestAnimationFrame(() => {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
setCrossfadePhase('running');
});
});
// Fallback timer - ensures completion even if transition events don't fire
const duration = getCrossfadeDuration(); const duration = getCrossfadeDuration();
// Add 50ms buffer for Safari's animation timing variance // Add buffer: Safari needs more time, Chrome/Firefox less
const bufferMs = isSafari() ? 100 : 50; const bufferMs = isSafari() ? 150 : 100;
fadeInTimerRef.current = setTimeout(() => { fadeInTimerRef.current = setTimeout(() => {
fadeInTimerRef.current = null; fadeInTimerRef.current = null;
@ -325,13 +379,17 @@ export function useBackgroundTransition({
} }
}, [pageSwitch.isSwitching, fadeIn, completeFadeIn]); }, [pageSwitch.isSwitching, fadeIn, completeFadeIn]);
// Cleanup timer on unmount // Cleanup timer and RAF on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (fadeInTimerRef.current) { if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current); clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null; fadeInTimerRef.current = null;
} }
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}; };
}, []); }, []);
@ -339,7 +397,9 @@ export function useBackgroundTransition({
isOverlayFadingOut, isOverlayFadingOut,
resetFadeOut, resetFadeOut,
isFadingIn, isFadingIn,
crossfadePhase,
onFadeInAnimationEnd, onFadeInAnimationEnd,
onTransitionEnd,
resetFadeIn, resetFadeIn,
}; };
} }