39948-vm/frontend/src/hooks/useTransitionPlayback.ts

776 lines
22 KiB
TypeScript

/**
* useTransitionPlayback Hook
*
* Handles video transition playback between pages.
* For back navigation, uses pre-reversed video generated by the backend.
* No frame-stepping - all transitions play forward.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from 'react';
import axios from 'axios';
import { logger } from '../lib/logger';
import {
markPresignedUrlFailed,
resolveAssetPlaybackUrl,
isPresignedUrl,
buildProxyUrl,
extractStoragePath,
} from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils';
export type ReverseMode = 'none' | 'separate';
export interface TransitionConfig {
videoUrl: string;
storageKey?: string; // Raw storage path for cache lookup
reverseMode: ReverseMode;
reverseVideoUrl?: string;
durationSec?: number;
targetPageId?: string;
displayName?: string;
/** Whether this is a back navigation (for history management) */
isBack?: boolean;
}
export interface UseTransitionPlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>;
transition: TransitionConfig | null;
/** Called when playback completes. isBack indicates if this was a back navigation. */
onComplete: (targetPageId?: string, isBack?: boolean) => void;
onError?: (reason: string) => void;
timeouts?: {
playbackStartMs?: number;
durationBufferMs?: number;
hardTimeoutMs?: number;
};
features?: {
useBlobUrl?: boolean;
preDecodeImages?: boolean;
getTargetPageImages?: () => string[];
};
preload?: {
preloadedUrls?: Set<string>;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
getReadyBlobUrl?: (url: string) => string | null;
};
}
export type PlaybackPhase =
| 'idle'
| 'preparing'
| 'playing'
| 'finishing'
| 'completed';
export interface UseTransitionPlaybackResult {
phase: PlaybackPhase;
isBuffering: boolean;
isReversing: boolean;
cancel: () => void;
forceComplete: () => void;
}
const DEFAULT_TIMEOUTS = {
playbackStartMs: 3000,
durationBufferMs: 200,
hardTimeoutMs: 45000,
};
/**
* Get browser-specific finish offset for transition videos.
* This is a backup timer - requestVideoFrameCallback is primary for modern browsers.
*
* @returns Finish offset in milliseconds before video end
*/
const getFinishBeforeEndMs = (): number => {
if (isSafari()) {
return 350;
}
if (isFirefox()) {
return 300;
}
return 300;
};
function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean {
if (useBlobUrlOption === false) return false;
if (useBlobUrlOption === true) return true;
try {
const parsedUrl = new URL(url, window.location.origin);
const isSameOrigin = parsedUrl.origin === window.location.origin;
if (!isSameOrigin) return false;
return (
parsedUrl.pathname === '/api/file/download' ||
parsedUrl.pathname === '/file/download'
);
} catch {
return false;
}
}
async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
if (urls.length === 0) return;
const decodePromises = urls.map(
(url) =>
new Promise<void>((resolve) => {
const img = new Image();
img.src = url;
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
}),
);
await Promise.race([
Promise.all(decodePromises),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
}
export function useTransitionPlayback(
options: UseTransitionPlaybackOptions,
): UseTransitionPlaybackResult {
const {
videoRef,
transition,
onComplete,
onError,
timeouts: customTimeouts,
features,
preload,
} = options;
const playbackStartMs =
customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs;
const hardTimeoutMs =
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
const [phase, setPhase] = useState<PlaybackPhase>('idle');
const [isVideoReady, setIsVideoReady] = useState(false);
const didFinishRef = useRef(false);
const didStartPlaybackRef = useRef(false);
const activeSourceUrlRef = useRef<string | null>(null);
const lastLoadedBlobUrlRef = useRef<string | null>(null);
const lastLoadedSourceUrlRef = useRef<string | null>(null);
const didTryDecodeRetryRef = useRef(false);
const currentPlayableUrlRef = useRef<string | null>(null);
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const finishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hardTimeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const onCompleteRef = useRef(onComplete);
const onErrorRef = useRef(onError);
const transitionRef = useRef(transition);
const featuresRef = useRef(features);
const preloadRef = useRef(preload);
// Determine which video URL to use:
// For back navigation with a reversed video, use reverseVideoUrl
// Otherwise, use the original videoUrl
const sourceUrl = useMemo(() => {
if (!transition) return '';
if (transition.isBack) {
return transition.reverseVideoUrl || '';
}
return transition.videoUrl;
}, [transition]);
// Storage key for cache lookup - use reversed video key for back navigation
const storageKey = useMemo(() => {
if (!transition) return undefined;
if (transition.isBack) {
return transition.reverseVideoUrl || undefined;
}
return transition.storageKey;
}, [transition]);
const clearTimers = useCallback(() => {
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
if (finishTimerRef.current) {
clearTimeout(finishTimerRef.current);
finishTimerRef.current = null;
}
if (hardTimeoutTimerRef.current) {
clearTimeout(hardTimeoutTimerRef.current);
hardTimeoutTimerRef.current = null;
}
}, []);
const revokeBlobUrl = useCallback((force = false) => {
if (!force || !lastLoadedBlobUrlRef.current) return;
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
lastLoadedBlobUrlRef.current = null;
}, []);
const finishPlayback = useCallback(
async (reason: string) => {
if (didFinishRef.current) return;
didFinishRef.current = true;
activeSourceUrlRef.current = null;
clearTimers();
const video = videoRef.current;
if (video) {
// Just pause - don't seek. We've already stopped at a safe frame
// (triggered by rvfc/timeupdate well before any black frames).
// Seeking is async and can cause a flash before the seek completes.
video.pause();
}
const currentTransition = transitionRef.current;
const currentFeatures = featuresRef.current;
logger.info('Transition playback finished', {
reason,
displayName: currentTransition?.displayName,
targetPageId: currentTransition?.targetPageId,
});
setPhase('finishing');
if (
currentFeatures?.preDecodeImages &&
currentFeatures.getTargetPageImages &&
currentTransition?.targetPageId
) {
try {
const imageUrls = currentFeatures.getTargetPageImages();
await waitForImages(imageUrls);
} catch {
// Ignore pre-decode errors
}
}
setPhase('completed');
onCompleteRef.current(
currentTransition?.targetPageId,
currentTransition?.isBack,
);
},
[clearTimers, videoRef],
);
const handleError = useCallback(
(reason: string) => {
if (didFinishRef.current) return;
logger.error('Transition playback error', { reason });
onErrorRef.current?.(reason);
finishPlayback(reason);
},
[finishPlayback],
);
useEffect(() => {
onCompleteRef.current = onComplete;
onErrorRef.current = onError;
transitionRef.current = transition;
featuresRef.current = features;
preloadRef.current = preload;
});
const cancel = useCallback(() => {
if (phase === 'idle') return;
clearTimers();
didFinishRef.current = true;
setPhase('idle');
const video = videoRef.current;
if (video) {
video.pause();
video.removeAttribute('src');
video.load();
}
revokeBlobUrl(true);
}, [phase, clearTimers, videoRef, revokeBlobUrl]);
const forceComplete = useCallback(() => {
finishPlayback('forced');
}, [finishPlayback]);
useEffect(() => {
const video = videoRef.current;
const currentTransition = transitionRef.current;
if (!currentTransition || !video) {
return;
}
if (!sourceUrl) {
logger.info('No playable transition source, skipping playback', {
isBack: currentTransition.isBack,
targetPageId: currentTransition.targetPageId,
});
void finishPlayback('missing-source');
return;
}
// Include isBack in the key so same video can play forward or as reversed
const sourceKey = `${sourceUrl}|${currentTransition.isBack ? 'back' : 'forward'}`;
if (activeSourceUrlRef.current === sourceKey) {
logger.info('Skipping duplicate effect for same source', {
sourceUrl,
isBack: currentTransition.isBack,
});
return;
}
activeSourceUrlRef.current = sourceKey;
didFinishRef.current = false;
didStartPlaybackRef.current = false;
didTryDecodeRetryRef.current = false;
currentPlayableUrlRef.current = null;
setPhase('preparing');
const configuredDurationSec = Number(currentTransition.durationSec);
const getMediaErrorDetails = () => {
if (!video.error) return null;
const mediaError = video.error as MediaError & { message?: string };
return {
code: mediaError.code,
message: mediaError.message || '',
};
};
const logIssue = (reason: string, error?: unknown) => {
logger.error('Transition playback issue:', {
reason,
src: video.currentSrc || sourceUrl,
readyState: video.readyState,
networkState: video.networkState,
duration: video.duration,
configuredDurationSec,
isBack: currentTransition.isBack,
mediaError: getMediaErrorDetails(),
error: error instanceof Error ? error : { error },
});
};
const scheduleFinishByDuration = (durationSec: number) => {
if (
!Number.isFinite(durationSec) ||
durationSec <= 0 ||
finishTimerRef.current
) {
return;
}
// Use browser-specific offset to prevent black flash at video end
const finishBeforeEndMs = getFinishBeforeEndMs();
const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs);
finishTimerRef.current = setTimeout(
() => finishPlayback('duration-timer'),
finishMs,
);
};
const attemptPlay = () => {
video.play().catch((playError) => {
logIssue('play-failed', playError);
});
};
const resolvePlayableSource = async (): Promise<string> => {
const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl;
const currentStorageKey = storageKey;
// 1. Try storage key lookup first (most reliable for cache hits)
if (getReadyBlobUrl && currentStorageKey) {
const readyUrl = getReadyBlobUrl(currentStorageKey);
if (readyUrl) {
logger.info('Using ready blob URL from storage key', {
storageKey: currentStorageKey.slice(-50),
});
return readyUrl;
}
}
// 2. Try cached blob URL by storage key
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
if (getCachedBlobUrl && currentStorageKey) {
try {
const cachedBlobUrl = await getCachedBlobUrl(currentStorageKey);
if (cachedBlobUrl) {
logger.info('Using cached blob URL from storage key', {
storageKey: currentStorageKey.slice(-50),
});
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch {
// Fall through
}
}
// 3. Reuse cached blob URL if same source
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl
) {
logger.info('Reusing cached blob URL');
return lastLoadedBlobUrlRef.current;
}
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current !== sourceUrl
) {
revokeBlobUrl(true);
}
const needsBlobUrl = shouldLoadViaBlob(
sourceUrl,
featuresRef.current?.useBlobUrl,
);
if (!needsBlobUrl) {
return sourceUrl;
}
// 4. Try ready blob URL by resolved URL
if (getReadyBlobUrl) {
const readyUrl = getReadyBlobUrl(sourceUrl);
if (readyUrl) {
logger.info('Using ready blob URL from resolved URL', {
url: sourceUrl.slice(-50),
});
return readyUrl;
}
}
// 5. Try cached blob URL by resolved URL
if (getCachedBlobUrl) {
try {
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
if (cachedBlobUrl) {
logger.info('Using preloaded blob URL from cache', {
isBack: currentTransition.isBack,
});
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch (cacheError) {
logger.warn('Cache lookup failed, falling back to fetch', {
cacheError,
});
}
}
// 6. Fetch video as blob
logger.info('Fetching video as blob', {
isBack: currentTransition.isBack,
});
const freshUrl = currentStorageKey
? resolveAssetPlaybackUrl(currentStorageKey)
: sourceUrl;
const token =
typeof window !== 'undefined'
? localStorage.getItem('token') || ''
: '';
const fetchVideoAsBlob = async (url: string): Promise<string> => {
logger.info('Fetching video from URL', {
url: url.slice(0, 80),
isPresigned: isPresignedUrl(url),
});
const response = await axios.get(url, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
baseURL: '',
});
const blob = response.data as Blob;
if (currentStorageKey) {
const normalizedKey = extractStoragePath(currentStorageKey);
const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, {
assetType: 'transition',
});
lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return blobUrl;
}
const blobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
logger.info('Created blob URL for video (no caching)', {
blobUrl: blobUrl.substring(0, 50),
});
return blobUrl;
};
try {
return await fetchVideoAsBlob(freshUrl);
} catch (error) {
if (currentStorageKey && isPresignedUrl(freshUrl)) {
logger.info('Presigned URL failed, retrying with proxy', {
storageKey: currentStorageKey.slice(-40),
});
markPresignedUrlFailed(currentStorageKey);
const proxyUrl = buildProxyUrl(currentStorageKey);
return await fetchVideoAsBlob(proxyUrl);
}
throw error;
}
};
const loadAndPlay = async () => {
logger.info('loadAndPlay called', {
isBack: currentTransition.isBack,
sourceUrl,
});
setIsVideoReady(false); // Reset for new playback
didStartPlaybackRef.current = false;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
}
try {
const playableSourceUrl = await resolvePlayableSource();
if (didFinishRef.current) return;
video.pause();
video.src = playableSourceUrl;
video.currentTime = 0;
video.load();
lastLoadedSourceUrlRef.current = playableSourceUrl;
currentPlayableUrlRef.current = playableSourceUrl;
attemptPlay();
startWatchdogTimerRef.current = setTimeout(() => {
if (didStartPlaybackRef.current || didFinishRef.current) return;
logIssue('playback-start-slow');
attemptPlay();
}, playbackStartMs);
} catch (error) {
logIssue('source-prepare-failed', error);
handleError('source-prepare-failed');
}
};
const onLoadedMetadata = () => {
if (didFinishRef.current) return;
video.currentTime = 0;
attemptPlay();
};
const onCanPlay = () => {
if (didFinishRef.current) return;
attemptPlay();
};
const onPlaying = () => {
logger.info('onPlaying fired', {
isBack: currentTransition.isBack,
didStartPlayback: didStartPlaybackRef.current,
didFinish: didFinishRef.current,
});
if (didFinishRef.current) return;
didStartPlaybackRef.current = true;
setPhase('playing');
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
// This fires when a frame is actually sent to the compositor - no guesswork
if ('requestVideoFrameCallback' in video) {
const rvfc = video.requestVideoFrameCallback.bind(video);
// First callback: frame is composited, safe to show overlay
rvfc((_now, _metadata) => {
if (!didFinishRef.current) {
setIsVideoReady(true);
}
// Monitor video position to finish before end (prevents END flash)
// Note: rvfc fires AFTER frame is composited, so we need extra buffer
const monitorEnd = (
_now2: number,
metadata: VideoFrameCallbackMetadata,
) => {
if (didFinishRef.current) return;
const duration = video.duration;
// Finish 300ms before end - gives margin for black/fade frames
// that some videos have in the last 100-200ms
if (
Number.isFinite(duration) &&
metadata.mediaTime >= duration - 0.3
) {
finishPlayback('rvfc-end');
return;
}
// Continue monitoring each frame
rvfc(monitorEnd);
};
rvfc(monitorEnd);
});
} else {
// Fallback for older browsers without requestVideoFrameCallback
scheduleAfterPaint(() => {
if (!didFinishRef.current) {
setIsVideoReady(true);
}
});
}
const mediaDurationSec = Number(video.duration);
const durationSec =
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
? configuredDurationSec
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
? mediaDurationSec
: NaN;
if (Number.isFinite(durationSec) && durationSec > 0) {
scheduleFinishByDuration(durationSec);
}
};
const onEnded = () => {
finishPlayback('ended');
};
// Backup handler for browsers without requestVideoFrameCallback
// Note: timeupdate fires infrequently (~250ms in Safari), so this is just a fallback
const onTimeUpdate = () => {
if (didFinishRef.current) return;
const duration = video.duration;
if (!Number.isFinite(duration)) return;
// Large buffer since timeupdate is infrequent
// Safari: 600ms, Others: 400ms
const safetyBuffer = isSafari() ? 0.6 : 0.4;
if (video.currentTime >= duration - safetyBuffer) {
finishPlayback('timeupdate-end');
}
};
const onVideoError = async () => {
if (didFinishRef.current) return;
logIssue('video-error');
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;
}
handleError('video-error');
};
const onAbort = () => {
if (didFinishRef.current) return;
logIssue('video-abort');
handleError('video-abort');
};
const onStalled = () => {
if (didFinishRef.current) return;
logIssue('video-stalled');
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying);
video.addEventListener('ended', onEnded);
video.addEventListener('timeupdate', onTimeUpdate);
video.addEventListener('error', onVideoError);
video.addEventListener('abort', onAbort);
video.addEventListener('stalled', onStalled);
hardTimeoutTimerRef.current = setTimeout(() => {
if (didFinishRef.current) return;
logIssue('hard-timeout');
handleError('hard-timeout');
}, hardTimeoutMs);
void loadAndPlay();
return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('playing', onPlaying);
video.removeEventListener('ended', onEnded);
video.removeEventListener('timeupdate', onTimeUpdate);
video.removeEventListener('error', onVideoError);
video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled);
clearTimers();
};
}, [
sourceUrl,
storageKey,
transition?.isBack,
videoRef,
playbackStartMs,
hardTimeoutMs,
clearTimers,
revokeBlobUrl,
finishPlayback,
handleError,
]);
useEffect(() => {
if (!transition) {
setPhase('idle');
setIsVideoReady(false);
activeSourceUrlRef.current = null;
didFinishRef.current = false;
didStartPlaybackRef.current = false;
}
}, [transition]);
return {
phase,
// Show buffering until video first frame is painted (prevents START black flash)
isBuffering:
phase === 'preparing' || (phase === 'playing' && !isVideoReady),
isReversing: false, // No longer support frame-stepping reverse
cancel,
forceComplete,
};
}