improvements for safari fades
This commit is contained in:
parent
6fecb68941
commit
a6de052952
@ -9,6 +9,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import NextImage from 'next/image';
|
import NextImage from 'next/image';
|
||||||
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
||||||
|
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
||||||
|
|
||||||
interface CanvasBackgroundProps {
|
interface CanvasBackgroundProps {
|
||||||
backgroundImageUrl?: string;
|
backgroundImageUrl?: string;
|
||||||
@ -101,27 +102,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
{/* Previous background overlays - show during loading AND crossfade.
|
{/* Previous background overlays - show during loading AND crossfade.
|
||||||
Uses CSS animation for fade-out effect during crossfade.
|
Uses CSS animation for fade-out effect during crossfade.
|
||||||
z-0 keeps them BELOW new backgrounds (z-1). */}
|
z-0 keeps them BELOW new backgrounds (z-1). */}
|
||||||
{previousBgImageUrl && (isFadingIn || (isSwitching && !isNewBgReady)) && (
|
<PreviousBackgroundOverlay
|
||||||
<div
|
imageUrl={previousBgImageUrl}
|
||||||
className={`pointer-events-none absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
|
videoUrl={previousBgVideoUrl}
|
||||||
style={{
|
isSwitching={isSwitching}
|
||||||
backgroundImage: `url("${previousBgImageUrl}")`,
|
isNewBgReady={isNewBgReady}
|
||||||
backgroundSize: 'contain',
|
isFadingIn={isFadingIn}
|
||||||
backgroundPosition: 'center',
|
/>
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{previousBgVideoUrl && (isFadingIn || (isSwitching && !isNewBgReady)) && (
|
|
||||||
<video
|
|
||||||
className={`absolute inset-0 z-0 h-full w-full object-contain pointer-events-none ${isFadingIn ? 'animate-crossfade-out' : ''}`}
|
|
||||||
src={previousBgVideoUrl}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
|
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
|
||||||
{backgroundVideoUrl && (
|
{backgroundVideoUrl && (
|
||||||
|
|||||||
66
frontend/src/components/PreviousBackgroundOverlay.tsx
Normal file
66
frontend/src/components/PreviousBackgroundOverlay.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* PreviousBackgroundOverlay Component
|
||||||
|
*
|
||||||
|
* Renders the previous page background during page transitions.
|
||||||
|
* Shows during loading and crossfade, with optional fade-out animation.
|
||||||
|
* Used by both CanvasBackground (constructor) and RuntimePresentation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface PreviousBackgroundOverlayProps {
|
||||||
|
/** Previous background image URL */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Previous background video URL */
|
||||||
|
videoUrl?: string;
|
||||||
|
/** Whether page is currently switching */
|
||||||
|
isSwitching?: boolean;
|
||||||
|
/** Whether new background is ready */
|
||||||
|
isNewBgReady?: boolean;
|
||||||
|
/** Whether fade animation is in progress */
|
||||||
|
isFadingIn?: boolean;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
|
isSwitching = false,
|
||||||
|
isNewBgReady = false,
|
||||||
|
isFadingIn = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
// Show during loading (isSwitching && !isNewBgReady) OR during crossfade (isFadingIn)
|
||||||
|
const shouldShow = isFadingIn || (isSwitching && !isNewBgReady);
|
||||||
|
|
||||||
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{imageUrl && (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''} ${className}`}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("${imageUrl}")`,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{videoUrl && (
|
||||||
|
<video
|
||||||
|
className={`absolute inset-0 z-0 h-full w-full object-contain pointer-events-none ${isFadingIn ? 'animate-crossfade-out' : ''} ${className}`}
|
||||||
|
src={videoUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PreviousBackgroundOverlay;
|
||||||
@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
|
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Image from 'next/image';
|
|
||||||
import React, {
|
import React, {
|
||||||
ReactElement,
|
ReactElement,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -26,6 +25,7 @@ import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
|
|||||||
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||||
import { BackdropPortalProvider } from './BackdropPortal';
|
import { BackdropPortalProvider } from './BackdropPortal';
|
||||||
import { RotatePrompt } from './RotatePrompt';
|
import { RotatePrompt } from './RotatePrompt';
|
||||||
|
import CanvasBackground from './Constructor/CanvasBackground';
|
||||||
import { useCanvasScale } from '../hooks/useCanvasScale';
|
import { useCanvasScale } from '../hooks/useCanvasScale';
|
||||||
import { CANVAS_CONFIG } from '../config/canvas.config';
|
import { CANVAS_CONFIG } from '../config/canvas.config';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
@ -40,7 +40,6 @@ import {
|
|||||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||||
import { useBackgroundVideoPlayback } from '../hooks/useBackgroundVideoPlayback';
|
|
||||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import {
|
import {
|
||||||
@ -242,39 +241,14 @@ 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 700ms via CSS transitions).
|
// fadeIn is used for non-video navigation (crossfade 500ms).
|
||||||
const {
|
const { isFadingIn, resetFadeIn } = useBackgroundTransition({
|
||||||
isFadingIn,
|
|
||||||
crossfadePhase,
|
|
||||||
onFadeInAnimationEnd,
|
|
||||||
onTransitionEnd,
|
|
||||||
resetFadeIn,
|
|
||||||
} = useBackgroundTransition({
|
|
||||||
pageSwitch,
|
pageSwitch,
|
||||||
// No fadeOut - video transitions don't use fade
|
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
hasActiveTransition: Boolean(transitionPreview),
|
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 {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
@ -519,18 +493,7 @@ export default function RuntimePresentation({
|
|||||||
? parseFloat(String(selectedPage.background_video_end_time))
|
? parseFloat(String(selectedPage.background_video_end_time))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Use background video playback hook for custom start/end time handling
|
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
|
||||||
const { videoRef: bgVideoRef } = useBackgroundVideoPlayback({
|
|
||||||
videoUrl: backgroundVideoUrl,
|
|
||||||
autoplay: videoAutoplay,
|
|
||||||
loop: videoLoop,
|
|
||||||
muted: videoMuted,
|
|
||||||
startTime: videoStartTime,
|
|
||||||
endTime: videoEndTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
// When endTime is set, we disable native loop and handle it via the hook
|
|
||||||
const useNativeLoop = videoEndTime == null ? videoLoop : false;
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@ -625,110 +588,40 @@ export default function RuntimePresentation({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Previous background overlays - show during loading AND crossfade.
|
|
||||||
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 ${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 ${getCrossfadeOutClasses()}`}
|
|
||||||
src={pageSwitch.previousBgVideoUrl}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
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. Uses shared CanvasBackground component
|
||||||
Uses CSS transitions (more reliable in Safari than animations). */}
|
for single source of truth with constructor (same transitions, same structure). */}
|
||||||
<div
|
<div
|
||||||
data-testid='page-background-wrapper'
|
data-testid='page-background-wrapper'
|
||||||
className={`absolute inset-0 z-5 ${getCrossfadeInClasses()}`}
|
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||||
onTransitionEnd={onTransitionEnd}
|
|
||||||
onAnimationEnd={onFadeInAnimationEnd}
|
|
||||||
>
|
>
|
||||||
{/* Background image element */}
|
<CanvasBackground
|
||||||
{backgroundImageUrl && !backgroundVideoUrl && (
|
backgroundImageUrl={backgroundImageUrl}
|
||||||
<div className='absolute inset-0 pointer-events-none'>
|
backgroundVideoUrl={backgroundVideoUrl}
|
||||||
{backgroundImageUrl.startsWith('blob:') ? (
|
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
||||||
<img
|
isSwitching={pageSwitch.isSwitching}
|
||||||
key={backgroundImageUrl}
|
isNewBgReady={pageSwitch.isNewBgReady}
|
||||||
src={backgroundImageUrl}
|
isFadingIn={isFadingIn}
|
||||||
alt=''
|
onBackgroundReady={() => {
|
||||||
className='absolute inset-0 w-full h-full object-contain'
|
setIsBackgroundReady(true);
|
||||||
onLoad={() => {
|
pageSwitch.markBackgroundReady();
|
||||||
setIsBackgroundReady(true);
|
}}
|
||||||
pageSwitch.markBackgroundReady();
|
videoAutoplay={videoAutoplay}
|
||||||
}}
|
videoLoop={videoLoop}
|
||||||
onError={() => {
|
videoMuted={videoMuted}
|
||||||
setIsBackgroundReady(true);
|
videoStartTime={videoStartTime}
|
||||||
pageSwitch.markBackgroundReady();
|
videoEndTime={videoEndTime}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
|
||||||
key={backgroundImageUrl}
|
|
||||||
src={backgroundImageUrl}
|
|
||||||
alt=''
|
|
||||||
fill
|
|
||||||
sizes='100vw'
|
|
||||||
className='object-contain'
|
|
||||||
priority
|
|
||||||
unoptimized
|
|
||||||
onLoad={() => {
|
|
||||||
setIsBackgroundReady(true);
|
|
||||||
pageSwitch.markBackgroundReady();
|
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
setIsBackgroundReady(true);
|
|
||||||
pageSwitch.markBackgroundReady();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Background video */}
|
|
||||||
{backgroundVideoUrl && (
|
|
||||||
<video
|
|
||||||
ref={bgVideoRef}
|
|
||||||
key={backgroundVideoUrl}
|
|
||||||
className='absolute inset-0 h-full w-full object-contain'
|
|
||||||
src={backgroundVideoUrl}
|
|
||||||
autoPlay={videoAutoplay}
|
|
||||||
loop={useNativeLoop}
|
|
||||||
muted={videoMuted}
|
|
||||||
playsInline
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* End page background wrapper */}
|
{/* End page background wrapper */}
|
||||||
|
|
||||||
{/* 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 using CSS transitions. */}
|
Fades in together with background. */}
|
||||||
<div
|
<div
|
||||||
data-testid='page-elements-wrapper'
|
data-testid='page-elements-wrapper'
|
||||||
className={`absolute inset-0 z-[46] ${getCrossfadeInClasses()}`}
|
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||||
>
|
>
|
||||||
{pageElements.map((element: CanvasElement) => (
|
{pageElements.map((element: CanvasElement) => (
|
||||||
<RuntimeElement
|
<RuntimeElement
|
||||||
|
|||||||
@ -108,76 +108,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
/* Crossfade animation classes - GPU accelerated for all browsers */
|
||||||
CROSSFADE TRANSITION SYSTEM
|
/* Duration controlled by --crossfade-duration CSS variable (single source of truth) */
|
||||||
|
|
||||||
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);
|
||||||
-webkit-animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
|
/* Full animation property for maximum browser compatibility */
|
||||||
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-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);
|
||||||
-webkit-animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
|
/* Full animation property for maximum browser compatibility */
|
||||||
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-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 */
|
||||||
@ -186,6 +166,44 @@
|
|||||||
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition-based crossfade (Safari-optimized alternative to animations) */
|
||||||
|
/* Use this for better Safari stability - transitions don't have the "snap" issue
|
||||||
|
when state changes because they interpolate between current and target values */
|
||||||
|
.crossfade-transition {
|
||||||
|
transition: opacity var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
-webkit-transition: opacity var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
will-change: opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safari-specific optimization for transitions */
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.crossfade-transition {
|
||||||
|
-webkit-perspective: 1000px;
|
||||||
|
perspective: 1000px;
|
||||||
|
-webkit-transform-style: preserve-3d;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,12 +71,6 @@ 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;
|
||||||
@ -84,12 +78,8 @@ 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;
|
||||||
}
|
}
|
||||||
@ -132,8 +122,6 @@ 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);
|
||||||
@ -142,8 +130,11 @@ 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);
|
// Track fadeIn config in ref to avoid stale closure issues
|
||||||
|
// This allows us to read current values without adding fadeIn to useLayoutEffect deps
|
||||||
|
const fadeInRef = useRef(fadeIn);
|
||||||
|
fadeInRef.current = fadeIn;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset fade-out state before starting a new transition.
|
* Reset fade-out state before starting a new transition.
|
||||||
@ -158,21 +149,16 @@ 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, onTransitionEnd, or timer fallback.
|
* Called either by onAnimationEnd or by timer fallback.
|
||||||
* Uses ref to prevent double-completion.
|
* Uses ref to prevent double-completion.
|
||||||
*/
|
*/
|
||||||
const completeFadeIn = useCallback(() => {
|
const completeFadeIn = useCallback(() => {
|
||||||
@ -184,12 +170,7 @@ 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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -207,19 +188,6 @@ 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.
|
||||||
@ -318,18 +286,23 @@ 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:
|
||||||
* - Uses two-phase CSS transitions (more reliable in Safari than animations)
|
* - Sets up JS timer fallback for Safari (unreliable onAnimationEnd)
|
||||||
* - Phase 1 (starting): Apply initial opacity
|
* - Chrome/Firefox rely on CSS onAnimationEnd event
|
||||||
* - Phase 2 (running): After one frame, apply final opacity - CSS transition animates
|
*
|
||||||
* - Fallback timer ensures completion even if events don't fire
|
* NOTE: We use fadeInRef to read current fadeIn config without adding it to deps.
|
||||||
|
* This prevents the effect from re-running on every render when the caller
|
||||||
|
* creates fadeIn config inline (which would reset wasSwitchingRef incorrectly).
|
||||||
*/
|
*/
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!fadeIn) {
|
// Read from ref to get latest value without triggering re-runs
|
||||||
|
const currentFadeIn = fadeInRef.current;
|
||||||
|
|
||||||
|
// Skip crossfade logic if fadeIn config was not provided
|
||||||
|
if (!currentFadeIn) {
|
||||||
wasSwitchingRef.current = pageSwitch.isSwitching;
|
wasSwitchingRef.current = pageSwitch.isSwitching;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasActiveTransition } = fadeIn;
|
|
||||||
const justStartedSwitching =
|
const justStartedSwitching =
|
||||||
pageSwitch.isSwitching && !wasSwitchingRef.current;
|
pageSwitch.isSwitching && !wasSwitchingRef.current;
|
||||||
|
|
||||||
@ -337,59 +310,43 @@ export function useBackgroundTransition({
|
|||||||
|
|
||||||
// Only start crossfade for NON-transition navigation
|
// Only start crossfade for NON-transition navigation
|
||||||
// Transitions use video overlay - no fade needed
|
// Transitions use video overlay - no fade needed
|
||||||
if (justStartedSwitching && !hasActiveTransition) {
|
if (justStartedSwitching && !currentFadeIn.hasActiveTransition) {
|
||||||
// Reset completion flag for new animation
|
// Reset completion flag for new animation
|
||||||
fadeInCompletedRef.current = false;
|
fadeInCompletedRef.current = false;
|
||||||
|
|
||||||
// Clear any existing timers/rafs
|
// Clear any existing timer
|
||||||
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);
|
||||||
|
|
||||||
// Two-phase approach for Safari:
|
// Safari/Firefox fallback: Use JS timer as backup since onAnimationEnd
|
||||||
// 1. First, set 'starting' phase with initial opacity
|
// can be unreliable or fire on wrong animations.
|
||||||
// 2. After browser paints, set 'running' phase - CSS transition animates
|
// Timer is slightly longer than CSS duration to let CSS complete first.
|
||||||
setCrossfadePhase('starting');
|
// Chrome typically fires onAnimationEnd reliably, but timer is harmless backup.
|
||||||
|
|
||||||
// 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 buffer: Safari needs more time, Chrome/Firefox less
|
// Add 50ms buffer for Safari's animation timing variance
|
||||||
const bufferMs = isSafari() ? 150 : 100;
|
const bufferMs = isSafari() ? 100 : 50;
|
||||||
|
|
||||||
fadeInTimerRef.current = setTimeout(() => {
|
fadeInTimerRef.current = setTimeout(() => {
|
||||||
fadeInTimerRef.current = null;
|
fadeInTimerRef.current = null;
|
||||||
completeFadeIn();
|
completeFadeIn();
|
||||||
}, duration + bufferMs);
|
}, duration + bufferMs);
|
||||||
}
|
}
|
||||||
}, [pageSwitch.isSwitching, fadeIn, completeFadeIn]);
|
// NOTE: fadeIn intentionally NOT in deps - we read from fadeInRef instead
|
||||||
|
// to avoid re-running when inline fadeIn object is recreated
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pageSwitch.isSwitching, completeFadeIn]);
|
||||||
|
|
||||||
// Cleanup timer and RAF on unmount
|
// Cleanup timer 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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -397,9 +354,7 @@ export function useBackgroundTransition({
|
|||||||
isOverlayFadingOut,
|
isOverlayFadingOut,
|
||||||
resetFadeOut,
|
resetFadeOut,
|
||||||
isFadingIn,
|
isFadingIn,
|
||||||
crossfadePhase,
|
|
||||||
onFadeInAnimationEnd,
|
onFadeInAnimationEnd,
|
||||||
onTransitionEnd,
|
|
||||||
resetFadeIn,
|
resetFadeIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,6 +103,52 @@ export interface UsePageSwitchResult {
|
|||||||
clearPreviousBackground: () => void;
|
clearPreviousBackground: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode an image from URL to ensure it's ready for display.
|
||||||
|
* Used for blob URLs that are already loaded but need decoding.
|
||||||
|
* Returns a promise that resolves when the image is decoded.
|
||||||
|
*
|
||||||
|
* Safari-specific: waits extra frame after decode to ensure pixels are painted.
|
||||||
|
*/
|
||||||
|
const decodeImage = (url: string): Promise<void> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!url) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new window.Image();
|
||||||
|
const safariMode = isSafari();
|
||||||
|
|
||||||
|
const onReady = () => {
|
||||||
|
if (safariMode) {
|
||||||
|
scheduleAfterPaintSafari(() => resolve());
|
||||||
|
} else {
|
||||||
|
// For non-Safari, wait one paint frame after decode
|
||||||
|
scheduleAfterPaint(() => resolve());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
if (typeof img.decode === 'function') {
|
||||||
|
img
|
||||||
|
.decode()
|
||||||
|
.then(onReady)
|
||||||
|
.catch(onReady); // Resolve even on decode error
|
||||||
|
} else {
|
||||||
|
onReady();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
// Resolve even on error to not block navigation
|
||||||
|
onReady();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and decode an image with presigned URL fallback.
|
* Load and decode an image with presigned URL fallback.
|
||||||
* Returns the URL that successfully loaded.
|
* Returns the URL that successfully loaded.
|
||||||
@ -396,6 +442,16 @@ export function usePageSwitch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Resolve URLs BEFORE setting isSwitching
|
||||||
|
// This ensures the new background is ready when the crossfade animation starts.
|
||||||
|
// If we set isSwitching first, the animation begins with opacity:0 but the new
|
||||||
|
// content isn't ready yet, causing a shorter/faster perceived animation.
|
||||||
|
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
|
||||||
|
resolveToDisplayUrl(targetPage.background_image_url),
|
||||||
|
resolveMediaUrl(targetPage.background_video_url),
|
||||||
|
resolveMediaUrl(targetPage.background_audio_url),
|
||||||
|
]);
|
||||||
|
|
||||||
// Save current backgrounds as previous for overlay (use refs to avoid dependencies)
|
// Save current backgrounds as previous for overlay (use refs to avoid dependencies)
|
||||||
if (currentBgImageUrlRef.current) {
|
if (currentBgImageUrlRef.current) {
|
||||||
setPreviousBgImageUrl(currentBgImageUrlRef.current);
|
setPreviousBgImageUrl(currentBgImageUrlRef.current);
|
||||||
@ -404,27 +460,24 @@ export function usePageSwitch(
|
|||||||
setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
|
setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSwitching(true);
|
// Set new backgrounds BEFORE triggering animation
|
||||||
setIsNewBgReady(false);
|
|
||||||
|
|
||||||
// Resolve URLs in parallel, preferring cached blob URLs
|
|
||||||
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
|
|
||||||
resolveToDisplayUrl(targetPage.background_image_url),
|
|
||||||
resolveMediaUrl(targetPage.background_video_url),
|
|
||||||
resolveMediaUrl(targetPage.background_audio_url),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Set new backgrounds
|
|
||||||
setCurrentBgImageUrl(imageUrl);
|
setCurrentBgImageUrl(imageUrl);
|
||||||
setCurrentBgVideoUrl(videoUrl);
|
setCurrentBgVideoUrl(videoUrl);
|
||||||
setCurrentBgAudioUrl(audioUrl);
|
setCurrentBgAudioUrl(audioUrl);
|
||||||
|
|
||||||
|
// NOW trigger the crossfade animation
|
||||||
|
// The new background is already set, so animation shows the actual crossfade
|
||||||
|
setIsSwitching(true);
|
||||||
|
setIsNewBgReady(false);
|
||||||
|
|
||||||
// Notify caller that backgrounds are set
|
// Notify caller that backgrounds are set
|
||||||
onSwitched?.();
|
onSwitched?.();
|
||||||
|
|
||||||
// For blob URLs, mark ready after paint (Safari-compatible)
|
// For blob URLs, decode the image before marking ready
|
||||||
|
// This ensures the image is actually decoded and ready for display,
|
||||||
|
// matching the constructor behavior where images have a render cycle head start
|
||||||
if (imageUrl.startsWith('blob:') || !imageUrl) {
|
if (imageUrl.startsWith('blob:') || !imageUrl) {
|
||||||
scheduleAfterPaint(() => {
|
decodeImage(imageUrl).then(() => {
|
||||||
setIsNewBgReady(true);
|
setIsNewBgReady(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user