776 lines
22 KiB
TypeScript
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,
|
|
};
|
|
}
|