Files
frigate/web/src/components/player/dynamic/DynamicVideoPlayer.tsx
T

406 lines
11 KiB
TypeScript
Raw Normal View History

import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useApiHost } from "@/api";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Recording } from "@/types/record";
import { Preview } from "@/types/preview";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
2025-10-18 13:19:21 -05:00
import { useDetailStream } from "@/context/detail-stream-context";
2024-04-14 10:14:10 -06:00
import { TimeRange } from "@/types/timeline";
2024-04-14 14:43:43 -06:00
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { VideoResolutionType } from "@/types/live";
import axios from "axios";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
2025-11-21 14:40:58 -07:00
import {
calculateInpointOffset,
calculateSeekPosition,
} from "@/utils/videoUtil";
import {
downloadSnapshot,
generateSnapshotFilename,
grabVideoSnapshot,
} from "@/utils/snapshotUtil";
import { isFirefox } from "react-device-detect";
/**
* Dynamically switches between video playback and scrubbing preview player.
*/
type DynamicVideoPlayerProps = {
className?: string;
camera: string;
2024-03-26 15:03:58 -06:00
timeRange: TimeRange;
cameraPreviews: Preview[];
startTimestamp?: number;
isScrubbing: boolean;
hotKeys: boolean;
supportsFullscreen: boolean;
fullscreen: boolean;
onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void;
2026-07-01 15:03:34 -05:00
onClipPrevious?: (diff: number) => void;
2025-10-16 15:15:23 -05:00
onSeekToTime?: (timestamp: number, play?: boolean) => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
toggleFullscreen: () => void;
2024-07-08 08:14:10 -05:00
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
transformedOverlay?: ReactNode;
};
export default function DynamicVideoPlayer({
className,
camera,
timeRange,
cameraPreviews,
startTimestamp,
isScrubbing,
hotKeys,
supportsFullscreen,
fullscreen,
onControllerReady,
onTimestampUpdate,
onClipEnded,
2026-07-01 15:03:34 -05:00
onClipPrevious,
2025-10-16 08:24:14 -05:00
onSeekToTime,
setFullResolution,
toggleFullscreen,
2024-07-08 08:14:10 -05:00
containerRef,
transformedOverlay,
}: DynamicVideoPlayerProps) {
const { t } = useTranslation(["components/player", "views/live"]);
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
2025-10-18 13:19:21 -05:00
// for detail stream context in History
2025-10-25 17:15:36 -05:00
const {
isDetailMode,
camera: contextCamera,
currentTime,
} = useDetailStream();
2025-10-18 13:19:21 -05:00
// controlling playback
const playerRef = useRef<HTMLVideoElement | null>(null);
const [previewController, setPreviewController] =
useState<PreviewController | null>(null);
const [noRecording, setNoRecording] = useState(false);
const controller = useMemo(() => {
if (!config || !playerRef.current || !previewController) {
return undefined;
}
return new DynamicVideoController(
camera,
playerRef.current,
previewController,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
2024-03-14 09:28:06 -05:00
isScrubbing ? "scrubbing" : "playback",
setNoRecording,
2024-04-14 10:14:10 -06:00
() => {},
);
// we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, config, playerRef.current, previewController]);
useEffect(() => {
if (!controller) {
return;
}
if (controller) {
onControllerReady(controller);
}
// we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller]);
// initial state
2024-03-15 06:52:38 -06:00
const [isLoading, setIsLoading] = useState(false);
2024-07-17 07:39:37 -06:00
const [isBuffering, setIsBuffering] = useState(false);
2024-04-14 14:43:43 -06:00
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
2025-11-21 14:40:58 -07:00
// Don't set source until recordings load - we need accurate startPosition
// to avoid hls.js clamping to video end when startPosition exceeds duration
const [source, setSource] = useState<HlsSource | undefined>(undefined);
// start at correct time
2024-03-15 06:52:38 -06:00
useEffect(() => {
2024-04-14 14:43:43 -06:00
if (!isScrubbing) {
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
2024-03-15 06:52:38 -06:00
}
return () => {
if (loadingTimeout) {
clearTimeout(loadingTimeout);
}
};
// we only want trigger when scrubbing state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, isScrubbing]);
2024-03-15 06:52:38 -06:00
const onPlayerLoaded = useCallback(() => {
if (!controller || !startTimestamp) {
return;
}
controller.seekToTimestamp(startTimestamp, true);
}, [startTimestamp, controller]);
const onTimeUpdate = useCallback(
(time: number) => {
if (isScrubbing || !controller || !onTimestampUpdate || time == 0) {
return;
}
2024-04-16 14:55:24 -06:00
if (isLoading) {
setIsLoading(false);
}
2024-07-17 07:39:37 -06:00
if (isBuffering) {
setIsBuffering(false);
}
onTimestampUpdate(controller.getProgress(time));
},
2024-07-17 07:39:37 -06:00
[controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing],
);
const onUploadFrameToPlus = useCallback(
(playTime: number) => {
if (!controller) {
return;
}
const time = controller.getProgress(playTime);
return axios.post(`/${camera}/plus/${time}`);
},
[camera, controller],
);
2026-04-20 08:19:09 -05:00
const getSnapshotUrlForPlus = useCallback(
(playTime: number) => {
if (!controller) {
return undefined;
}
const time = controller.getProgress(playTime);
if (!time) {
return undefined;
}
return `${apiHost}api/${camera}/recordings/${time}/snapshot.jpg?height=500`;
},
[apiHost, camera, controller],
);
const onDownloadSnapshot = useCallback(
async (playTime: number) => {
if (!controller || !playerRef.current) {
return;
}
// map the player time back to the timeline timestamp so the filename
// reflects the moment being viewed rather than the current time
const frameTime = controller.getProgress(playTime);
const result = await grabVideoSnapshot(playerRef.current);
if (result.success) {
downloadSnapshot(
result.data.dataUrl,
generateSnapshotFilename(camera, frameTime),
);
toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), {
position: "top-center",
});
} else {
toast.error(t("snapshot.captureFailed", { ns: "views/live" }), {
position: "top-center",
});
}
},
[camera, controller, t],
);
// state of playback player
2024-05-20 08:44:39 -06:00
const recordingParams = useMemo(
() => ({
before: timeRange.before,
after: timeRange.after,
}),
[timeRange],
);
const { data: recordings } = useSWR<Recording[]>(
[`${camera}/recordings`, recordingParams],
{ revalidateOnFocus: false },
);
useEffect(() => {
2025-11-21 14:40:58 -07:00
if (!recordings?.length) {
2024-08-12 14:30:16 -06:00
if (recordings?.length == 0) {
setNoRecording(true);
}
return;
}
2025-11-20 15:58:58 -07:00
let startPosition = undefined;
if (startTimestamp) {
const inpointOffset = calculateInpointOffset(
recordingParams.after,
(recordings || [])[0],
);
2025-11-21 14:40:58 -07:00
startPosition = calculateSeekPosition(
startTimestamp,
recordings,
inpointOffset,
);
2025-11-20 15:58:58 -07:00
}
setSource({
playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`,
2025-11-20 15:58:58 -07:00
startPosition,
});
2025-11-21 14:40:58 -07:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recordings]);
useEffect(() => {
if (!controller || !recordings?.length) {
return;
}
if (playerRef.current) {
playerRef.current.autoplay = !isScrubbing;
}
2024-04-14 14:43:43 -06:00
setLoadingTimeout(setTimeout(() => setIsLoading(true), 1000));
controller.newPlayback({
recordings: recordings ?? [],
timeRange,
});
2025-11-21 14:40:58 -07:00
// we only want this to change when controller or recordings update
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, recordings]);
2025-05-19 15:43:22 -05:00
const inpointOffset = useMemo(
() => calculateInpointOffset(recordingParams.after, (recordings || [])[0]),
[recordingParams, recordings],
);
2025-05-18 17:23:01 -06:00
const onValidateClipEnd = useCallback(
(currentTime: number) => {
if (!onClipEnded || !controller || !recordings) {
return;
}
if (!isFirefox) {
onClipEnded();
}
// Firefox has a bug where clipEnded can be called prematurely due to buffering
// we need to validate if the current play-point is truly at the end of available recordings
const lastRecordingTime = recordings.at(-1)?.start_time;
if (
!lastRecordingTime ||
controller.getProgress(currentTime) < lastRecordingTime
) {
return;
}
onClipEnded();
},
[onClipEnded, controller, recordings],
);
return (
2024-03-30 12:44:12 -06:00
<>
2025-11-21 14:40:58 -07:00
{source && (
<HlsVideoPlayer
videoRef={playerRef}
containerRef={containerRef}
visible={!(isScrubbing || isLoading)}
currentSource={source}
hotKeys={hotKeys}
supportsFullscreen={supportsFullscreen}
fullscreen={fullscreen}
inpointOffset={inpointOffset}
onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded}
onClipEnded={onValidateClipEnd}
2026-07-01 15:03:34 -05:00
onClipPrevious={onClipPrevious}
2025-11-21 14:40:58 -07:00
onSeekToTime={(timestamp, play) => {
if (onSeekToTime) {
onSeekToTime(timestamp, play);
}
}}
onPlaying={() => {
if (isScrubbing) {
playerRef.current?.pause();
}
2025-11-21 14:40:58 -07:00
if (loadingTimeout) {
clearTimeout(loadingTimeout);
}
2024-04-14 14:43:43 -06:00
2025-11-21 14:40:58 -07:00
setNoRecording(false);
}}
setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus}
2026-04-20 08:19:09 -05:00
getSnapshotUrl={getSnapshotUrlForPlus}
onSnapshot={onDownloadSnapshot}
2025-11-21 14:40:58 -07:00
toggleFullscreen={toggleFullscreen}
onError={(error) => {
if (error == "stalled" && !isScrubbing) {
setIsBuffering(true);
}
}}
isDetailMode={isDetailMode}
camera={contextCamera || camera}
currentTimeOverride={currentTime}
transformedOverlay={transformedOverlay}
2025-11-21 14:40:58 -07:00
/>
)}
<PreviewPlayer
className={cn(
className,
2024-05-09 07:20:33 -06:00
isScrubbing || isLoading ? "visible" : "hidden",
)}
camera={camera}
timeRange={timeRange}
cameraPreviews={cameraPreviews}
2024-03-15 06:52:38 -06:00
startTime={startTimestamp}
isScrubbing={isScrubbing}
2024-07-17 07:39:37 -06:00
onControllerReady={(previewController) =>
setPreviewController(previewController)
}
/>
2024-07-17 07:39:37 -06:00
{!isScrubbing && (isLoading || isBuffering) && !noRecording && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
2024-04-14 14:43:43 -06:00
)}
2024-07-21 13:14:59 -06:00
{!isScrubbing && !isLoading && noRecording && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{t("noRecordingsFoundForThisTime")}
</div>
)}
2024-03-30 12:44:12 -06:00
</>
);
}