diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 7d762912c..930588fc5 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; @@ -58,6 +63,7 @@ type HlsVideoPlayerProps = { isDetailMode?: boolean; camera?: string; currentTimeOverride?: number; + supportsSnapshot?: boolean; transformedOverlay?: ReactNode; }; @@ -83,9 +89,10 @@ export default function HlsVideoPlayer({ isDetailMode = false, camera, currentTimeOverride, + supportsSnapshot = false, transformedOverlay, }: HlsVideoPlayerProps) { - const { t } = useTranslation("components/player"); + const { t } = useTranslation(["components/player", "views/live"]); const { data: config } = useSWR("config"); const isAdmin = useIsAdmin(); @@ -264,13 +271,36 @@ export default function HlsVideoPlayer({ const getVideoTime = useCallback(() => { const currentTime = videoRef.current?.currentTime; - if (!currentTime) { + if (currentTime == undefined) { return undefined; } return currentTime + inpointOffset; }, [videoRef, inpointOffset]); + const handleSnapshot = useCallback(async () => { + 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", + }); + } + }, [camera, config?.ui?.timezone, currentTime, getVideoTime, t, videoRef]); + return ( { const frameTime = getVideoTime(); - if (frameTime && onUploadFrame) { + if (frameTime != undefined && onUploadFrame) { const resp = await onUploadFrame(frameTime); if (resp && resp.status == 200) { @@ -334,6 +365,8 @@ export default function HlsVideoPlayer({ } } }} + onSnapshot={supportsSnapshot ? handleSnapshot : undefined} + snapshotTitle={t("snapshot.takeSnapshot", { ns: "views/live" })} fullscreen={fullscreen} toggleFullscreen={toggleFullscreen} containerRef={containerRef} @@ -465,7 +498,7 @@ export default function HlsVideoPlayer({ const frameTime = getVideoTime(); - if (frameTime) { + if (frameTime != undefined) { onTimeUpdate(frameTime); } }} diff --git a/web/src/components/player/VideoControls.tsx b/web/src/components/player/VideoControls.tsx index 020c54d7b..2995f9fe3 100644 --- a/web/src/components/player/VideoControls.tsx +++ b/web/src/components/player/VideoControls.tsx @@ -34,12 +34,14 @@ import { import { cn } from "@/lib/utils"; import { FaCompress, FaExpand } from "react-icons/fa"; import { useTranslation } from "react-i18next"; +import { TbCameraDown } from "react-icons/tb"; type VideoControls = { volume?: boolean; seek?: boolean; playbackRate?: boolean; plusUpload?: boolean; + snapshot?: boolean; fullscreen?: boolean; }; @@ -48,6 +50,7 @@ const CONTROLS_DEFAULT: VideoControls = { seek: true, playbackRate: true, plusUpload: false, + snapshot: false, fullscreen: false, }; const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; @@ -71,6 +74,8 @@ type VideoControlsProps = { onSeek: (diff: number) => void; onSetPlaybackRate: (rate: number) => void; onUploadFrame?: () => void; + onSnapshot?: () => void; + snapshotTitle?: string; toggleFullscreen?: () => void; containerRef?: React.MutableRefObject; }; @@ -92,6 +97,8 @@ export default function VideoControls({ onSeek, onSetPlaybackRate, onUploadFrame, + onSnapshot, + snapshotTitle, toggleFullscreen, containerRef, }: VideoControlsProps) { @@ -292,6 +299,17 @@ export default function VideoControls({ fullscreen={fullscreen} /> )} + {features.snapshot && onSnapshot && ( + ) => { + e.stopPropagation(); + onSnapshot(); + }} + /> + )} {features.fullscreen && toggleFullscreen && (
{fullscreen ? : } diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index c8d95090d..e38f6bc2a 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -47,6 +47,7 @@ type DynamicVideoPlayerProps = { setFullResolution: React.Dispatch>; toggleFullscreen: () => void; containerRef?: React.MutableRefObject; + supportsSnapshot?: boolean; transformedOverlay?: ReactNode; }; export default function DynamicVideoPlayer({ @@ -66,6 +67,7 @@ export default function DynamicVideoPlayer({ setFullResolution, toggleFullscreen, containerRef, + supportsSnapshot = false, transformedOverlay, }: DynamicVideoPlayerProps) { const { t } = useTranslation(["components/player"]); @@ -321,6 +323,7 @@ export default function DynamicVideoPlayer({ isDetailMode={isDetailMode} camera={contextCamera || camera} currentTimeOverride={currentTime} + supportsSnapshot={supportsSnapshot} transformedOverlay={transformedOverlay} /> )} diff --git a/web/src/utils/snapshotUtil.ts b/web/src/utils/snapshotUtil.ts index c88433d45..f892bcdf7 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,34 @@ export function downloadSnapshot(dataUrl: string, filename: string): void { } } -export function generateSnapshotFilename(cameraName: string): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5); - return `${cameraName}_snapshot_${timestamp}.jpg`; +export function generateSnapshotFilename( + cameraName: string, + timestampSeconds?: number, + timezone?: string, +): string { + const seconds = timestampSeconds ?? Date.now() / 1000; + const timestamp = formatUnixTimestampToDateTime(seconds, { + timezone, + date_format: "yyyy-MM-dd'T'HH-mm-ss", + }); + + const safeTimestamp = + timestamp === "Invalid time" + ? new Date(seconds * 1000) + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, -5) + : timestamp; + return `${cameraName}_snapshot_${safeTimestamp}.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 { diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index a3466b256..21bb7ec49 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -839,6 +839,7 @@ export function RecordingView({ setFullResolution={setFullResolution} toggleFullscreen={toggleFullscreen} containerRef={mainLayoutRef} + supportsSnapshot={true} />
{isDesktop && effectiveCameras.length > 1 && (