636 lines
18 KiB
TypeScript
636 lines
18 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type RefObject,
|
|
} from 'react';
|
|
import axios from 'axios';
|
|
import { logger } from '../lib/logger';
|
|
import { useReversePlayback } from './useReversePlayback';
|
|
|
|
export type ReverseMode = 'none' | 'reverse' | 'separate';
|
|
|
|
export interface TransitionConfig {
|
|
videoUrl: string;
|
|
reverseMode: ReverseMode;
|
|
reverseVideoUrl?: string;
|
|
durationSec?: number;
|
|
targetPageId?: string;
|
|
displayName?: string;
|
|
}
|
|
|
|
export interface UseTransitionPlaybackOptions {
|
|
videoRef: RefObject<HTMLVideoElement | null>;
|
|
transition: TransitionConfig | null;
|
|
onComplete: (targetPageId?: string) => 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>;
|
|
};
|
|
}
|
|
|
|
export type PlaybackPhase =
|
|
| 'idle'
|
|
| 'preparing'
|
|
| 'playing'
|
|
| 'reversing'
|
|
| 'finishing'
|
|
| 'completed';
|
|
|
|
export interface UseTransitionPlaybackResult {
|
|
phase: PlaybackPhase;
|
|
isBuffering: boolean;
|
|
isReversing: boolean;
|
|
cancel: () => void;
|
|
forceComplete: () => void;
|
|
}
|
|
|
|
const DEFAULT_TIMEOUTS = {
|
|
playbackStartMs: 3000,
|
|
durationBufferMs: 200,
|
|
hardTimeoutMs: 45000,
|
|
};
|
|
|
|
function getBufferedEnd(video: HTMLVideoElement): number {
|
|
return video.buffered.length > 0
|
|
? video.buffered.end(video.buffered.length - 1)
|
|
: 0;
|
|
}
|
|
|
|
function shouldLoadViaBlob(
|
|
url: string,
|
|
reverseMode: ReverseMode,
|
|
useBlobUrlOption?: boolean,
|
|
): boolean {
|
|
if (useBlobUrlOption === false) return false;
|
|
if (reverseMode === 'reverse') return true;
|
|
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;
|
|
}
|
|
}
|
|
|
|
function buildBlobRequestUrl(url: string): string {
|
|
if (url.startsWith('/api/')) {
|
|
return url.replace(/^\/api(?=\/)/, '');
|
|
}
|
|
return url;
|
|
}
|
|
|
|
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 durationBufferMs =
|
|
customTimeouts?.durationBufferMs ?? DEFAULT_TIMEOUTS.durationBufferMs;
|
|
const hardTimeoutMs =
|
|
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
|
|
|
|
const [phase, setPhase] = useState<PlaybackPhase>('idle');
|
|
const [isReverseBufferingLocal, setIsReverseBufferingLocal] = 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 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);
|
|
const startReverseRef = useRef<(() => Promise<void>) | null>(null);
|
|
const stopReverseRef = useRef<(() => void) | null>(null);
|
|
|
|
const sourceUrl = useMemo(() => {
|
|
if (!transition) return '';
|
|
return transition.reverseMode === 'separate' && transition.reverseVideoUrl
|
|
? transition.reverseVideoUrl
|
|
: transition.videoUrl;
|
|
}, [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) {
|
|
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);
|
|
},
|
|
[clearTimers, videoRef],
|
|
);
|
|
|
|
const handleError = useCallback(
|
|
(reason: string) => {
|
|
if (didFinishRef.current) return;
|
|
logger.error('Transition playback error', { reason });
|
|
onErrorRef.current?.(reason);
|
|
finishPlayback(reason);
|
|
},
|
|
[finishPlayback],
|
|
);
|
|
|
|
const handleReverseComplete = useCallback(() => {
|
|
finishPlayback('reverse-complete');
|
|
}, [finishPlayback]);
|
|
|
|
const {
|
|
startReverse,
|
|
stopReverse,
|
|
isReversing,
|
|
isBuffering: isReverseBuffering,
|
|
} = useReversePlayback({
|
|
videoRef,
|
|
onComplete: handleReverseComplete,
|
|
preloadedUrls: preload?.preloadedUrls,
|
|
videoUrl: sourceUrl,
|
|
getCachedBlobUrl: preload?.getCachedBlobUrl,
|
|
});
|
|
|
|
useEffect(() => {
|
|
onCompleteRef.current = onComplete;
|
|
onErrorRef.current = onError;
|
|
transitionRef.current = transition;
|
|
featuresRef.current = features;
|
|
preloadRef.current = preload;
|
|
startReverseRef.current = startReverse;
|
|
stopReverseRef.current = stopReverse;
|
|
});
|
|
|
|
useEffect(() => {
|
|
setIsReverseBufferingLocal(isReverseBuffering);
|
|
}, [isReverseBuffering]);
|
|
|
|
const cancel = useCallback(() => {
|
|
if (phase === 'idle') return;
|
|
clearTimers();
|
|
stopReverseRef.current?.();
|
|
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 || !sourceUrl) {
|
|
return;
|
|
}
|
|
|
|
if (activeSourceUrlRef.current === sourceUrl) {
|
|
logger.info('Skipping duplicate effect for same source', { sourceUrl });
|
|
return;
|
|
}
|
|
|
|
activeSourceUrlRef.current = sourceUrl;
|
|
didFinishRef.current = false;
|
|
didStartPlaybackRef.current = false;
|
|
setPhase('preparing');
|
|
|
|
const isReverseMode = currentTransition.reverseMode === 'reverse';
|
|
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,
|
|
reverseMode: currentTransition.reverseMode,
|
|
mediaError: getMediaErrorDetails(),
|
|
error: error instanceof Error ? error : { error },
|
|
});
|
|
};
|
|
|
|
const scheduleFinishByDuration = (durationSec: number) => {
|
|
if (
|
|
!Number.isFinite(durationSec) ||
|
|
durationSec <= 0 ||
|
|
finishTimerRef.current
|
|
) {
|
|
return;
|
|
}
|
|
finishTimerRef.current = setTimeout(
|
|
() => finishPlayback('duration-timer'),
|
|
durationSec * 1000 + durationBufferMs,
|
|
);
|
|
};
|
|
|
|
const attemptPlay = () => {
|
|
video.play().catch((playError) => {
|
|
if (!isReverseMode) {
|
|
logIssue('play-failed', playError);
|
|
}
|
|
});
|
|
};
|
|
|
|
const resolvePlayableSource = async (): Promise<string> => {
|
|
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,
|
|
currentTransition.reverseMode,
|
|
featuresRef.current?.useBlobUrl,
|
|
);
|
|
|
|
if (!needsBlobUrl) {
|
|
return sourceUrl;
|
|
}
|
|
|
|
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
|
|
if (getCachedBlobUrl) {
|
|
try {
|
|
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
|
|
if (cachedBlobUrl) {
|
|
logger.info('Using preloaded blob URL from cache', {
|
|
reverseMode: currentTransition.reverseMode,
|
|
});
|
|
lastLoadedBlobUrlRef.current = cachedBlobUrl;
|
|
lastLoadedSourceUrlRef.current = sourceUrl;
|
|
return cachedBlobUrl;
|
|
}
|
|
} catch (cacheError) {
|
|
logger.warn('Cache lookup failed, falling back to fetch', {
|
|
cacheError,
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.info('Fetching video as blob for seeking support', {
|
|
reverseMode: currentTransition.reverseMode,
|
|
});
|
|
const token =
|
|
typeof window !== 'undefined'
|
|
? localStorage.getItem('token') || ''
|
|
: '';
|
|
const requestUrl = buildBlobRequestUrl(sourceUrl);
|
|
const response = await axios.get(requestUrl, {
|
|
responseType: 'blob',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
});
|
|
|
|
const blobUrl = URL.createObjectURL(response.data);
|
|
lastLoadedBlobUrlRef.current = blobUrl;
|
|
lastLoadedSourceUrlRef.current = sourceUrl;
|
|
logger.info('Created blob URL for video', {
|
|
blobUrl: blobUrl.substring(0, 50),
|
|
});
|
|
return blobUrl;
|
|
};
|
|
|
|
const loadAndPlay = async () => {
|
|
logger.info('loadAndPlay called', {
|
|
reverseMode: currentTransition.reverseMode,
|
|
sourceUrl,
|
|
});
|
|
|
|
didStartPlaybackRef.current = false;
|
|
|
|
if (startWatchdogTimerRef.current) {
|
|
clearTimeout(startWatchdogTimerRef.current);
|
|
}
|
|
|
|
try {
|
|
const playableSourceUrl = await resolvePlayableSource();
|
|
if (didFinishRef.current) return;
|
|
|
|
video.pause();
|
|
stopReverseRef.current?.();
|
|
|
|
const isSameSource =
|
|
lastLoadedSourceUrlRef.current === playableSourceUrl;
|
|
|
|
if (isReverseMode && isSameSource && video.readyState >= 2) {
|
|
logger.info('Reusing buffered video for reverse', {
|
|
readyState: video.readyState,
|
|
duration: video.duration,
|
|
bufferedEnd: getBufferedEnd(video),
|
|
});
|
|
setPhase('reversing');
|
|
void startReverseRef.current?.();
|
|
return;
|
|
}
|
|
|
|
video.src = playableSourceUrl;
|
|
video.currentTime = 0;
|
|
video.load();
|
|
lastLoadedSourceUrlRef.current = playableSourceUrl;
|
|
|
|
attemptPlay();
|
|
|
|
startWatchdogTimerRef.current = setTimeout(() => {
|
|
if (didStartPlaybackRef.current || didFinishRef.current) return;
|
|
logIssue('playback-start-slow');
|
|
if (isReverseMode) {
|
|
didStartPlaybackRef.current = true;
|
|
setPhase('reversing');
|
|
void startReverseRef.current?.();
|
|
} else {
|
|
attemptPlay();
|
|
}
|
|
}, playbackStartMs);
|
|
} catch (error) {
|
|
logIssue('source-prepare-failed', error);
|
|
handleError('source-prepare-failed');
|
|
}
|
|
};
|
|
|
|
const onLoadedMetadata = () => {
|
|
if (didFinishRef.current) return;
|
|
if (!isReverseMode) {
|
|
video.currentTime = 0;
|
|
attemptPlay();
|
|
}
|
|
};
|
|
|
|
const onCanPlayThrough = () => {
|
|
if (didFinishRef.current) return;
|
|
logger.info('canplaythrough fired', {
|
|
reverseMode: currentTransition.reverseMode,
|
|
didStartPlayback: didStartPlaybackRef.current,
|
|
});
|
|
|
|
if (isReverseMode && !didStartPlaybackRef.current) {
|
|
didStartPlaybackRef.current = true;
|
|
if (startWatchdogTimerRef.current) {
|
|
clearTimeout(startWatchdogTimerRef.current);
|
|
startWatchdogTimerRef.current = null;
|
|
}
|
|
video.pause();
|
|
setPhase('reversing');
|
|
void startReverseRef.current?.();
|
|
}
|
|
};
|
|
|
|
const onCanPlay = () => {
|
|
if (didFinishRef.current) return;
|
|
if (isReverseMode && didStartPlaybackRef.current) return;
|
|
attemptPlay();
|
|
};
|
|
|
|
const onPlaying = () => {
|
|
logger.info('onPlaying fired', {
|
|
reverseMode: currentTransition.reverseMode,
|
|
didStartPlayback: didStartPlaybackRef.current,
|
|
didFinish: didFinishRef.current,
|
|
});
|
|
|
|
if (didFinishRef.current) return;
|
|
|
|
if (isReverseMode && !didStartPlaybackRef.current) {
|
|
logger.info('Triggering reverse from onPlaying');
|
|
didStartPlaybackRef.current = true;
|
|
if (startWatchdogTimerRef.current) {
|
|
clearTimeout(startWatchdogTimerRef.current);
|
|
startWatchdogTimerRef.current = null;
|
|
}
|
|
video.pause();
|
|
setPhase('reversing');
|
|
void startReverseRef.current?.();
|
|
return;
|
|
}
|
|
|
|
didStartPlaybackRef.current = true;
|
|
setPhase('playing');
|
|
|
|
if (startWatchdogTimerRef.current) {
|
|
clearTimeout(startWatchdogTimerRef.current);
|
|
startWatchdogTimerRef.current = null;
|
|
}
|
|
|
|
if (!isReverseMode) {
|
|
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');
|
|
|
|
const onVideoError = () => {
|
|
if (didFinishRef.current) return;
|
|
logIssue('video-error');
|
|
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('canplaythrough', onCanPlayThrough);
|
|
video.addEventListener('canplay', onCanPlay);
|
|
video.addEventListener('playing', onPlaying);
|
|
video.addEventListener('ended', onEnded);
|
|
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('canplaythrough', onCanPlayThrough);
|
|
video.removeEventListener('canplay', onCanPlay);
|
|
video.removeEventListener('playing', onPlaying);
|
|
video.removeEventListener('ended', onEnded);
|
|
video.removeEventListener('error', onVideoError);
|
|
video.removeEventListener('abort', onAbort);
|
|
video.removeEventListener('stalled', onStalled);
|
|
clearTimers();
|
|
stopReverseRef.current?.();
|
|
};
|
|
}, [sourceUrl, videoRef, playbackStartMs, durationBufferMs, hardTimeoutMs, clearTimers, revokeBlobUrl, finishPlayback, handleError]);
|
|
|
|
useEffect(() => {
|
|
if (!transition) {
|
|
setPhase('idle');
|
|
activeSourceUrlRef.current = null;
|
|
didFinishRef.current = false;
|
|
didStartPlaybackRef.current = false;
|
|
}
|
|
}, [transition]);
|
|
|
|
return {
|
|
phase,
|
|
isBuffering: isReverseBufferingLocal,
|
|
isReversing,
|
|
cancel,
|
|
forceComplete,
|
|
};
|
|
}
|