made scoss-browser adaptivity
This commit is contained in:
parent
d55453a42d
commit
f445066706
@ -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.
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<Set<string>>(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],
|
||||
);
|
||||
|
||||
@ -169,6 +169,7 @@ export function useTransitionPlayback(
|
||||
const lastLoadedBlobUrlRef = useRef<string | null>(null);
|
||||
const lastLoadedSourceUrlRef = useRef<string | null>(null);
|
||||
const didTryFallbackRef = useRef(false);
|
||||
const didTryDecodeRetryRef = useRef(false);
|
||||
const currentPlayableUrlRef = useRef<string | null>(null);
|
||||
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
|
||||
89
frontend/src/lib/browserUtils.ts
Normal file
89
frontend/src/lib/browserUtils.ts
Normal file
@ -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;
|
||||
};
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user