changed animations to transitions for better Safari stability
This commit is contained in:
parent
4d189346d9
commit
6fecb68941
@ -242,15 +242,38 @@ export default function RuntimePresentation({
|
||||
// Use shared background transition hook for crossfade effects
|
||||
// NOTE: fadeOut config is NOT used for video transitions.
|
||||
// Video transitions end instantly (last frame = new page, then overlay removed).
|
||||
// fadeIn is used for non-video navigation (crossfade 500ms).
|
||||
const { isFadingIn, onFadeInAnimationEnd, resetFadeIn } =
|
||||
useBackgroundTransition({
|
||||
pageSwitch,
|
||||
// No fadeOut - video transitions don't use fade
|
||||
fadeIn: {
|
||||
hasActiveTransition: Boolean(transitionPreview),
|
||||
},
|
||||
});
|
||||
// fadeIn is used for non-video navigation (crossfade 700ms via CSS transitions).
|
||||
const {
|
||||
isFadingIn,
|
||||
crossfadePhase,
|
||||
onFadeInAnimationEnd,
|
||||
onTransitionEnd,
|
||||
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 () => {
|
||||
try {
|
||||
@ -603,40 +626,43 @@ export default function RuntimePresentation({
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
{pageSwitch.previousBgImageUrl &&
|
||||
(isFadingIn ||
|
||||
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
|
||||
<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={{
|
||||
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
)}
|
||||
{pageSwitch.previousBgVideoUrl &&
|
||||
(isFadingIn ||
|
||||
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
|
||||
<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}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
||||
Fades in for non-transition navigation.
|
||||
onAnimationEnd resets isFadingIn when CSS animation completes. */}
|
||||
Uses CSS transitions (more reliable in Safari than animations). */}
|
||||
<div
|
||||
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}
|
||||
>
|
||||
{/* 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).
|
||||
UI controls (z-50) remain on top.
|
||||
Fades in together with background. */}
|
||||
Fades in together with background using CSS transitions. */}
|
||||
<div
|
||||
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) => (
|
||||
<RuntimeElement
|
||||
|
||||
@ -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 {
|
||||
/* Explicit initial state prevents flash during animation setup */
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
/* Full animation property for maximum browser compatibility */
|
||||
-webkit-animation-name: page-crossfade-in;
|
||||
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-animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
|
||||
animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
/* Optimize compositing */
|
||||
will-change: opacity;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.animate-crossfade-out {
|
||||
/* Explicit initial state prevents flash during animation setup */
|
||||
opacity: 1;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
/* Full animation property for maximum browser compatibility */
|
||||
-webkit-animation-name: page-crossfade-out;
|
||||
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-animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
|
||||
animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
/* Optimize compositing */
|
||||
will-change: opacity;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
/* Safari-specific GPU compositing optimization */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.crossfade-layer,
|
||||
.animate-crossfade-in,
|
||||
.animate-crossfade-out {
|
||||
/* Force GPU layer creation in Safari */
|
||||
@ -166,20 +186,6 @@
|
||||
/* Prevent Safari from optimizing away the GPU layer */
|
||||
-webkit-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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -71,6 +71,12 @@ export interface UseBackgroundTransitionOptions {
|
||||
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 {
|
||||
/** Whether the overlay is currently fading out */
|
||||
isOverlayFadingOut: boolean;
|
||||
@ -78,8 +84,12 @@ export interface UseBackgroundTransitionResult {
|
||||
resetFadeOut: () => void;
|
||||
/** Whether page content is currently fading (crossfade in progress) */
|
||||
isFadingIn: boolean;
|
||||
/** Current crossfade phase for transition-based approach */
|
||||
crossfadePhase: CrossfadePhase;
|
||||
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
|
||||
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) */
|
||||
resetFadeIn: () => void;
|
||||
}
|
||||
@ -122,6 +132,8 @@ export function useBackgroundTransition({
|
||||
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
||||
const [isOverlayFadingOut, setIsOverlayFadingOut] = 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
|
||||
const wasSwitchingRef = useRef(false);
|
||||
@ -130,6 +142,8 @@ export function useBackgroundTransition({
|
||||
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Track if animation was already completed (by event or timer)
|
||||
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.
|
||||
@ -144,16 +158,21 @@ export function useBackgroundTransition({
|
||||
*/
|
||||
const resetFadeIn = useCallback(() => {
|
||||
setIsFadingIn(false);
|
||||
setCrossfadePhase('idle');
|
||||
fadeInCompletedRef.current = false;
|
||||
if (fadeInTimerRef.current) {
|
||||
clearTimeout(fadeInTimerRef.current);
|
||||
fadeInTimerRef.current = null;
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const completeFadeIn = useCallback(() => {
|
||||
@ -165,7 +184,12 @@ export function useBackgroundTransition({
|
||||
clearTimeout(fadeInTimerRef.current);
|
||||
fadeInTimerRef.current = null;
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
|
||||
setCrossfadePhase('complete');
|
||||
setIsFadingIn(false);
|
||||
}, []);
|
||||
|
||||
@ -183,6 +207,19 @@ export function useBackgroundTransition({
|
||||
[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.
|
||||
* Only runs when fadeOut config is provided.
|
||||
@ -281,8 +318,10 @@ export function useBackgroundTransition({
|
||||
* IMPORTANT: Skip this for transitions - transition video IS the effect.
|
||||
*
|
||||
* Cross-browser handling:
|
||||
* - Sets up JS timer fallback for Safari (unreliable onAnimationEnd)
|
||||
* - Chrome/Firefox rely on CSS onAnimationEnd event
|
||||
* - Uses two-phase CSS transitions (more reliable in Safari than animations)
|
||||
* - 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(() => {
|
||||
if (!fadeIn) {
|
||||
@ -302,21 +341,36 @@ export function useBackgroundTransition({
|
||||
// Reset completion flag for new animation
|
||||
fadeInCompletedRef.current = false;
|
||||
|
||||
// Clear any existing timer
|
||||
// Clear any existing timers/rafs
|
||||
if (fadeInTimerRef.current) {
|
||||
clearTimeout(fadeInTimerRef.current);
|
||||
fadeInTimerRef.current = null;
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
|
||||
setIsFadingIn(true);
|
||||
|
||||
// Safari/Firefox fallback: Use JS timer as backup since onAnimationEnd
|
||||
// can be unreliable or fire on wrong animations.
|
||||
// Timer is slightly longer than CSS duration to let CSS complete first.
|
||||
// Chrome typically fires onAnimationEnd reliably, but timer is harmless backup.
|
||||
// Two-phase approach for Safari:
|
||||
// 1. First, set 'starting' phase with initial opacity
|
||||
// 2. After browser paints, set 'running' phase - CSS transition animates
|
||||
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();
|
||||
// Add 50ms buffer for Safari's animation timing variance
|
||||
const bufferMs = isSafari() ? 100 : 50;
|
||||
// Add buffer: Safari needs more time, Chrome/Firefox less
|
||||
const bufferMs = isSafari() ? 150 : 100;
|
||||
|
||||
fadeInTimerRef.current = setTimeout(() => {
|
||||
fadeInTimerRef.current = null;
|
||||
@ -325,13 +379,17 @@ export function useBackgroundTransition({
|
||||
}
|
||||
}, [pageSwitch.isSwitching, fadeIn, completeFadeIn]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
// Cleanup timer and RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fadeInTimerRef.current) {
|
||||
clearTimeout(fadeInTimerRef.current);
|
||||
fadeInTimerRef.current = null;
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -339,7 +397,9 @@ export function useBackgroundTransition({
|
||||
isOverlayFadingOut,
|
||||
resetFadeOut,
|
||||
isFadingIn,
|
||||
crossfadePhase,
|
||||
onFadeInAnimationEnd,
|
||||
onTransitionEnd,
|
||||
resetFadeIn,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user