-
-
Notifications
You must be signed in to change notification settings - Fork 3k
Create snapshot functionality from History view #22639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 1 commit
c5e816f
84c1836
9e56382
bef2ecd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<FrigateConfig>("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 () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'll need some kind of loading guard that disables the button while the snapshot is being downloaded (like is done in LiveCameraView's snapshot handler), because a user could spam click the snapshot button, which in most cases will cause the browser to just download a bunch of black frames.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in the guarding features! |
||
| 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 ( | ||
| <TransformWrapper | ||
| minScale={1.0} | ||
|
|
@@ -294,6 +324,7 @@ export default function HlsVideoPlayer({ | |
| seek: true, | ||
| playbackRate: true, | ||
| plusUpload: isAdmin && config?.plus?.enabled == true, | ||
| snapshot: supportsSnapshot, | ||
| fullscreen: supportsFullscreen, | ||
| }} | ||
| setControlsOpen={setControlsOpen} | ||
|
|
@@ -320,7 +351,7 @@ export default function HlsVideoPlayer({ | |
| onUploadFrame={async () => { | ||
| 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); | ||
| } | ||
| }} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<HTMLDivElement | null>; | ||
| }; | ||
|
|
@@ -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 && ( | ||
| <TbCameraDown | ||
| aria-label={snapshotTitle} | ||
| className="size-5 cursor-pointer" | ||
| title={snapshotTitle} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd remove this. None of the other buttons here have a title. |
||
| onClick={(e: React.MouseEvent<SVGElement>) => { | ||
| e.stopPropagation(); | ||
| onSnapshot(); | ||
| }} | ||
| /> | ||
| )} | ||
| {features.fullscreen && toggleFullscreen && ( | ||
| <div className="cursor-pointer" onClick={toggleFullscreen}> | ||
| {fullscreen ? <FaCompress /> : <FaExpand />} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,7 @@ type DynamicVideoPlayerProps = { | |
| setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>; | ||
| toggleFullscreen: () => void; | ||
| containerRef?: React.MutableRefObject<HTMLDivElement | null>; | ||
| 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} | ||
| /> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SnapshotResult> { | ||
| export async function grabVideoSnapshot( | ||
| targetVideo?: HTMLVideoElement | null, | ||
| ): Promise<SnapshotResult> { | ||
| 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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change here seems unrelated to snapshots. Am I missing something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. That change was unrelated to snapshot scope. I reverted the logic back to its previous behavior so this PR only carries snapshot-specific changes.