import { MutableRefObject, useCallback, 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 "../indicators/activity-indicator"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; import { DynamicPlayback } from "@/types/playback"; type PlayerMode = "playback" | "scrubbing"; /** * Dynamically switches between video playback and scrubbing preview player. */ type DynamicVideoPlayerProps = { className?: string; camera: string; timeRange: { start: number; end: number }; cameraPreviews: Preview[]; previewOnly?: boolean; onControllerReady: (controller: DynamicVideoController) => void; onClick?: () => void; }; export default function DynamicVideoPlayer({ className, camera, timeRange, cameraPreviews, previewOnly = false, onControllerReady, onClick, }: DynamicVideoPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR<FrigateConfig>("config"); const timezone = useMemo( () => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config], ); // playback behavior const tallVideo = useMemo(() => { if (!config) { return false; } return ( config.cameras[camera].detect.width / config.cameras[camera].detect.height < 1 ); }, [camera, config]); // controlling playback const playerRef = useRef<Player | undefined>(undefined); const previewRef = useRef<HTMLVideoElement | null>(null); const [isScrubbing, setIsScrubbing] = useState(previewOnly); const [focusedItem, setFocusedItem] = useState<Timeline | undefined>( undefined, ); const controller = useMemo(() => { if (!config) { return undefined; } return new DynamicVideoController( playerRef, previewRef, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, previewOnly ? "scrubbing" : "playback", setIsScrubbing, setFocusedItem, ); }, [camera, config, previewOnly]); useEffect(() => { if (!playerRef.current && !previewRef.current) { return; } if (controller) { onControllerReady(controller); } // we only want to fire once when players are ready // eslint-disable-next-line react-hooks/exhaustive-deps }, [playerRef, previewRef]); const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true); // keyboard control const onKeyboardShortcut = useCallback( (key: string, down: boolean, repeat: boolean) => { switch (key) { case "ArrowLeft": if (down) { const currentTime = playerRef.current?.currentTime(); if (currentTime) { playerRef.current?.currentTime(Math.max(0, currentTime - 5)); } } break; case "ArrowRight": if (down) { const currentTime = playerRef.current?.currentTime(); if (currentTime) { playerRef.current?.currentTime(currentTime + 5); } } break; case "m": if (down && !repeat && playerRef.current) { playerRef.current.muted(!playerRef.current.muted()); } break; case " ": if (down && playerRef.current) { if (playerRef.current.paused()) { playerRef.current.play(); } else { playerRef.current.pause(); } } break; } }, [playerRef], ); useKeyboardListener( ["ArrowLeft", "ArrowRight", "m", " "], onKeyboardShortcut, ); // 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", }; // we only want to calculate this once // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const initialPreview = useMemo(() => { return cameraPreviews.find( (preview) => preview.camera == camera && Math.round(preview.start) >= timeRange.start && Math.floor(preview.end) <= timeRange.end, ); // we only want to calculate this once // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [currentPreview, setCurrentPreview] = useState(initialPreview); const onPreviewSeeked = useCallback(() => { if (!controller) { return; } controller.finishedSeeking(); if (currentPreview && previewOnly && previewRef.current && onClick) { setHasRecordingAtTime( controller.hasRecordingAtTime( currentPreview.start + previewRef.current.currentTime, ), ); } }, [controller, currentPreview, onClick, previewOnly]); // state of playback player const recordingParams = useMemo(() => { return { before: timeRange.end, after: timeRange.start, }; }, [timeRange]); const { data: recordings } = useSWR<Recording[]>( previewOnly && onClick == undefined ? null : [`${camera}/recordings`, recordingParams], { revalidateOnFocus: false }, ); useEffect(() => { if (!controller || (!previewOnly && !recordings)) { 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) => preview.camera == camera && Math.round(preview.start) >= timeRange.start && Math.floor(preview.end) <= timeRange.end, ); setCurrentPreview(preview); if (preview && previewRef.current) { previewRef.current.load(); } controller.newPlayback({ recordings: recordings ?? [], playbackUri, preview, }); // we only want this to change when recordings update // eslint-disable-next-line react-hooks/exhaustive-deps }, [controller, recordings]); if (!controller) { return <ActivityIndicator />; } return ( <div className={`relative ${className ?? ""} ${onClick ? (hasRecordingAtTime ? "cursor-pointer" : "") : ""}`} onClick={onClick} > {!previewOnly && ( <div className={`w-full relative ${ currentPreview != undefined && isScrubbing ? "hidden" : "visible" }`} > <VideoPlayer options={{ preload: "auto", autoplay: true, sources: [initialPlaybackSource], aspectRatio: tallVideo ? "16:9" : undefined, controlBar: { remainingTimeDisplay: false, progressControl: { seekBar: false, }, }, }} seekOptions={{ forward: 10, backward: 5 }} onReady={(player) => { playerRef.current = player; player.on("playing", () => setFocusedItem(undefined)); player.on("timeupdate", () => { controller.updateProgress(player.currentTime() || 0); }); player.on("ended", () => controller.fireClipChangeEvent("forward"), ); }} onDispose={() => { playerRef.current = undefined; }} > {config && focusedItem && ( <TimelineEventOverlay timeline={focusedItem} cameraConfig={config.cameras[camera]} /> )} </VideoPlayer> </div> )} <video ref={previewRef} className={`size-full rounded-2xl ${currentPreview != undefined && (previewOnly || isScrubbing) ? "visible" : "hidden"} ${tallVideo ? "aspect-tall" : ""} bg-black`} preload="auto" autoPlay playsInline muted disableRemotePlayback onSeeked={onPreviewSeeked} onLoadedData={() => controller.previewReady()} > {currentPreview != undefined && ( <source src={currentPreview.src} type={currentPreview.type} /> )} </video> </div> ); } export class DynamicVideoController { // main state private playerRef: MutableRefObject<Player | undefined>; private previewRef: MutableRefObject<HTMLVideoElement | null>; private setScrubbing: (isScrubbing: boolean) => void; private setFocusedItem: (timeline: Timeline) => void; private playerMode: PlayerMode = "playback"; // playback private recordings: Recording[] = []; private onPlaybackTimestamp: ((time: number) => void) | undefined = undefined; private onClipChange: ((dir: "forward" | "backward") => void) | undefined = undefined; private annotationOffset: number; private timeToStart: number | undefined = undefined; private clipChangeLockout: boolean = true; // preview private preview: Preview | undefined = undefined; private timeToSeek: number | undefined = undefined; private seeking = false; private readyToScrub = true; constructor( playerRef: MutableRefObject<Player | undefined>, previewRef: MutableRefObject<HTMLVideoElement | null>, annotationOffset: number, defaultMode: PlayerMode, setScrubbing: (isScrubbing: boolean) => void, setFocusedItem: (timeline: Timeline) => void, ) { this.playerRef = playerRef; this.previewRef = previewRef; this.annotationOffset = annotationOffset; this.playerMode = defaultMode; 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; } 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) | undefined) { this.onPlaybackTimestamp = listener; } onClipChangedEvent(listener: (dir: "forward" | "backward") => void) { this.onClipChange = listener; } fireClipChangeEvent(dir: "forward" | "backward") { if (this.onClipChange) { this.onClipChange(dir); } } scrubToTimestamp(time: number) { if (!this.preview) { return; } if (!this.readyToScrub) { return; } if (time > this.preview.end) { if (this.clipChangeLockout && time - this.preview.end < 30) { return; } if (this.playerMode == "scrubbing") { this.playerMode = "playback"; this.setScrubbing(false); this.timeToSeek = undefined; this.seeking = false; this.readyToScrub = false; this.clipChangeLockout = true; this.fireClipChangeEvent("forward"); } return; } if (time < this.preview.start) { if (this.clipChangeLockout && this.preview.start - time < 30) { return; } if (this.playerMode == "scrubbing") { this.playerMode = "playback"; this.setScrubbing(false); this.timeToSeek = undefined; this.seeking = false; this.readyToScrub = false; this.clipChangeLockout = true; this.fireClipChangeEvent("backward"); } return; } if (this.playerMode != "scrubbing") { this.playerMode = "scrubbing"; this.playerRef.current?.pause(); this.setScrubbing(true); } if (this.seeking) { this.timeToSeek = time; } else { if (this.previewRef.current) { this.previewRef.current.currentTime = Math.max( 0, time - this.preview.start, ); this.seeking = true; } } } finishedSeeking() { if ( !this.previewRef.current || !this.preview || this.playerMode == "playback" ) { return; } this.clipChangeLockout = false; if ( this.timeToSeek && this.timeToSeek != this.previewRef.current?.currentTime ) { this.previewRef.current.currentTime = this.timeToSeek - this.preview.start; } else { this.seeking = false; } } previewReady() { this.previewRef.current?.pause(); this.readyToScrub = true; } hasRecordingAtTime(time: number): boolean { if (!this.recordings || this.recordings.length == 0) { return false; } return ( this.recordings.find( (segment) => segment.start_time <= time && segment.end_time >= time, ) != undefined ); } }