From f445066706fd851752efa3d5358caba97b312ac4 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 13 Apr 2026 10:50:05 +0400 Subject: [PATCH] made scoss-browser adaptivity --- frontend/src/config/canvas.config.ts | 6 +- frontend/src/css/main.css | 152 +++++++++++++++++--- frontend/src/hooks/usePageSwitch.ts | 23 +-- frontend/src/hooks/useTransitionPlayback.ts | 12 ++ frontend/src/lib/browserUtils.ts | 89 ++++++++++++ frontend/tailwind.config.js | 4 +- 6 files changed, 258 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/browserUtils.ts diff --git a/frontend/src/config/canvas.config.ts b/frontend/src/config/canvas.config.ts index cdd0ece..b8104c0 100644 --- a/frontend/src/config/canvas.config.ts +++ b/frontend/src/config/canvas.config.ts @@ -46,9 +46,13 @@ export const CANVAS_CONFIG = { /** * Fade-out duration for transition video overlay (ms). * Applied after transition video finishes playing. - * Note: Crossfade duration is controlled by CSS in main.css (.animate-crossfade-in/out) */ fadeOutDurationMs: 300, + /** + * Crossfade animation duration for page backgrounds (ms). + * Used for smooth transitions between pages. + */ + crossfadeDurationMs: 300, /** * CSS easing function for fade animations. */ diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index 0ba12c4..ef01248 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -34,96 +34,214 @@ @apply bg-transparent border border-blue-600 text-blue-600 !important; } -/* Page crossfade animation keyframes */ -@keyframes page-crossfade-in { +/* Page crossfade animation keyframes - Safari optimized */ +@-webkit-keyframes page-crossfade-in { from { opacity: 0; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); } to { opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} + +@keyframes page-crossfade-in { + from { + opacity: 0; + transform: translate3d(0, 0, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@-webkit-keyframes page-crossfade-out { + from { + opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + to { + opacity: 0; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); } } @keyframes page-crossfade-out { from { opacity: 1; + transform: translate3d(0, 0, 0); } to { opacity: 0; + transform: translate3d(0, 0, 0); } } -/* Crossfade animation classes */ +/* Crossfade animation classes - GPU accelerated for Safari */ .animate-crossfade-in { - animation: page-crossfade-in 500ms ease-out forwards; + -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; } .animate-crossfade-out { - animation: page-crossfade-out 500ms ease-out forwards; + -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; } -/* Element appear animation keyframes */ -@keyframes element-fade-in { +/* Element appear animation keyframes - Safari optimized */ +@-webkit-keyframes element-fade-in { from { opacity: 0; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); } to { opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} + +@keyframes element-fade-in { + from { + opacity: 0; + transform: translate3d(0, 0, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@-webkit-keyframes element-slide-up { + from { + opacity: 0; + -webkit-transform: translate3d(0, 20px, 0); + transform: translate3d(0, 20px, 0); + } + to { + opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); } } @keyframes element-slide-up { from { opacity: 0; - transform: translateY(20px); + transform: translate3d(0, 20px, 0); } to { opacity: 1; - transform: translateY(0); + transform: translate3d(0, 0, 0); + } +} + +@-webkit-keyframes element-slide-down { + from { + opacity: 0; + -webkit-transform: translate3d(0, -20px, 0); + transform: translate3d(0, -20px, 0); + } + to { + opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); } } @keyframes element-slide-down { from { opacity: 0; - transform: translateY(-20px); + transform: translate3d(0, -20px, 0); } to { opacity: 1; - transform: translateY(0); + transform: translate3d(0, 0, 0); + } +} + +@-webkit-keyframes element-slide-left { + from { + opacity: 0; + -webkit-transform: translate3d(20px, 0, 0); + transform: translate3d(20px, 0, 0); + } + to { + opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); } } @keyframes element-slide-left { from { opacity: 0; - transform: translateX(20px); + transform: translate3d(20px, 0, 0); } to { opacity: 1; - transform: translateX(0); + transform: translate3d(0, 0, 0); + } +} + +@-webkit-keyframes element-slide-right { + from { + opacity: 0; + -webkit-transform: translate3d(-20px, 0, 0); + transform: translate3d(-20px, 0, 0); + } + to { + opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); } } @keyframes element-slide-right { from { opacity: 0; - transform: translateX(-20px); + transform: translate3d(-20px, 0, 0); } to { opacity: 1; - transform: translateX(0); + transform: translate3d(0, 0, 0); + } +} + +@-webkit-keyframes element-scale-in { + from { + opacity: 0; + -webkit-transform: scale3d(0.8, 0.8, 1); + transform: scale3d(0.8, 0.8, 1); + } + to { + opacity: 1; + -webkit-transform: scale3d(1, 1, 1); + transform: scale3d(1, 1, 1); } } @keyframes element-scale-in { from { opacity: 0; - transform: scale(0.8); + transform: scale3d(0.8, 0.8, 1); } to { opacity: 1; - transform: scale(1); + transform: scale3d(1, 1, 1); } } diff --git a/frontend/src/hooks/usePageSwitch.ts b/frontend/src/hooks/usePageSwitch.ts index 242ee64..55f0a75 100644 --- a/frontend/src/hooks/usePageSwitch.ts +++ b/frontend/src/hooks/usePageSwitch.ts @@ -21,6 +21,7 @@ import { buildProxyUrl, } from '../lib/assetUrl'; import { logger } from '../lib/logger'; +import { scheduleAfterPaint } from '../lib/browserUtils'; /** * Minimal page interface for page switching @@ -206,7 +207,8 @@ export function usePageSwitch( // Transition state const [isSwitching, setIsSwitching] = useState(false); - const [isNewBgReady, setIsNewBgReady] = useState(true); + // Initialize as false to trigger fade-in animation on initial page load + const [isNewBgReady, setIsNewBgReady] = useState(false); // Track blob URLs we created so we can revoke them const createdBlobUrlsRef = useRef>(new Set()); @@ -401,12 +403,10 @@ export function usePageSwitch( // Notify caller that backgrounds are set onSwitched?.(); - // For blob URLs, mark ready immediately (local data) + // For blob URLs, mark ready after paint (Safari-compatible) if (imageUrl.startsWith('blob:') || !imageUrl) { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - setIsNewBgReady(true); - }); + scheduleAfterPaint(() => { + setIsNewBgReady(true); }); } // For remote images, wait for Image onLoad (caller should use markBackgroundReady) @@ -415,7 +415,8 @@ export function usePageSwitch( ); /** - * Directly set backgrounds without transition overlay + * Directly set backgrounds without transition overlay. + * Used for initial page load with fade-in animation. */ const setBackgroundsDirectly = useCallback( (imageUrl: string, videoUrl: string, audioUrl: string) => { @@ -430,7 +431,13 @@ export function usePageSwitch( setCurrentBgAudioUrl(audioUrl); setPreviousBgImageUrl(''); setIsSwitching(false); - setIsNewBgReady(true); + + // Trigger fade-in animation: set not-ready then ready after paint + // This ensures the CSS animation triggers on initial page load + setIsNewBgReady(false); + scheduleAfterPaint(() => { + setIsNewBgReady(true); + }); }, [revokeBlobUrl], ); diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index 5b29240..2754f56 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -169,6 +169,7 @@ export function useTransitionPlayback( const lastLoadedBlobUrlRef = useRef(null); const lastLoadedSourceUrlRef = useRef(null); const didTryFallbackRef = useRef(false); + const didTryDecodeRetryRef = useRef(false); const currentPlayableUrlRef = useRef(null); const startWatchdogTimerRef = useRef | null>( null, @@ -351,6 +352,7 @@ export function useTransitionPlayback( didFinishRef.current = false; didStartPlaybackRef.current = false; didTryFallbackRef.current = false; + didTryDecodeRetryRef.current = false; currentPlayableUrlRef.current = null; setPhase('preparing'); @@ -720,6 +722,16 @@ export function useTransitionPlayback( if (didFinishRef.current) return; logIssue('video-error'); + // Safari video decode error recovery (MEDIA_ERR_DECODE = 3) + const errorCode = video.error?.code; + if (errorCode === 3 && !didTryDecodeRetryRef.current) { + logger.info('Safari video decode error, attempting reload'); + didTryDecodeRetryRef.current = true; + video.load(); + attemptPlay(); + return; + } + // Check if this is a presigned URL failure (likely CORS) const currentUrl = currentPlayableUrlRef.current; if ( diff --git a/frontend/src/lib/browserUtils.ts b/frontend/src/lib/browserUtils.ts new file mode 100644 index 0000000..8da866c --- /dev/null +++ b/frontend/src/lib/browserUtils.ts @@ -0,0 +1,89 @@ +/** + * Browser Utilities + * + * Centralized browser detection and cross-browser timing utilities. + * Follows patterns from useNetworkAware.ts for vendor-prefixed API detection. + */ + +/** + * 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); +}; + +/** + * 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); +}; + +/** + * Schedule a callback to run after the next browser paint. + * Safari-compatible timing that ensures state updates occur on the correct paint cycle. + * + * 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. + * + * @param callback - Function to run after paint + */ +export const scheduleAfterPaint = (callback: () => void): void => { + if (typeof window === 'undefined') { + callback(); + return; + } + + // 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); +}; + +/** + * Schedule a callback with explicit frame delay. + * Useful when you need to ensure a specific number of frames have passed. + * + * @param callback - Function to run after delay + * @param frames - Number of frames to wait (default: 1) + * @returns Cleanup function to cancel the scheduled callback + */ +export const scheduleAfterFrames = ( + callback: () => void, + frames = 1, +): (() => void) => { + if (typeof window === 'undefined') { + callback(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + } + + let cancelled = false; + let frameCount = 0; + + const tick = () => { + if (cancelled) return; + frameCount++; + if (frameCount >= frames) { + callback(); + } else { + requestAnimationFrame(tick); + } + }; + + requestAnimationFrame(tick); + + return () => { + cancelled = true; + }; +}; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 4af698f..e840cb9 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -42,8 +42,8 @@ module.exports = { } }, animation: { - 'fade-out': 'fade-out 250ms ease-in-out', - 'fade-in': 'fade-in 250ms ease-in-out' + 'fade-out': 'fade-out 300ms ease-out', + 'fade-in': 'fade-in 300ms ease-out' }, colors: { dark: {