From 0e6528a989edaab55712c454c596d9ffcf1bb755 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 12 Dec 2023 16:21:42 -0700 Subject: [PATCH] Add export page in to new web UI (#8929) --- web-new/src/components/card/ExportCard.tsx | 50 +++ web-new/src/components/player/VideoPlayer.tsx | 74 +++++ web-new/src/pages/Export.tsx | 313 ++++++++++++++++++ web-new/tsconfig.json | 2 +- 4 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 web-new/src/components/card/ExportCard.tsx create mode 100644 web-new/src/components/player/VideoPlayer.tsx diff --git a/web-new/src/components/card/ExportCard.tsx b/web-new/src/components/card/ExportCard.tsx new file mode 100644 index 000000000..9596f729c --- /dev/null +++ b/web-new/src/components/card/ExportCard.tsx @@ -0,0 +1,50 @@ +import { baseUrl } from "@/api/baseUrl"; +import ActivityIndicator from "../ui/activity-indicator"; +import { Card } from "../ui/card"; +import { LuPlay, LuTrash } from "react-icons/lu"; +import { Button } from "../ui/button"; + +type ExportProps = { + file: { + name: string; + }; + onSelect: (file: string) => void; + onDelete: (file: string) => void; +}; + +export default function ExportCard({ file, onSelect, onDelete }: ExportProps) { + return ( + + {file.name.startsWith("in_progress") ? ( + <> +
+ +
+
+ {file.name.substring(12, file.name.length - 4)} +
+ + ) : ( + <> + + + {file.name.substring(0, file.name.length - 4)} + + + + )} +
+ ); +} diff --git a/web-new/src/components/player/VideoPlayer.tsx b/web-new/src/components/player/VideoPlayer.tsx new file mode 100644 index 000000000..7e8cab137 --- /dev/null +++ b/web-new/src/components/player/VideoPlayer.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef, ReactElement } from "react"; +import videojs from 'video.js'; +import 'videojs-playlist'; +import 'video.js/dist/video-js.css'; +import Player from "video.js/dist/types/player"; + +type VideoPlayerProps = { + children?: ReactElement | ReactElement[], + options?: { + [key: string]: any + }, + seekOptions?: { + forward?: number, + backward?: number, + }, + onReady?: (player: Player) => void, + onDispose?: () => void, +} + +export default function VideoPlayer({ children, options, seekOptions = {forward:30, backward: 10}, onReady = (_) => {}, onDispose = () => {} }: VideoPlayerProps) { + const videoRef = useRef(null); + const playerRef = useRef(null); + + useEffect(() => { + const defaultOptions = { + controls: true, + controlBar: { + skipButtons: seekOptions, + }, + playbackRates: [0.5, 1, 2, 4, 8], + fluid: true, + }; + + + if (!videojs.browser.IS_FIREFOX) { + defaultOptions.playbackRates.push(16); + } + + // Make sure Video.js player is only initialized once + if (!playerRef.current) { + // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode. + const videoElement = document.createElement("video-js"); + + videoElement.classList.add('small-player'); + videoElement.classList.add('video-js'); + videoElement.classList.add('vjs-default-skin'); + videoRef.current.appendChild(videoElement); + + const player = playerRef.current = videojs(videoElement, { ...defaultOptions, ...options }, () => { + onReady && onReady(player); + }); + } + }, [options, videoRef]); + + // Dispose the Video.js player when the functional component unmounts + useEffect(() => { + const player = playerRef.current; + + return () => { + if (player && !player.isDisposed()) { + player.dispose(); + playerRef.current = null; + onDispose(); + } + }; + }, [playerRef]); + + return ( +
+
+ {children} +
+ ); + } \ No newline at end of file diff --git a/web-new/src/pages/Export.tsx b/web-new/src/pages/Export.tsx index a03c64668..879c22eee 100644 --- a/web-new/src/pages/Export.tsx +++ b/web-new/src/pages/Export.tsx @@ -1,9 +1,322 @@ +import { baseUrl } from "@/api/baseUrl"; +import ExportCard from "@/components/card/ExportCard"; +import VideoPlayer from "@/components/player/VideoPlayer"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenuRadioGroup, + DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuRadioItem, +} from "@/components/ui/dropdown-menu"; import Heading from "@/components/ui/heading"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { FrigateConfig } from "@/types/frigateConfig"; +import axios from "axios"; +import { format } from "date-fns"; +import { useCallback, useState } from "react"; +import { DateRange } from "react-day-picker"; +import useSWR from "swr"; + +type ExportItem = { + name: string; +}; function Export() { + const { data: config } = useSWR("config"); + const { data: exports, mutate } = useSWR( + "exports/", + (url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data) + ); + + // Export States + const [camera, setCamera] = useState(); + const [playback, setPlayback] = useState(); + const [message, setMessage] = useState({ text: "", error: false }); + + const currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); + + const [date, setDate] = useState({ + from: currentDate, + }); + const [startTime, setStartTime] = useState("00:00:00"); + const [endTime, setEndTime] = useState("23:59:59"); + + const [selectedClip, setSelectedClip] = useState(); + const [deleteClip, setDeleteClip] = useState(); + + const onHandleExport = () => { + if (camera == "select") { + setMessage({ text: "A camera needs to be selected.", error: true }); + return; + } + + if (playback == "select") { + setMessage({ + text: "A playback factor needs to be selected.", + error: true, + }); + return; + } + + if (!date?.from || !startTime || !endTime) { + setMessage({ + text: "A start and end time needs to be selected", + error: true, + }); + return; + } + + const startDate = new Date(date.from.getTime()); + const [startHour, startMin, startSec] = startTime.split(":"); + startDate.setHours( + parseInt(startHour), + parseInt(startMin), + parseInt(startSec), + 0 + ); + const start = startDate.getTime() / 1000; + const endDate = new Date((date.to || date.from).getTime()); + const [endHour, endMin, endSec] = endTime.split(":"); + endDate.setHours(parseInt(endHour), parseInt(endMin), parseInt(endSec), 0); + const end = endDate.getTime() / 1000; + + if (end <= start) { + setMessage({ + text: "The end time must be after the start time.", + error: true, + }); + return; + } + + axios + .post(`export/${camera}/start/${start}/end/${end}`, { playback }) + .then((response) => { + if (response.status == 200) { + setMessage({ + text: "Successfully started export. View the file in the /exports folder.", + error: false, + }); + } + + mutate(); + }) + .catch((error) => { + if (error.response?.data?.message) { + setMessage({ + text: `Failed to start export: ${error.response.data.message}`, + error: true, + }); + } else { + setMessage({ + text: `Failed to start export: ${error.message}`, + error: true, + }); + } + }); + }; + + const onHandleDelete = useCallback(() => { + if (!deleteClip) { + return; + } + + axios.delete(`export/${deleteClip}`).then((response) => { + if (response.status == 200) { + setDeleteClip(undefined); + mutate(); + } + }); + }, [deleteClip]); + return ( <> Export + + {message.text && ( +
+ {message.text} +
+ )} + + setDeleteClip(undefined)} + > + + + Delete Export + + Confirm deletion of {deleteClip}. + + + + Cancel + + + + + + setSelectedClip(undefined)} + > + + + Playback + + + + + +
+
+
+ + + + + + Select A Camera + + + {Object.keys(config?.cameras || {}).map((item) => ( + + {item.replaceAll("_", " ")} + + ))} + + + +
+ + + + + + + Select A Playback Factor + + + + + Realtime + + + Timelapse + + + + +
+
+ + + + + + +
+ setStartTime(e.target.value)} + /> + setEndTime(e.target.value)} + /> +
+
+
+
+ +
+
+ + {exports && ( + + Exports + {Object.values(exports).map((item) => ( + setSelectedClip(file)} + onDelete={(file) => setDeleteClip(file)} + /> + ))} + + )} +
); } diff --git a/web-new/tsconfig.json b/web-new/tsconfig.json index 7ef7c299a..0874f4123 100644 --- a/web-new/tsconfig.json +++ b/web-new/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2021.String"], "module": "ESNext", "skipLibCheck": true, "baseUrl": ".",