From 4e800e19ffb796ace9450fda39103d76439f40b6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 27 Mar 2024 17:03:05 -0600 Subject: [PATCH] Mobile recordings redesign (#10711) * Only show back button text on desktop * Add mobile camera drawer to separate component * Use bottom sheet for export on mobile * Add intermediary mobile bottom sheet * fix filter * Fix mobile layout jumping * Fix desktop vertical camera view * Fix horizontal camera list * Add overlay instead of using same button for timeline exports * Don't use native hls for now * Fix export bottom sheet * Fix scrolling * Simplify checks * Adjust hls compat approach * Fix events shadow * Make corners consistent * Make corners consistent * fix max drawer height * Use separate buttons for export control * Add icons * Fix list views * Fix new items to review * bottom padding on bottom sheets * bottom padding on bottom sheets --- .../components/filter/ReviewFilterGroup.tsx | 140 +++++--- web/src/components/overlay/ExportDialog.tsx | 317 +++++++++++------- .../components/overlay/MobileCameraDrawer.tsx | 46 +++ .../overlay/MobileReviewSettingsDrawer.tsx | 289 ++++++++++++++++ .../overlay/MobileTimelineDrawer.tsx | 51 +++ .../components/overlay/SaveExportOverlay.tsx | 45 +++ web/src/components/player/HlsVideoPlayer.tsx | 116 ++++--- .../player/dynamic/DynamicVideoPlayer.tsx | 9 +- web/src/types/timeline.ts | 2 + web/src/views/events/EventView.tsx | 2 +- web/src/views/events/RecordingView.tsx | 221 ++++++------ 11 files changed, 890 insertions(+), 348 deletions(-) create mode 100644 web/src/components/overlay/MobileCameraDrawer.tsx create mode 100644 web/src/components/overlay/MobileReviewSettingsDrawer.tsx create mode 100644 web/src/components/overlay/MobileTimelineDrawer.tsx create mode 100644 web/src/components/overlay/SaveExportOverlay.tsx diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 4ebad426f..52809d7b4 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -108,7 +108,7 @@ export default function ReviewFilterGroup({ ); return ( -
+
{filters.includes("cameras") && ( - + ); const content = ( + setOpen(false)} + /> + ); + + if (isMobile) { + return ( + { + if (!open) { + setReviewed(showReviewed ?? 0); + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setReviewed(showReviewed ?? 0); + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type GeneralFilterContentProps = { + allLabels: string[]; + selectedLabels: string[] | undefined; + currentLabels: string[] | undefined; + showReviewed?: 0 | 1; + reviewed: 0 | 1; + updateLabelFilter: (labels: string[] | undefined) => void; + setCurrentLabels: (labels: string[] | undefined) => void; + setShowReviewed: (reviewed?: 0 | 1) => void; + setReviewed: (reviewed: 0 | 1) => void; + onClose: () => void; +}; +export function GeneralFilterContent({ + allLabels, + selectedLabels, + currentLabels, + showReviewed, + reviewed, + updateLabelFilter, + setCurrentLabels, + setShowReviewed, + setReviewed, + onClose, +}: GeneralFilterContentProps) { + return ( <>
Apply @@ -474,44 +556,6 @@ function GeneralFilterButton({
); - - if (isMobile) { - return ( - { - if (!open) { - setReviewed(showReviewed ?? 0); - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - - return ( - { - if (!open) { - setReviewed(showReviewed ?? 0); - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - {content} - - ); } type ShowMotionOnlyButtonProps = { diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index a2c074bd6..b4b0ae65d 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { Dialog, - DialogClose, DialogContent, DialogFooter, DialogHeader, @@ -23,6 +22,9 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import ReviewActivityCalendar from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; +import { isDesktop } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import SaveExportOverlay from "./SaveExportOverlay"; const EXPORT_OPTIONS = [ "1", @@ -53,8 +55,121 @@ export default function ExportDialog({ setRange, setMode, }: ExportDialogProps) { - const [selectedOption, setSelectedOption] = useState("1"); const [name, setName] = useState(""); + const onStartExport = useCallback(() => { + if (!range) { + toast.error("No valid time range selected", { position: "top-center" }); + return; + } + + axios + .post(`export/${camera}/start/${range.after}/end/${range.before}`, { + playback: "realtime", + name, + }) + .then((response) => { + if (response.status == 200) { + toast.success( + "Successfully started export. View the file in the /exports folder.", + { position: "top-center" }, + ); + setName(""); + setRange(undefined); + setMode("none"); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error( + `Failed to start export: ${error.response.data.message}`, + { position: "top-center" }, + ); + } else { + toast.error(`Failed to start export: ${error.message}`, { + position: "top-center", + }); + } + }); + }, [camera, name, range, setRange, setName, setMode]); + + const Overlay = isDesktop ? Dialog : Drawer; + const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; + const Content = isDesktop ? DialogContent : DrawerContent; + + return ( + <> + onStartExport()} + onCancel={() => setMode("none")} + /> + { + if (!open) { + setMode("none"); + } + }} + > + + + + + setMode("none")} + /> + + + + ); +} + +type ExportContentProps = { + latestTime: number; + currentTime: number; + range?: TimeRange; + name: string; + onStartExport: () => void; + setName: (name: string) => void; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; + onCancel: () => void; +}; +export function ExportContent({ + latestTime, + currentTime, + range, + name, + onStartExport, + setName, + setRange, + setMode, + onCancel, +}: ExportContentProps) { + const [selectedOption, setSelectedOption] = useState("1"); const onSelectTime = useCallback( (option: ExportOption) => { @@ -93,136 +208,86 @@ export default function ExportDialog({ [latestTime, setRange], ); - const onStartExport = useCallback(() => { - if (!range) { - toast.error("No valid time range selected", { position: "top-center" }); - return; - } - - axios - .post(`export/${camera}/start/${range.after}/end/${range.before}`, { - playback: "realtime", - name, - }) - .then((response) => { - if (response.status == 200) { - toast.success( - "Successfully started export. View the file in the /exports folder.", - { position: "top-center" }, - ); - setName(""); - setRange(undefined); - setSelectedOption("1"); - } - }) - .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to start export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to start export: ${error.message}`, { - position: "top-center", - }); - } - }); - }, [camera, name, range, setRange]); - return ( - { - if (!open) { - setMode("none"); - } - }} - > - +
+ {isDesktop && ( + <> + + Export + + + + )} + onSelectTime(value as ExportOption)} + > + {EXPORT_OPTIONS.map((opt) => { + return ( +
+ + +
+ ); + })} +
+ {selectedOption == "custom" && ( + + )} + setName(e.target.value)} + /> + {isDesktop && } + +
+ Cancel +
- - - - Export - - - onSelectTime(value as ExportOption)} - > - {EXPORT_OPTIONS.map((opt) => { - return ( -
- - -
- ); - })} -
- {selectedOption == "custom" && ( - - )} - setName(e.target.value)} - /> - - - setMode("none")}>Cancel - - -
-
+ +
); } @@ -276,7 +341,9 @@ function CustomTimeSelector({ const [endOpen, setEndOpen] = useState(false); return ( -
+
+ + + {allCameras.map((cam) => ( +
{ + onSelectCamera(cam); + setCameraDrawer(false); + }} + > + {cam.replaceAll("_", " ")} +
+ ))} +
+ + ); +} diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx new file mode 100644 index 000000000..563237f7c --- /dev/null +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -0,0 +1,289 @@ +import { useCallback, useMemo, useState } from "react"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Button } from "../ui/button"; +import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; +import { TimeRange } from "@/types/timeline"; +import { ExportContent } from "./ExportDialog"; +import { ExportMode } from "@/types/filter"; +import ReviewActivityCalendar from "./ReviewActivityCalendar"; +import { SelectSeparator } from "../ui/select"; +import { ReviewFilter } from "@/types/review"; +import { getEndOfDayTimestamp } from "@/utils/dateUtil"; +import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { toast } from "sonner"; +import axios from "axios"; +import SaveExportOverlay from "./SaveExportOverlay"; +import { isMobile } from "react-device-detect"; + +const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; +type DrawerMode = "none" | "select" | "export" | "calendar" | "filter"; + +type MobileReviewSettingsDrawerProps = { + camera: string; + filter?: ReviewFilter; + latestTime: number; + currentTime: number; + range?: TimeRange; + mode: ExportMode; + onUpdateFilter: (filter: ReviewFilter) => void; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; +}; +export default function MobileReviewSettingsDrawer({ + camera, + filter, + latestTime, + currentTime, + range, + mode, + onUpdateFilter, + setRange, + setMode, +}: MobileReviewSettingsDrawerProps) { + const { data: config } = useSWR("config"); + const [drawerMode, setDrawerMode] = useState("none"); + + // exports + + const [name, setName] = useState(""); + const onStartExport = useCallback(() => { + if (!range) { + toast.error("No valid time range selected", { position: "top-center" }); + return; + } + + axios + .post(`export/${camera}/start/${range.after}/end/${range.before}`, { + playback: "realtime", + name, + }) + .then((response) => { + if (response.status == 200) { + toast.success( + "Successfully started export. View the file in the /exports folder.", + { position: "top-center" }, + ); + setName(""); + setRange(undefined); + setMode("none"); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error( + `Failed to start export: ${error.response.data.message}`, + { position: "top-center" }, + ); + } else { + toast.error(`Failed to start export: ${error.message}`, { + position: "top-center", + }); + } + }); + }, [camera, name, range, setRange, setName, setMode]); + + // filters + + const allLabels = useMemo(() => { + if (!config) { + return []; + } + + const labels = new Set(); + const cameras = filter?.cameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + const cameraConfig = config.cameras[camera]; + cameraConfig.objects.track.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + + if (cameraConfig.audio.enabled_in_config) { + cameraConfig.audio.listen.forEach((label) => { + labels.add(label); + }); + } + }); + + return [...labels].sort(); + }, [config, filter]); + const [currentLabels, setCurrentLabels] = useState( + filter?.labels, + ); + + if (!isMobile) { + return; + } + + let content; + if (drawerMode == "select") { + content = ( +
+ + + +
+ ); + } else if (drawerMode == "export") { + content = ( + { + setMode(mode); + + if (mode == "timeline") { + setDrawerMode("none"); + } + }} + onCancel={() => { + setMode("none"); + setRange(undefined); + setDrawerMode("select"); + }} + /> + ); + } else if (drawerMode == "calendar") { + content = ( +
+
+
setDrawerMode("select")} + > + Back +
+
+ Calendar +
+
+ { + onUpdateFilter({ + ...filter, + after: day == undefined ? undefined : day.getTime() / 1000, + before: day == undefined ? undefined : getEndOfDayTimestamp(day), + }); + }} + /> + +
+ +
+
+ ); + } else if (drawerMode == "filter") { + content = ( +
+
+
setDrawerMode("select")} + > + Back +
+
+ Filter +
+
+ + onUpdateFilter({ ...filter, labels: newLabels }) + } + setShowReviewed={() => {}} + setReviewed={() => {}} + onClose={() => setDrawerMode("select")} + /> +
+ ); + } + + return ( + <> + onStartExport()} + onCancel={() => setMode("none")} + /> + { + if (!open) { + setDrawerMode("none"); + } + }} + > + + + + + {content} + + + + ); +} + +/** + * + */ diff --git a/web/src/components/overlay/MobileTimelineDrawer.tsx b/web/src/components/overlay/MobileTimelineDrawer.tsx new file mode 100644 index 000000000..b29fde559 --- /dev/null +++ b/web/src/components/overlay/MobileTimelineDrawer.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Button } from "../ui/button"; +import { FaFlag } from "react-icons/fa"; +import { TimelineType } from "@/types/timeline"; +import { isMobile } from "react-device-detect"; + +type MobileTimelineDrawerProps = { + selected: TimelineType; + onSelect: (timeline: TimelineType) => void; +}; +export default function MobileTimelineDrawer({ + selected, + onSelect, +}: MobileTimelineDrawerProps) { + const [drawer, setDrawer] = useState(false); + + if (!isMobile) { + return; + } + + return ( + + + + + +
{ + onSelect("timeline"); + setDrawer(false); + }} + > + Timeline +
+
{ + onSelect("events"); + setDrawer(false); + }} + > + Events +
+
+
+ ); +} diff --git a/web/src/components/overlay/SaveExportOverlay.tsx b/web/src/components/overlay/SaveExportOverlay.tsx new file mode 100644 index 000000000..25d625d0d --- /dev/null +++ b/web/src/components/overlay/SaveExportOverlay.tsx @@ -0,0 +1,45 @@ +import { LuX } from "react-icons/lu"; +import { Button } from "../ui/button"; +import { FaCompactDisc } from "react-icons/fa"; + +type SaveExportOverlayProps = { + className: string; + show: boolean; + onSave: () => void; + onCancel: () => void; +}; +export default function SaveExportOverlay({ + className, + show, + onSave, + onCancel, +}: SaveExportOverlayProps) { + return ( +
+
+ + +
+
+ ); +} diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index e0d42d6e6..d667f4be0 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -88,29 +88,36 @@ export default function HlsVideoPlayer({ const [controlsOpen, setControlsOpen] = useState(false); return ( -
{ - setControls(true); - } - : undefined - } - onMouseOut={ - isDesktop - ? () => { - setControls(controlsOpen); - } - : undefined - } - onClick={isDesktop ? undefined : () => setControls(!controls)} - > - - + +
{ + setControls(true); + } + : undefined + } + onMouseOut={ + isDesktop + ? () => { + setControls(controlsOpen); + } + : undefined + } + onClick={isDesktop ? undefined : () => setControls(!controls)} + > + - - { - if (!videoRef.current) { - return; - } + { + if (!videoRef.current) { + return; + } - if (play) { - videoRef.current.play(); - } else { - videoRef.current.pause(); - } - }} - onSeek={(diff) => { - const currentTime = videoRef.current?.currentTime; + if (play) { + videoRef.current.play(); + } else { + videoRef.current.pause(); + } + }} + onSeek={(diff) => { + const currentTime = videoRef.current?.currentTime; - if (!videoRef.current || !currentTime) { - return; - } + if (!videoRef.current || !currentTime) { + return; + } - videoRef.current.currentTime = Math.max(0, currentTime + diff); - }} - onSetPlaybackRate={(rate) => - videoRef.current ? (videoRef.current.playbackRate = rate) : null - } - /> - {children} -
+ videoRef.current.currentTime = Math.max(0, currentTime + diff); + }} + onSetPlaybackRate={(rate) => + videoRef.current ? (videoRef.current.playbackRate = rate) : null + } + /> + {children} +
+ ); } diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 77e3b78a5..ae15fff8e 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -9,6 +9,7 @@ import PreviewPlayer, { PreviewController } from "../PreviewPlayer"; import { DynamicVideoController } from "./DynamicVideoController"; import HlsVideoPlayer from "../HlsVideoPlayer"; import { TimeRange, Timeline } from "@/types/timeline"; +import { isDesktop } from "react-device-detect"; /** * Dynamically switches between video playback and scrubbing preview player. @@ -54,7 +55,7 @@ export default function DynamicVideoPlayer({ if (aspectRatio > 2) { return ""; } else if (aspectRatio < 16 / 9) { - return "aspect-tall"; + return isDesktop ? "" : "aspect-tall"; } else { return "aspect-video"; } @@ -168,9 +169,9 @@ export default function DynamicVideoPlayer({ }, [controller, recordings]); return ( -
+
{filter?.before == undefined && ( { + if (isMobile) { + return ""; + } + if (mainCameraAspect == "wide") { return "w-full aspect-wide"; } else if (isDesktop && mainCameraAspect == "tall") { - return "h-full aspect-tall"; + return "h-full aspect-tall flex flex-col justify-center"; } else { return "w-full aspect-video"; } @@ -220,59 +224,50 @@ export function RecordingView({ return (
-
-
- {isMobile && ( - - - - - - {allCameras.map((cam) => ( - { - setPlaybackStart(currentTime); - setMainCamera(cam); - }} - /> - ))} - - - )} - - {}} + { + setPlaybackStart(currentTime); + setMainCamera(cam); + }} /> {isDesktop && ( + + )} + {isDesktop && ( + {}} + /> + )} + {isDesktop ? ( Events
+ ) : ( + )} +
@@ -339,7 +350,7 @@ export function RecordingView({
{isDesktop && (
{allCameras.map((cam) => { if (cam !== mainCamera) { @@ -347,7 +358,9 @@ export function RecordingView({
- {isMobile && ( - - value ? setTimelineType(value) : null - } // don't allow the severity to be unselected - > - -
Timeline
-
- -
Events
-
-
- )} + return ( +
+
+
+ {timelineType == "timeline" ? ( setScrubbing(scrubbing)} /> -
- ); - } + ) : ( +
+ {mainCameraReviewItems.map((review) => { + if (review.severity == "significant_motion") { + return; + } - return ( -
-
-
- {mainCameraReviewItems.map((review) => { - if (review.severity == "significant_motion") { - return; - } - - return ( - setCurrentTime(review.start_time)} - /> - ); - })} + return ( + setCurrentTime(review.start_time)} + /> + ); + })} +
+ )}
); }