39948-vm/frontend/src/hooks/useMediaDurationProbe.ts
2026-03-29 16:03:25 +04:00

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;