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).
|
* Fade-out duration for transition video overlay (ms).
|
||||||
* Applied after transition video finishes playing.
|
* Applied after transition video finishes playing.
|
||||||
* Note: Crossfade duration is controlled by CSS in main.css (.animate-crossfade-in/out)
|
|
||||||
*/
|
*/
|
||||||
fadeOutDurationMs: 300,
|
fadeOutDurationMs: 300,
|
||||||
|
/**
|
||||||
|
* Crossfade animation duration for page backgrounds (ms).
|
||||||
|
* Used for smooth transitions between pages.
|
||||||
|
*/
|
||||||
|
crossfadeDurationMs: 300,
|
||||||
/**
|
/**
|
||||||
* CSS easing function for fade animations.
|
* CSS easing function for fade animations.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -34,96 +34,214 @@
|
|||||||
@apply bg-transparent border border-blue-600 text-blue-600 !important;
|
@apply bg-transparent border border-blue-600 text-blue-600 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page crossfade animation keyframes */
|
/* Page crossfade animation keyframes - Safari optimized */
|
||||||
@keyframes page-crossfade-in {
|
@-webkit-keyframes page-crossfade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 {
|
@keyframes page-crossfade-out {
|
||||||
from {
|
from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Crossfade animation classes */
|
/* Crossfade animation classes - GPU accelerated for Safari */
|
||||||
.animate-crossfade-in {
|
.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 {
|
.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 */
|
/* Element appear animation keyframes - Safari optimized */
|
||||||
@keyframes element-fade-in {
|
@-webkit-keyframes element-fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 {
|
@keyframes element-slide-up {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translate3d(0, 20px, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 {
|
@keyframes element-slide-down {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-20px);
|
transform: translate3d(0, -20px, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 {
|
@keyframes element-slide-left {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(20px);
|
transform: translate3d(20px, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 {
|
@keyframes element-slide-right {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-20px);
|
transform: translate3d(-20px, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 {
|
@keyframes element-scale-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.8);
|
transform: scale3d(0.8, 0.8, 1);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale3d(1, 1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
buildProxyUrl,
|
buildProxyUrl,
|
||||||
} from '../lib/assetUrl';
|
} from '../lib/assetUrl';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { scheduleAfterPaint } from '../lib/browserUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal page interface for page switching
|
* Minimal page interface for page switching
|
||||||
@ -206,7 +207,8 @@ export function usePageSwitch(
|
|||||||
|
|
||||||
// Transition state
|
// Transition state
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
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
|
// Track blob URLs we created so we can revoke them
|
||||||
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
|
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
|
||||||
@ -401,12 +403,10 @@ export function usePageSwitch(
|
|||||||
// Notify caller that backgrounds are set
|
// Notify caller that backgrounds are set
|
||||||
onSwitched?.();
|
onSwitched?.();
|
||||||
|
|
||||||
// For blob URLs, mark ready immediately (local data)
|
// For blob URLs, mark ready after paint (Safari-compatible)
|
||||||
if (imageUrl.startsWith('blob:') || !imageUrl) {
|
if (imageUrl.startsWith('blob:') || !imageUrl) {
|
||||||
requestAnimationFrame(() => {
|
scheduleAfterPaint(() => {
|
||||||
requestAnimationFrame(() => {
|
setIsNewBgReady(true);
|
||||||
setIsNewBgReady(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// For remote images, wait for Image onLoad (caller should use markBackgroundReady)
|
// 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(
|
const setBackgroundsDirectly = useCallback(
|
||||||
(imageUrl: string, videoUrl: string, audioUrl: string) => {
|
(imageUrl: string, videoUrl: string, audioUrl: string) => {
|
||||||
@ -430,7 +431,13 @@ export function usePageSwitch(
|
|||||||
setCurrentBgAudioUrl(audioUrl);
|
setCurrentBgAudioUrl(audioUrl);
|
||||||
setPreviousBgImageUrl('');
|
setPreviousBgImageUrl('');
|
||||||
setIsSwitching(false);
|
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],
|
[revokeBlobUrl],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -169,6 +169,7 @@ export function useTransitionPlayback(
|
|||||||
const lastLoadedBlobUrlRef = useRef<string | null>(null);
|
const lastLoadedBlobUrlRef = useRef<string | null>(null);
|
||||||
const lastLoadedSourceUrlRef = useRef<string | null>(null);
|
const lastLoadedSourceUrlRef = useRef<string | null>(null);
|
||||||
const didTryFallbackRef = useRef(false);
|
const didTryFallbackRef = useRef(false);
|
||||||
|
const didTryDecodeRetryRef = useRef(false);
|
||||||
const currentPlayableUrlRef = useRef<string | null>(null);
|
const currentPlayableUrlRef = useRef<string | null>(null);
|
||||||
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
@ -351,6 +352,7 @@ export function useTransitionPlayback(
|
|||||||
didFinishRef.current = false;
|
didFinishRef.current = false;
|
||||||
didStartPlaybackRef.current = false;
|
didStartPlaybackRef.current = false;
|
||||||
didTryFallbackRef.current = false;
|
didTryFallbackRef.current = false;
|
||||||
|
didTryDecodeRetryRef.current = false;
|
||||||
currentPlayableUrlRef.current = null;
|
currentPlayableUrlRef.current = null;
|
||||||
setPhase('preparing');
|
setPhase('preparing');
|
||||||
|
|
||||||
@ -720,6 +722,16 @@ export function useTransitionPlayback(
|
|||||||
if (didFinishRef.current) return;
|
if (didFinishRef.current) return;
|
||||||
logIssue('video-error');
|
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)
|
// Check if this is a presigned URL failure (likely CORS)
|
||||||
const currentUrl = currentPlayableUrlRef.current;
|
const currentUrl = currentPlayableUrlRef.current;
|
||||||
if (
|
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: {
|
animation: {
|
||||||
'fade-out': 'fade-out 250ms ease-in-out',
|
'fade-out': 'fade-out 300ms ease-out',
|
||||||
'fade-in': 'fade-in 250ms ease-in-out'
|
'fade-in': 'fade-in 300ms ease-out'
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
dark: {
|
dark: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user