import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; import VideoPlayer from "./VideoPlayer"; import Player from "video.js/dist/types/player"; import TimelineEventOverlay from "../overlay/TimelineDataOverlay"; import { useApiHost } from "@/api"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import ActivityIndicator from "../ui/activity-indicator"; /** * Dynamically switches between video playback and scrubbing preview player. */ type DynamicVideoPlayerProps = { className?: string; camera: string; timeRange: { start: number; end: number }; cameraPreviews: Preview[]; onControllerReady?: (controller: DynamicVideoController) => void; }; export default function DynamicVideoPlayer({ className, camera, timeRange, cameraPreviews, onControllerReady, }: DynamicVideoPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const timezone = useMemo( () => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config] ); // controlling playback const playerRef = useRef(undefined); const previewRef = useRef(undefined); const [isScrubbing, setIsScrubbing] = useState(false); const [hasPreview, setHasPreview] = useState(false); const [focusedItem, setFocusedItem] = useState( undefined ); const controller = useMemo(() => { if (!config) { return undefined; } return new DynamicVideoController( playerRef, previewRef, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, setIsScrubbing, setFocusedItem ); }, [config]); // initial state const initialPlaybackSource = useMemo(() => { const date = new Date(timeRange.start * 1000); return { src: `${apiHost}vod/${date.getFullYear()}-${ date.getMonth() + 1 }/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll( "/", "," )}/master.m3u8`, type: "application/vnd.apple.mpegurl", }; }, []); // state of playback player const recordingParams = useMemo(() => { return { before: timeRange.end, after: timeRange.start, }; }, [timeRange]); const { data: recordings } = useSWR( [`${camera}/recordings`, recordingParams], { revalidateOnFocus: false } ); useEffect(() => { if (!controller || !recordings || recordings.length == 0) { return; } const date = new Date(timeRange.start * 1000); const playbackUri = `${apiHost}vod/${date.getFullYear()}-${ date.getMonth() + 1 }/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll( "/", "," )}/master.m3u8`; const preview = cameraPreviews.find( (preview) => Math.round(preview.start) >= timeRange.start && Math.floor(preview.end) <= timeRange.end ); setHasPreview(preview != undefined); controller.newPlayback({ recordings, playbackUri, preview, }); }, [controller, recordings]); if (!controller) { return ; } return (
{ playerRef.current = player; player.on("playing", () => setFocusedItem(undefined)); player.on("timeupdate", () => { controller.updateProgress(player.currentTime() || 0); }); if (onControllerReady) { onControllerReady(controller); } }} onDispose={() => { playerRef.current = undefined; }} > {config && focusedItem && ( )}
{ previewRef.current = player; player.on("seeked", () => controller.finishedSeeking()); }} onDispose={() => { previewRef.current = undefined; }} />
); } export class DynamicVideoController { // main state private playerRef: MutableRefObject; private previewRef: MutableRefObject; private setScrubbing: (isScrubbing: boolean) => void; private setFocusedItem: (timeline: Timeline) => void; private playerMode: "playback" | "scrubbing" = "playback"; // playback private recordings: Recording[] = []; private onPlaybackTimestamp: ((time: number) => void) | undefined = undefined; private annotationOffset: number; private timeToStart: number | undefined = undefined; // preview private preview: Preview | undefined = undefined; private timeToSeek: number | undefined = undefined; private seeking = false; constructor( playerRef: MutableRefObject, previewRef: MutableRefObject, annotationOffset: number, setScrubbing: (isScrubbing: boolean) => void, setFocusedItem: (timeline: Timeline) => void ) { this.playerRef = playerRef; this.previewRef = previewRef; this.annotationOffset = annotationOffset; this.setScrubbing = setScrubbing; this.setFocusedItem = setFocusedItem; } newPlayback(newPlayback: DynamicPlayback) { this.recordings = newPlayback.recordings; this.playerRef.current?.src({ src: newPlayback.playbackUri, type: "application/vnd.apple.mpegurl", }); if (this.timeToStart) { this.seekToTimestamp(this.timeToStart); this.timeToStart = undefined; } this.preview = newPlayback.preview; if (this.preview && this.previewRef.current) { this.previewRef.current.src({ src: this.preview.src, type: this.preview.type, }); } } seekToTimestamp(time: number, play: boolean = false) { if (this.playerMode != "playback") { this.playerMode = "playback"; this.setScrubbing(false); this.timeToSeek = undefined; this.seeking = false; } if (this.recordings.length == 0) { this.timeToStart = time; } let seekSeconds = 0; (this.recordings || []).every((segment) => { // if the next segment is past the desired time, stop calculating if (segment.start_time > time) { return false; } if (segment.end_time < time) { seekSeconds += segment.end_time - segment.start_time; return true; } seekSeconds += segment.end_time - segment.start_time - (segment.end_time - time); return true; }); this.playerRef.current?.currentTime(seekSeconds); if (play) { this.playerRef.current?.play(); } } seekToTimelineItem(timeline: Timeline) { this.playerRef.current?.pause(); this.seekToTimestamp(timeline.timestamp + this.annotationOffset); this.setFocusedItem(timeline); } updateProgress(playerTime: number) { if (this.onPlaybackTimestamp) { // take a player time in seconds and convert to timestamp in timeline let timestamp = 0; let totalTime = 0; (this.recordings || []).every((segment) => { if (totalTime + segment.duration > playerTime) { // segment is here timestamp = segment.start_time + (playerTime - totalTime); return false; } else { totalTime += segment.duration; return true; } }); this.onPlaybackTimestamp(timestamp); } } onPlayerTimeUpdate(listener: (timestamp: number) => void) { this.onPlaybackTimestamp = listener; } scrubToTimestamp(time: number) { if (this.playerMode != "scrubbing") { this.playerMode = "scrubbing"; this.playerRef.current?.pause(); this.setScrubbing(true); } if (this.preview) { if (this.seeking) { this.timeToSeek = time; } else { this.previewRef.current?.currentTime(time - this.preview.start); this.seeking = true; } } } finishedSeeking() { if (!this.preview || this.playerMode == "playback") { return; } if ( this.timeToSeek && this.timeToSeek != this.previewRef.current?.currentTime() ) { this.previewRef.current?.currentTime( this.timeToSeek - this.preview.start ); } else { this.seeking = false; } } }