diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 9433e39755..91cf0c210d 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -54,6 +54,7 @@ type HlsVideoPlayerProps = { setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; getSnapshotUrl?: (playTime: number) => string | undefined; + onSnapshot?: (playTime: number) => Promise | void; toggleFullscreen?: () => void; onError?: (error: RecordingPlayerError) => void; isDetailMode?: boolean; @@ -80,6 +81,7 @@ export default function HlsVideoPlayer({ setFullResolution, onUploadFrame, getSnapshotUrl, + onSnapshot, toggleFullscreen, onError, isDetailMode = false, @@ -232,6 +234,7 @@ export default function HlsVideoPlayer({ const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState(); const [controls, setControls] = useState(isMobile); const [controlsOpen, setControlsOpen] = useState(false); + const [isSnapshotLoading, setIsSnapshotLoading] = useState(false); const [zoomScale, setZoomScale] = useState(1.0); const [videoDimensions, setVideoDimensions] = useState<{ width: number; @@ -287,6 +290,21 @@ export default function HlsVideoPlayer({ return currentTime + inpointOffset; }, [videoRef, inpointOffset]); + const handleSnapshot = useCallback(async () => { + const frameTime = getVideoTime(); + + if (!frameTime || !onSnapshot) { + return; + } + + setIsSnapshotLoading(true); + try { + await onSnapshot(frameTime); + } finally { + setIsSnapshotLoading(false); + } + }, [getVideoTime, onSnapshot]); + return ( void; onUploadFrame?: () => void; getSnapshotUrl?: () => string | undefined; + onSnapshot?: () => void; + snapshotLoading?: boolean; toggleFullscreen?: () => void; containerRef?: React.MutableRefObject; }; @@ -95,6 +100,8 @@ export default function VideoControls({ onSetPlaybackRate, onUploadFrame, getSnapshotUrl, + onSnapshot, + snapshotLoading = false, toggleFullscreen, containerRef, }: VideoControlsProps) { @@ -295,6 +302,25 @@ export default function VideoControls({ fullscreen={fullscreen} /> )} + {features.snapshot && onSnapshot && ( + ) => { + e.stopPropagation(); + + if (snapshotLoading) { + return; + } + + onSnapshot(); + }} + /> + )} {features.fullscreen && toggleFullscreen && (
{fullscreen ? : } diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 5b864aea5f..1578f4d1d7 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -19,12 +19,18 @@ import { TimeRange } from "@/types/timeline"; 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"; import { calculateInpointOffset, calculateSeekPosition, } from "@/utils/videoUtil"; +import { + downloadSnapshot, + generateSnapshotFilename, + grabVideoSnapshot, +} from "@/utils/snapshotUtil"; import { isFirefox } from "react-device-detect"; /** @@ -68,7 +74,7 @@ export default function DynamicVideoPlayer({ containerRef, transformedOverlay, }: DynamicVideoPlayerProps) { - const { t } = useTranslation(["components/player"]); + const { t } = useTranslation(["components/player", "views/live"]); const apiHost = useApiHost(); const { data: config } = useSWR("config"); @@ -196,6 +202,34 @@ export default function DynamicVideoPlayer({ [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 const recordingParams = useMemo( @@ -328,6 +362,7 @@ export default function DynamicVideoPlayer({ setFullResolution={setFullResolution} onUploadFrame={onUploadFrameToPlus} getSnapshotUrl={getSnapshotUrlForPlus} + onSnapshot={onDownloadSnapshot} toggleFullscreen={toggleFullscreen} onError={(error) => { if (error == "stalled" && !isScrubbing) { diff --git a/web/src/utils/snapshotUtil.ts b/web/src/utils/snapshotUtil.ts index c88433d45b..a1067f5e91 100644 --- a/web/src/utils/snapshotUtil.ts +++ b/web/src/utils/snapshotUtil.ts @@ -97,17 +97,27 @@ export function downloadSnapshot(dataUrl: string, filename: string): void { } } -export function generateSnapshotFilename(cameraName: string): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5); +export function generateSnapshotFilename( + cameraName: string, + timestampSeconds?: number, +): string { + // Live snapshots use the current time, while History snapshots pass the + // playback timestamp so the filename matches the moment being viewed. + const date = + typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds) + ? new Date(timestampSeconds * 1000) + : new Date(); + const timestamp = date.toISOString().replace(/[:.]/g, "-").slice(0, -5); return `${cameraName}_snapshot_${timestamp}.jpg`; } -export async function grabVideoSnapshot(): Promise { +export async function grabVideoSnapshot( + targetVideo?: HTMLVideoElement | null, +): Promise { try { - // Find the video element in the player - const videoElement = document.querySelector( - "#player-container video", - ) as HTMLVideoElement; + const videoElement = + targetVideo ?? + (document.querySelector("#player-container video") as HTMLVideoElement); if (!videoElement) { return {