forked from Github/frigate
Add ability to submit frames from recordings (#11212)
* add ability to parse and upload image from recording to frigate+ * Show dialog with current frame to be uploaded * Implement uploading image in frontend * Cleanup * Update title
This commit is contained in:
21
web/src/components/icons/FrigatePlusIcon.tsx
Normal file
21
web/src/components/icons/FrigatePlusIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import Logo from "../Logo";
|
||||
|
||||
type FrigatePlusIconProps = {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export default function FrigatePlusIcon({
|
||||
className,
|
||||
onClick,
|
||||
}: FrigatePlusIconProps) {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex items-center ${className ?? ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Logo className="size-full" />
|
||||
<LuPlus className="absolute size-2 translate-x-3 translate-y-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import { isAndroid, isDesktop, isMobile } from "react-device-detect";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import VideoControls from "./VideoControls";
|
||||
import { VideoResolutionType } from "@/types/live";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Android native hls does not seek correctly
|
||||
const USE_NATIVE_HLS = !isAndroid;
|
||||
@@ -29,6 +33,7 @@ type HlsVideoPlayerProps = {
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onPlaying?: () => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
||||
};
|
||||
export default function HlsVideoPlayer({
|
||||
videoRef,
|
||||
@@ -40,7 +45,10 @@ export default function HlsVideoPlayer({
|
||||
onTimeUpdate,
|
||||
onPlaying,
|
||||
setFullResolution,
|
||||
onUploadFrame,
|
||||
}: HlsVideoPlayerProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// playback
|
||||
|
||||
const hlsRef = useRef<Hls>();
|
||||
@@ -137,10 +145,15 @@ export default function HlsVideoPlayer({
|
||||
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
|
||||
video={videoRef.current}
|
||||
isPlaying={isPlaying}
|
||||
show={visible && controls}
|
||||
show={visible && (controls || controlsOpen)}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
controlsOpen={controlsOpen}
|
||||
features={{
|
||||
volume: true,
|
||||
seek: true,
|
||||
playbackRate: true,
|
||||
plusUpload: config?.plus?.enabled == true,
|
||||
}}
|
||||
setControlsOpen={setControlsOpen}
|
||||
setMuted={setMuted}
|
||||
playbackRate={videoRef.current?.playbackRate ?? 1}
|
||||
@@ -168,6 +181,21 @@ export default function HlsVideoPlayer({
|
||||
onSetPlaybackRate={(rate) =>
|
||||
videoRef.current ? (videoRef.current.playbackRate = rate) : null
|
||||
}
|
||||
onUploadFrame={async () => {
|
||||
if (videoRef.current && onUploadFrame) {
|
||||
const resp = await onUploadFrame(videoRef.current.currentTime);
|
||||
|
||||
if (resp && resp.status == 200) {
|
||||
toast.success("Successfully submitted frame to Frigate Plus", {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.success("Failed to submit frame to Frigate Plus", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import { LuPause, LuPlay } from "react-icons/lu";
|
||||
import {
|
||||
@@ -18,17 +18,30 @@ import {
|
||||
} from "react-icons/md";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { VolumeSlider } from "../ui/slider";
|
||||
import FrigatePlusIcon from "../icons/FrigatePlusIcon";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "../ui/alert-dialog";
|
||||
|
||||
type VideoControls = {
|
||||
volume?: boolean;
|
||||
seek?: boolean;
|
||||
playbackRate?: boolean;
|
||||
plusUpload?: boolean;
|
||||
};
|
||||
|
||||
const CONTROLS_DEFAULT: VideoControls = {
|
||||
volume: true,
|
||||
seek: true,
|
||||
playbackRate: true,
|
||||
plusUpload: false,
|
||||
};
|
||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||
|
||||
@@ -40,7 +53,6 @@ type VideoControlsProps = {
|
||||
show: boolean;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
controlsOpen?: boolean;
|
||||
playbackRates?: number[];
|
||||
playbackRate: number;
|
||||
hotKeys?: boolean;
|
||||
@@ -49,6 +61,7 @@ type VideoControlsProps = {
|
||||
onPlayPause: (play: boolean) => void;
|
||||
onSeek: (diff: number) => void;
|
||||
onSetPlaybackRate: (rate: number) => void;
|
||||
onUploadFrame?: () => void;
|
||||
};
|
||||
export default function VideoControls({
|
||||
className,
|
||||
@@ -58,7 +71,6 @@ export default function VideoControls({
|
||||
show,
|
||||
muted,
|
||||
volume,
|
||||
controlsOpen,
|
||||
playbackRates = PLAYBACK_RATE_DEFAULT,
|
||||
playbackRate,
|
||||
hotKeys = true,
|
||||
@@ -67,6 +79,7 @@ export default function VideoControls({
|
||||
onPlayPause,
|
||||
onSeek,
|
||||
onSetPlaybackRate,
|
||||
onUploadFrame,
|
||||
}: VideoControlsProps) {
|
||||
const onReplay = useCallback(
|
||||
(e: React.MouseEvent<SVGElement>) => {
|
||||
@@ -189,7 +202,6 @@ export default function VideoControls({
|
||||
)}
|
||||
{features.playbackRate && (
|
||||
<DropdownMenu
|
||||
open={controlsOpen == true}
|
||||
onOpenChange={(open) => {
|
||||
if (setControlsOpen) {
|
||||
setControlsOpen(open);
|
||||
@@ -214,6 +226,84 @@ export default function VideoControls({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{features.plusUpload && onUploadFrame && (
|
||||
<FrigatePlusUploadButton
|
||||
video={video}
|
||||
onClose={() => {
|
||||
if (setControlsOpen) {
|
||||
setControlsOpen(false);
|
||||
}
|
||||
}}
|
||||
onOpen={() => {
|
||||
onPlayPause(false);
|
||||
|
||||
if (setControlsOpen) {
|
||||
setControlsOpen(true);
|
||||
}
|
||||
}}
|
||||
onUploadFrame={onUploadFrame}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FrigatePlusUploadButtonProps = {
|
||||
video?: HTMLVideoElement | null;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onUploadFrame: () => void;
|
||||
};
|
||||
function FrigatePlusUploadButton({
|
||||
video,
|
||||
onOpen,
|
||||
onClose,
|
||||
onUploadFrame,
|
||||
}: FrigatePlusUploadButtonProps) {
|
||||
const [videoImg, setVideoImg] = useState<string>();
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<FrigatePlusIcon
|
||||
className="size-5 cursor-pointer"
|
||||
onClick={() => {
|
||||
onOpen();
|
||||
|
||||
if (video) {
|
||||
const videoSize = [video.clientWidth, video.clientHeight];
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = videoSize[0];
|
||||
canvas.height = videoSize[1];
|
||||
|
||||
const context = canvas?.getContext("2d");
|
||||
|
||||
if (context) {
|
||||
context.drawImage(video, 0, 0, videoSize[0], videoSize[1]);
|
||||
setVideoImg(canvas.toDataURL("image/webp"));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="md:max-w-[80%]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<img className="w-full object-contain" src={videoImg} />
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction className="bg-selected" onClick={onUploadFrame}>
|
||||
Submit
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export class DynamicVideoController {
|
||||
this.playerController.pause();
|
||||
}
|
||||
} else {
|
||||
console.log(`seek time is 0`);
|
||||
// no op
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import HlsVideoPlayer from "../HlsVideoPlayer";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { VideoResolutionType } from "@/types/live";
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Dynamically switches between video playback and scrubbing preview player.
|
||||
@@ -127,6 +128,18 @@ export default function DynamicVideoPlayer({
|
||||
[controller, onTimestampUpdate, isScrubbing, isLoading],
|
||||
);
|
||||
|
||||
const onUploadFrameToPlus = useCallback(
|
||||
(playTime: number) => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = controller.getProgress(playTime);
|
||||
return axios.post(`/${camera}/plus/${time}`);
|
||||
},
|
||||
[camera, controller],
|
||||
);
|
||||
|
||||
// state of playback player
|
||||
|
||||
const recordingParams = useMemo(() => {
|
||||
@@ -186,6 +199,7 @@ export default function DynamicVideoPlayer({
|
||||
setNoRecording(false);
|
||||
}}
|
||||
setFullResolution={setFullResolution}
|
||||
onUploadFrame={onUploadFrameToPlus}
|
||||
/>
|
||||
<PreviewPlayer
|
||||
className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${className}`}
|
||||
|
||||
@@ -975,10 +975,9 @@ function MotionReview({
|
||||
playbackRate: true,
|
||||
}}
|
||||
isPlaying={playing}
|
||||
show={!scrubbing}
|
||||
show={!scrubbing || controlsOpen}
|
||||
playbackRates={[4, 8, 12, 16]}
|
||||
playbackRate={playbackRate}
|
||||
controlsOpen={controlsOpen}
|
||||
setControlsOpen={setControlsOpen}
|
||||
onPlayPause={setPlaying}
|
||||
onSeek={(diff) => {
|
||||
|
||||
Reference in New Issue
Block a user