218 lines
5.8 KiB
TypeScript
218 lines
5.8 KiB
TypeScript
/**
|
|
* useMediaDurationProbe Hook
|
|
*
|
|
* Probes media durations with caching and deduplication.
|
|
* Used in constructor.tsx for displaying video/audio duration info.
|
|
*/
|
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
import {
|
|
formatDurationNote,
|
|
resolveDurationWithFallback,
|
|
} from '../lib/mediaHelpers';
|
|
import { logger } from '../lib/logger';
|
|
|
|
interface DurationProbeTarget {
|
|
source: string;
|
|
mediaType: 'video' | 'audio';
|
|
}
|
|
|
|
interface UseMediaDurationProbeOptions {
|
|
/** Array of media sources to probe */
|
|
targets: DurationProbeTarget[];
|
|
}
|
|
|
|
interface UseMediaDurationProbeResult {
|
|
/** Map of source URL to resolved duration (seconds) or null */
|
|
durationBySource: Record<string, number | null>;
|
|
/** Get known duration for a source (or null if unknown/pending) */
|
|
getDuration: (source: string) => number | null;
|
|
/** Get formatted duration note for a source */
|
|
getDurationNote: (source: string) => string;
|
|
/** Whether any probes are in progress */
|
|
isProbing: boolean;
|
|
}
|
|
|
|
/**
|
|
* Hook for probing and caching media durations.
|
|
* Automatically deduplicates in-flight requests and caches results.
|
|
*
|
|
* @example
|
|
* const { getDurationNote, getDuration } = useMediaDurationProbe({
|
|
* targets: [
|
|
* { source: backgroundVideoUrl, mediaType: 'video' },
|
|
* { source: backgroundAudioUrl, mediaType: 'audio' },
|
|
* ],
|
|
* });
|
|
*
|
|
* return <p>{getDurationNote(backgroundVideoUrl)}</p>;
|
|
*/
|
|
export function useMediaDurationProbe({
|
|
targets,
|
|
}: UseMediaDurationProbeOptions): UseMediaDurationProbeResult {
|
|
const [durationBySource, setDurationBySource] = useState<
|
|
Record<string, number | null>
|
|
>({});
|
|
const [isProbing, setIsProbing] = useState(false);
|
|
const inFlightRef = useRef<Set<string>>(new Set());
|
|
|
|
// Probe targets for duration
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
let isCancelled = false;
|
|
let activeProbes = 0;
|
|
|
|
targets.forEach(({ source, mediaType }) => {
|
|
const normalizedSource = String(source || '').trim();
|
|
if (!normalizedSource) return;
|
|
|
|
// Skip if already resolved
|
|
if (durationBySource[normalizedSource] !== undefined) return;
|
|
|
|
// Skip if already in flight
|
|
const probeKey = `${mediaType}:${normalizedSource}`;
|
|
if (inFlightRef.current.has(probeKey)) return;
|
|
|
|
inFlightRef.current.add(probeKey);
|
|
activeProbes++;
|
|
setIsProbing(true);
|
|
|
|
resolveDurationWithFallback(normalizedSource, mediaType)
|
|
.then((duration) => {
|
|
if (isCancelled) return;
|
|
setDurationBySource((prev) => ({
|
|
...prev,
|
|
[normalizedSource]:
|
|
Number.isFinite(duration) && Number(duration) > 0
|
|
? Number(duration)
|
|
: null,
|
|
}));
|
|
})
|
|
.catch((error) => {
|
|
logger.error(
|
|
'Failed to resolve media duration:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
if (isCancelled) return;
|
|
setDurationBySource((prev) => ({
|
|
...prev,
|
|
[normalizedSource]: null,
|
|
}));
|
|
})
|
|
.finally(() => {
|
|
inFlightRef.current.delete(probeKey);
|
|
activeProbes--;
|
|
if (activeProbes === 0) {
|
|
setIsProbing(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
isCancelled = true;
|
|
};
|
|
}, [targets, durationBySource]);
|
|
|
|
const getDuration = useCallback(
|
|
(source: string): number | null => {
|
|
const normalizedSource = String(source || '').trim();
|
|
if (!normalizedSource) return null;
|
|
|
|
const duration = durationBySource[normalizedSource];
|
|
if (Number.isFinite(duration) && Number(duration) > 0) {
|
|
return Number(duration);
|
|
}
|
|
|
|
return null;
|
|
},
|
|
[durationBySource],
|
|
);
|
|
|
|
const getDurationNote = useCallback(
|
|
(source: string): string => {
|
|
return formatDurationNote(getDuration(source));
|
|
},
|
|
[getDuration],
|
|
);
|
|
|
|
return {
|
|
durationBySource,
|
|
getDuration,
|
|
getDurationNote,
|
|
isProbing,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build duration probe targets from constructor state.
|
|
* Utility to simplify target array creation in constructor.tsx.
|
|
*/
|
|
export function buildDurationProbeTargets({
|
|
backgroundVideoUrl,
|
|
backgroundAudioUrl,
|
|
selectedElement,
|
|
newTransitionVideoUrl,
|
|
elements,
|
|
isMediaElementType,
|
|
isVideoPlayerElementType,
|
|
isNavigationElementType,
|
|
}: {
|
|
backgroundVideoUrl?: string;
|
|
backgroundAudioUrl?: string;
|
|
selectedElement?: {
|
|
type: string;
|
|
mediaUrl?: string;
|
|
} | null;
|
|
newTransitionVideoUrl?: string;
|
|
elements?: Array<{
|
|
type: string;
|
|
transitionVideoUrl?: string;
|
|
reverseVideoUrl?: string;
|
|
}>;
|
|
isMediaElementType: (type: string) => boolean;
|
|
isVideoPlayerElementType: (type: string) => boolean;
|
|
isNavigationElementType: (type: string) => boolean;
|
|
}): DurationProbeTarget[] {
|
|
const targets: DurationProbeTarget[] = [];
|
|
|
|
if (backgroundVideoUrl) {
|
|
targets.push({ source: backgroundVideoUrl, mediaType: 'video' });
|
|
}
|
|
|
|
if (backgroundAudioUrl) {
|
|
targets.push({ source: backgroundAudioUrl, mediaType: 'audio' });
|
|
}
|
|
|
|
if (
|
|
selectedElement &&
|
|
isMediaElementType(selectedElement.type) &&
|
|
selectedElement.mediaUrl
|
|
) {
|
|
targets.push({
|
|
source: selectedElement.mediaUrl,
|
|
mediaType: isVideoPlayerElementType(selectedElement.type)
|
|
? 'video'
|
|
: 'audio',
|
|
});
|
|
}
|
|
|
|
if (newTransitionVideoUrl) {
|
|
targets.push({ source: newTransitionVideoUrl, mediaType: 'video' });
|
|
}
|
|
|
|
elements?.forEach((element) => {
|
|
if (!isNavigationElementType(element.type)) return;
|
|
if (element.transitionVideoUrl) {
|
|
targets.push({ source: element.transitionVideoUrl, mediaType: 'video' });
|
|
}
|
|
if (element.reverseVideoUrl) {
|
|
targets.push({ source: element.reverseVideoUrl, mediaType: 'video' });
|
|
}
|
|
});
|
|
|
|
return targets;
|
|
}
|
|
|
|
export default useMediaDurationProbe;
|