diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 7d762912c..7e01b0588 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -22,6 +22,11 @@ import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; import { useTranslation } from "react-i18next"; import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay"; import { useIsAdmin } from "@/hooks/use-is-admin"; +import { + downloadSnapshot, + generateSnapshotFilename, + grabVideoSnapshot, +} from "@/utils/snapshotUtil"; // Android native hls does not seek correctly const USE_NATIVE_HLS = false; @@ -85,7 +90,7 @@ export default function HlsVideoPlayer({ currentTimeOverride, transformedOverlay, }: HlsVideoPlayerProps) { - const { t } = useTranslation("components/player"); + const { t } = useTranslation(["components/player", "views/live"]); const { data: config } = useSWR("config"); const isAdmin = useIsAdmin(); @@ -226,6 +231,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; @@ -271,6 +277,35 @@ export default function HlsVideoPlayer({ return currentTime + inpointOffset; }, [videoRef, inpointOffset]); + const handleSnapshot = useCallback(async () => { + setIsSnapshotLoading(true); + try { + const frameTime = getVideoTime(); + const result = await grabVideoSnapshot(videoRef.current); + + if (result.success) { + downloadSnapshot( + result.data.dataUrl, + generateSnapshotFilename( + camera ?? "recording", + currentTime ?? frameTime, + config?.ui?.timezone, + ), + ); + toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), { + position: "top-center", + }); + } else { + toast.error(t("snapshot.captureFailed", { ns: "views/live" }), { + position: "top-center", + }); + } + } finally { + setIsSnapshotLoading(false); + } + }, [camera, config?.ui?.timezone, currentTime, getVideoTime, t, videoRef]); + const onSnapshot = camera ? handleSnapshot : undefined; + return ( void; onSetPlaybackRate: (rate: number) => void; onUploadFrame?: () => void; + onSnapshot?: () => void; + snapshotLoading?: boolean; + snapshotTitle?: string; toggleFullscreen?: () => void; containerRef?: React.MutableRefObject; }; @@ -92,6 +98,9 @@ export default function VideoControls({ onSeek, onSetPlaybackRate, onUploadFrame, + onSnapshot, + snapshotLoading = false, + snapshotTitle, toggleFullscreen, containerRef, }: VideoControlsProps) { @@ -292,6 +301,26 @@ export default function VideoControls({ fullscreen={fullscreen} /> )} + {features.snapshot && onSnapshot && ( + ) => { + e.stopPropagation(); + if (snapshotLoading) { + return; + } + onSnapshot(); + }} + /> + )} {features.fullscreen && toggleFullscreen && (
{fullscreen ? : } diff --git a/web/src/utils/snapshotUtil.ts b/web/src/utils/snapshotUtil.ts index c88433d45..c35450349 100644 --- a/web/src/utils/snapshotUtil.ts +++ b/web/src/utils/snapshotUtil.ts @@ -1,4 +1,5 @@ import { baseUrl } from "@/api/baseUrl"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; type SnapshotResponse = { dataUrl: string; @@ -97,17 +98,29 @@ 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, + timezone?: string, +): string { + const seconds = + typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds) + ? timestampSeconds + : Date.now() / 1000; + const timestamp = formatUnixTimestampToDateTime(seconds, { + timezone, + date_format: "yyyy-MM-dd'T'HH-mm-ss", + }); 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 {