mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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
This commit is contained in:
parent
559e6910c4
commit
4e800e19ff
@ -108,7 +108,7 @@ export default function ReviewFilterGroup({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center gap-2">
|
||||||
{filters.includes("cameras") && (
|
{filters.includes("cameras") && (
|
||||||
<CamerasFilterButton
|
<CamerasFilterButton
|
||||||
allCameras={filterValues.cameras}
|
allCameras={filterValues.cameras}
|
||||||
@ -171,8 +171,12 @@ function CamerasFilterButton({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button size="sm" className="mx-1 capitalize" variant="secondary">
|
<Button
|
||||||
<FaVideo className="md:mr-[10px] text-muted-foreground" />
|
className="flex items-center gap-2 capitalize"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<FaVideo className="text-muted-foreground" />
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
{selectedCameras == undefined
|
{selectedCameras == undefined
|
||||||
? "All Cameras"
|
? "All Cameras"
|
||||||
@ -319,8 +323,8 @@ function CalendarFilterButton({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button size="sm" className="mx-1" variant="secondary">
|
<Button size="sm" className="flex items-center gap-2" variant="secondary">
|
||||||
<FaCalendarAlt className="md:mr-[10px] text-muted-foreground" />
|
<FaCalendarAlt className="text-muted-foreground" />
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
{day == undefined ? "Last 24 Hours" : selectedDate}
|
{day == undefined ? "Last 24 Hours" : selectedDate}
|
||||||
</div>
|
</div>
|
||||||
@ -367,15 +371,15 @@ function CalendarFilterButton({
|
|||||||
type GeneralFilterButtonProps = {
|
type GeneralFilterButtonProps = {
|
||||||
allLabels: string[];
|
allLabels: string[];
|
||||||
selectedLabels: string[] | undefined;
|
selectedLabels: string[] | undefined;
|
||||||
updateLabelFilter: (labels: string[] | undefined) => void;
|
|
||||||
showReviewed?: 0 | 1;
|
showReviewed?: 0 | 1;
|
||||||
|
updateLabelFilter: (labels: string[] | undefined) => void;
|
||||||
setShowReviewed: (reviewed?: 0 | 1) => void;
|
setShowReviewed: (reviewed?: 0 | 1) => void;
|
||||||
};
|
};
|
||||||
function GeneralFilterButton({
|
function GeneralFilterButton({
|
||||||
allLabels,
|
allLabels,
|
||||||
selectedLabels,
|
selectedLabels,
|
||||||
updateLabelFilter,
|
|
||||||
showReviewed,
|
showReviewed,
|
||||||
|
updateLabelFilter,
|
||||||
setShowReviewed,
|
setShowReviewed,
|
||||||
}: GeneralFilterButtonProps) {
|
}: GeneralFilterButtonProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -385,12 +389,90 @@ function GeneralFilterButton({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button size="sm" className="ml-1" variant="secondary">
|
<Button size="sm" className="flex items-center gap-2" variant="secondary">
|
||||||
<FaFilter className="md:mr-[10px] text-muted-foreground" />
|
<FaFilter className="text-muted-foreground" />
|
||||||
<div className="hidden md:block">Filter</div>
|
<div className="hidden md:block">Filter</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
const content = (
|
const content = (
|
||||||
|
<GeneralFilterContent
|
||||||
|
allLabels={allLabels}
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
currentLabels={currentLabels}
|
||||||
|
showReviewed={showReviewed}
|
||||||
|
reviewed={reviewed}
|
||||||
|
updateLabelFilter={updateLabelFilter}
|
||||||
|
setShowReviewed={setShowReviewed}
|
||||||
|
setCurrentLabels={setCurrentLabels}
|
||||||
|
setReviewed={setReviewed}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setReviewed(showReviewed ?? 0);
|
||||||
|
setCurrentLabels(selectedLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
||||||
|
{content}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setReviewed(showReviewed ?? 0);
|
||||||
|
setCurrentLabels(selectedLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||||
|
<PopoverContent side="left">{content}</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
<>
|
<>
|
||||||
<div className="flex p-2 justify-start items-center">
|
<div className="flex p-2 justify-start items-center">
|
||||||
<Switch
|
<Switch
|
||||||
@ -455,7 +537,7 @@ function GeneralFilterButton({
|
|||||||
updateLabelFilter(currentLabels);
|
updateLabelFilter(currentLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpen(false);
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
@ -474,44 +556,6 @@ function GeneralFilterButton({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setReviewed(showReviewed ?? 0);
|
|
||||||
setCurrentLabels(selectedLabels);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
|
||||||
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
|
||||||
{content}
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setReviewed(showReviewed ?? 0);
|
|
||||||
setCurrentLabels(selectedLabels);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
||||||
<PopoverContent side="left">{content}</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShowMotionOnlyButtonProps = {
|
type ShowMotionOnlyButtonProps = {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@ -23,6 +22,9 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
import ReviewActivityCalendar from "./ReviewActivityCalendar";
|
import ReviewActivityCalendar from "./ReviewActivityCalendar";
|
||||||
import { SelectSeparator } from "../ui/select";
|
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 = [
|
const EXPORT_OPTIONS = [
|
||||||
"1",
|
"1",
|
||||||
@ -53,8 +55,121 @@ export default function ExportDialog({
|
|||||||
setRange,
|
setRange,
|
||||||
setMode,
|
setMode,
|
||||||
}: ExportDialogProps) {
|
}: ExportDialogProps) {
|
||||||
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
|
||||||
const [name, setName] = useState("");
|
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 (
|
||||||
|
<>
|
||||||
|
<SaveExportOverlay
|
||||||
|
className="absolute top-8 left-1/2 -translate-x-1/2 z-50 pointer-events-none"
|
||||||
|
show={mode == "timeline"}
|
||||||
|
onSave={() => onStartExport()}
|
||||||
|
onCancel={() => setMode("none")}
|
||||||
|
/>
|
||||||
|
<Overlay
|
||||||
|
open={mode == "select"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setMode("none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trigger asChild>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setMode("select");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" />
|
||||||
|
{isDesktop && "Export"}
|
||||||
|
</Button>
|
||||||
|
</Trigger>
|
||||||
|
<Content
|
||||||
|
className={
|
||||||
|
isDesktop ? "sm:rounded-2xl" : "px-4 pb-4 mx-4 rounded-2xl"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExportContent
|
||||||
|
latestTime={latestTime}
|
||||||
|
currentTime={currentTime}
|
||||||
|
range={range}
|
||||||
|
name={name}
|
||||||
|
onStartExport={onStartExport}
|
||||||
|
setName={setName}
|
||||||
|
setRange={setRange}
|
||||||
|
setMode={setMode}
|
||||||
|
onCancel={() => setMode("none")}
|
||||||
|
/>
|
||||||
|
</Content>
|
||||||
|
</Overlay>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ExportOption>("1");
|
||||||
|
|
||||||
const onSelectTime = useCallback(
|
const onSelectTime = useCallback(
|
||||||
(option: ExportOption) => {
|
(option: ExportOption) => {
|
||||||
@ -93,136 +208,86 @@ export default function ExportDialog({
|
|||||||
[latestTime, setRange],
|
[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 (
|
return (
|
||||||
<Dialog
|
<div className="w-full">
|
||||||
open={mode == "select"}
|
{isDesktop && (
|
||||||
onOpenChange={(open) => {
|
<>
|
||||||
if (!open) {
|
<DialogHeader>
|
||||||
setMode("none");
|
<DialogTitle>Export</DialogTitle>
|
||||||
}
|
</DialogHeader>
|
||||||
}}
|
<SelectSeparator className="bg-secondary" />
|
||||||
>
|
</>
|
||||||
<DialogTrigger asChild>
|
)}
|
||||||
|
<RadioGroup
|
||||||
|
className={`flex flex-col gap-3 ${isDesktop ? "" : "mt-4"}`}
|
||||||
|
onValueChange={(value) => onSelectTime(value as ExportOption)}
|
||||||
|
>
|
||||||
|
{EXPORT_OPTIONS.map((opt) => {
|
||||||
|
return (
|
||||||
|
<div key={opt} className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
className={
|
||||||
|
opt == selectedOption
|
||||||
|
? "from-selected/50 to-selected/90 text-selected bg-selected"
|
||||||
|
: "from-secondary/50 to-secondary/90 text-secondary bg-secondary"
|
||||||
|
}
|
||||||
|
id={opt}
|
||||||
|
value={opt}
|
||||||
|
/>
|
||||||
|
<Label className="cursor-pointer capitalize" htmlFor={opt}>
|
||||||
|
{isNaN(parseInt(opt))
|
||||||
|
? opt == "timeline"
|
||||||
|
? "Select from Timeline"
|
||||||
|
: `${opt}`
|
||||||
|
: `Last ${opt > "1" ? `${opt} Hours` : "Hour"}`}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
{selectedOption == "custom" && (
|
||||||
|
<CustomTimeSelector
|
||||||
|
latestTime={latestTime}
|
||||||
|
range={range}
|
||||||
|
setRange={setRange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
className="mt-3"
|
||||||
|
type="search"
|
||||||
|
placeholder="Name the Export"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
{isDesktop && <SelectSeparator className="bg-secondary" />}
|
||||||
|
<DialogFooter
|
||||||
|
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`p-2 cursor-pointer text-center ${isDesktop ? "" : "w-full"}`}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2"
|
className={isDesktop ? "" : "w-full"}
|
||||||
variant="secondary"
|
variant="select"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mode == "none") {
|
if (selectedOption == "timeline") {
|
||||||
setMode("select");
|
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
||||||
} else if (mode == "timeline") {
|
setMode("timeline");
|
||||||
|
} else {
|
||||||
onStartExport();
|
onStartExport();
|
||||||
|
setSelectedOption("1");
|
||||||
setMode("none");
|
setMode("none");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" />
|
{selectedOption == "timeline" ? "Select" : "Export"}
|
||||||
{mode != "timeline" ? "Export" : "Save"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogFooter>
|
||||||
<DialogContent className="sm:rounded-2xl">
|
</div>
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Export</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<SelectSeparator className="bg-secondary" />
|
|
||||||
<RadioGroup
|
|
||||||
className="flex flex-col gap-3"
|
|
||||||
onValueChange={(value) => onSelectTime(value as ExportOption)}
|
|
||||||
>
|
|
||||||
{EXPORT_OPTIONS.map((opt) => {
|
|
||||||
return (
|
|
||||||
<div key={opt} className="flex items-center gap-2">
|
|
||||||
<RadioGroupItem
|
|
||||||
className={
|
|
||||||
opt == selectedOption
|
|
||||||
? "from-selected/50 to-selected/90 text-selected bg-selected"
|
|
||||||
: "from-secondary/50 to-secondary/90 text-secondary bg-secondary"
|
|
||||||
}
|
|
||||||
id={opt}
|
|
||||||
value={opt}
|
|
||||||
/>
|
|
||||||
<Label className="cursor-pointer capitalize" htmlFor={opt}>
|
|
||||||
{isNaN(parseInt(opt))
|
|
||||||
? opt == "timeline"
|
|
||||||
? "Select from Timeline"
|
|
||||||
: `${opt}`
|
|
||||||
: `Last ${opt > "1" ? `${opt} Hours` : "Hour"}`}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</RadioGroup>
|
|
||||||
{selectedOption == "custom" && (
|
|
||||||
<CustomTimeSelector
|
|
||||||
latestTime={latestTime}
|
|
||||||
range={range}
|
|
||||||
setRange={setRange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
className="mt-2"
|
|
||||||
type="search"
|
|
||||||
placeholder="Name the Export"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<SelectSeparator className="bg-secondary" />
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose onClick={() => setMode("none")}>Cancel</DialogClose>
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedOption == "timeline") {
|
|
||||||
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
|
||||||
setMode("timeline");
|
|
||||||
} else {
|
|
||||||
onStartExport();
|
|
||||||
setMode("none");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedOption == "timeline" ? "Select" : "Export"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,7 +341,9 @@ function CustomTimeSelector({
|
|||||||
const [endOpen, setEndOpen] = useState(false);
|
const [endOpen, setEndOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-8 px-2 flex items-center gap-2 bg-secondary rounded-lg">
|
<div
|
||||||
|
className={`flex items-center bg-secondary rounded-lg ${isDesktop ? "mx-8 px-2 gap-2" : "pl-2 mt-3"}`}
|
||||||
|
>
|
||||||
<FaCalendarAlt />
|
<FaCalendarAlt />
|
||||||
<Popover
|
<Popover
|
||||||
open={startOpen}
|
open={startOpen}
|
||||||
@ -288,7 +355,9 @@ function CustomTimeSelector({
|
|||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
className={isDesktop ? "" : "text-xs"}
|
||||||
variant={startOpen ? "select" : "secondary"}
|
variant={startOpen ? "select" : "secondary"}
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStartOpen(true);
|
setStartOpen(true);
|
||||||
setEndOpen(false);
|
setEndOpen(false);
|
||||||
@ -347,7 +416,9 @@ function CustomTimeSelector({
|
|||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
className={isDesktop ? "" : "text-xs"}
|
||||||
variant={endOpen ? "select" : "secondary"}
|
variant={endOpen ? "select" : "secondary"}
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEndOpen(true);
|
setEndOpen(true);
|
||||||
setStartOpen(false);
|
setStartOpen(false);
|
||||||
|
46
web/src/components/overlay/MobileCameraDrawer.tsx
Normal file
46
web/src/components/overlay/MobileCameraDrawer.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { FaVideo } from "react-icons/fa";
|
||||||
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
|
type MobileCameraDrawerProps = {
|
||||||
|
allCameras: string[];
|
||||||
|
selected: string;
|
||||||
|
onSelectCamera: (cam: string) => void;
|
||||||
|
};
|
||||||
|
export default function MobileCameraDrawer({
|
||||||
|
allCameras,
|
||||||
|
selected,
|
||||||
|
onSelectCamera,
|
||||||
|
}: MobileCameraDrawerProps) {
|
||||||
|
const [cameraDrawer, setCameraDrawer] = useState(false);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button className="rounded-lg capitalize" size="sm" variant="secondary">
|
||||||
|
<FaVideo className="text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[75dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
|
||||||
|
{allCameras.map((cam) => (
|
||||||
|
<div
|
||||||
|
key={cam}
|
||||||
|
className={`w-full mx-4 py-2 text-center capitalize ${cam == selected ? "bg-secondary rounded-lg" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectCamera(cam);
|
||||||
|
setCameraDrawer(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cam.replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
289
web/src/components/overlay/MobileReviewSettingsDrawer.tsx
Normal file
289
web/src/components/overlay/MobileReviewSettingsDrawer.tsx
Normal file
@ -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<FrigateConfig>("config");
|
||||||
|
const [drawerMode, setDrawerMode] = useState<DrawerMode>("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<string[]>(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = new Set<string>();
|
||||||
|
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<string[] | undefined>(
|
||||||
|
filter?.labels,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content;
|
||||||
|
if (drawerMode == "select") {
|
||||||
|
content = (
|
||||||
|
<div className="w-full p-4 flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
className="w-full flex justify-center items-center gap-2"
|
||||||
|
onClick={() => setDrawerMode("export")}
|
||||||
|
>
|
||||||
|
<FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full flex justify-center items-center gap-2"
|
||||||
|
onClick={() => setDrawerMode("calendar")}
|
||||||
|
>
|
||||||
|
<FaCalendarAlt className="fill-muted-foreground" />
|
||||||
|
Calendar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full flex justify-center items-center gap-2"
|
||||||
|
onClick={() => setDrawerMode("filter")}
|
||||||
|
>
|
||||||
|
<FaFilter className="fill-muted-foreground" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (drawerMode == "export") {
|
||||||
|
content = (
|
||||||
|
<ExportContent
|
||||||
|
latestTime={latestTime}
|
||||||
|
currentTime={currentTime}
|
||||||
|
range={range}
|
||||||
|
name={name}
|
||||||
|
onStartExport={onStartExport}
|
||||||
|
setName={setName}
|
||||||
|
setRange={setRange}
|
||||||
|
setMode={(mode) => {
|
||||||
|
setMode(mode);
|
||||||
|
|
||||||
|
if (mode == "timeline") {
|
||||||
|
setDrawerMode("none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setMode("none");
|
||||||
|
setRange(undefined);
|
||||||
|
setDrawerMode("select");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (drawerMode == "calendar") {
|
||||||
|
content = (
|
||||||
|
<div className="w-full flex flex-col">
|
||||||
|
<div className="w-full h-8 relative">
|
||||||
|
<div
|
||||||
|
className="absolute left-0 text-selected"
|
||||||
|
onClick={() => setDrawerMode("select")}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
|
||||||
|
Calendar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReviewActivityCalendar
|
||||||
|
selectedDay={
|
||||||
|
filter?.after == undefined
|
||||||
|
? undefined
|
||||||
|
: new Date(filter.after * 1000)
|
||||||
|
}
|
||||||
|
onSelect={(day) => {
|
||||||
|
onUpdateFilter({
|
||||||
|
...filter,
|
||||||
|
after: day == undefined ? undefined : day.getTime() / 1000,
|
||||||
|
before: day == undefined ? undefined : getEndOfDayTimestamp(day),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SelectSeparator />
|
||||||
|
<div className="p-2 flex justify-center items-center">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateFilter({
|
||||||
|
...filter,
|
||||||
|
after: undefined,
|
||||||
|
before: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (drawerMode == "filter") {
|
||||||
|
content = (
|
||||||
|
<div className="w-full h-auto overflow-y-auto flex flex-col">
|
||||||
|
<div className="w-full h-8 mb-2 relative">
|
||||||
|
<div
|
||||||
|
className="absolute left-0 text-selected"
|
||||||
|
onClick={() => setDrawerMode("select")}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 text-muted-foreground">
|
||||||
|
Filter
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GeneralFilterContent
|
||||||
|
allLabels={allLabels.concat(allLabels)}
|
||||||
|
selectedLabels={filter?.labels}
|
||||||
|
currentLabels={currentLabels}
|
||||||
|
showReviewed={0}
|
||||||
|
reviewed={0}
|
||||||
|
setCurrentLabels={setCurrentLabels}
|
||||||
|
updateLabelFilter={(newLabels) =>
|
||||||
|
onUpdateFilter({ ...filter, labels: newLabels })
|
||||||
|
}
|
||||||
|
setShowReviewed={() => {}}
|
||||||
|
setReviewed={() => {}}
|
||||||
|
onClose={() => setDrawerMode("select")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SaveExportOverlay
|
||||||
|
className="absolute top-8 left-1/2 -translate-x-1/2 z-50 pointer-events-none"
|
||||||
|
show={mode == "timeline"}
|
||||||
|
onSave={() => onStartExport()}
|
||||||
|
onCancel={() => setMode("none")}
|
||||||
|
/>
|
||||||
|
<Drawer
|
||||||
|
open={drawerMode != "none"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDrawerMode("none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="rounded-lg capitalize"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setDrawerMode("select")}
|
||||||
|
>
|
||||||
|
<FaCog className="text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[80dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
|
||||||
|
{content}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <MobileTimelineDrawer
|
||||||
|
selected={timelineType ?? "timeline"}
|
||||||
|
onSelect={setTimelineType}
|
||||||
|
/>
|
||||||
|
*/
|
51
web/src/components/overlay/MobileTimelineDrawer.tsx
Normal file
51
web/src/components/overlay/MobileTimelineDrawer.tsx
Normal file
@ -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 (
|
||||||
|
<Drawer open={drawer} onOpenChange={setDrawer}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button className="rounded-lg capitalize" size="sm" variant="secondary">
|
||||||
|
<FaFlag className="text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[75dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">
|
||||||
|
<div
|
||||||
|
className={`w-full mx-4 py-2 text-center capitalize ${selected == "timeline" ? "bg-secondary rounded-lg" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect("timeline");
|
||||||
|
setDrawer(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Timeline
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-full mx-4 py-2 text-center capitalize ${selected == "events" ? "bg-secondary rounded-lg" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect("events");
|
||||||
|
setDrawer(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Events
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
45
web/src/components/overlay/SaveExportOverlay.tsx
Normal file
45
web/src/components/overlay/SaveExportOverlay.tsx
Normal file
@ -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 (
|
||||||
|
<div className={className}>
|
||||||
|
<div
|
||||||
|
className={`flex justify-center px-2 gap-2 items-center pointer-events-auto rounded-lg *:text-white ${
|
||||||
|
show ? "animate-in slide-in-from-top duration-500" : "invisible"
|
||||||
|
} text-center mt-5 mx-auto`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
variant="select"
|
||||||
|
size="sm"
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
<FaCompactDisc />
|
||||||
|
Save Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<LuX />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -88,29 +88,36 @@ export default function HlsVideoPlayer({
|
|||||||
const [controlsOpen, setControlsOpen] = useState(false);
|
const [controlsOpen, setControlsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<TransformWrapper minScale={1.0}>
|
||||||
className={`relative ${visible ? "visible" : "hidden"}`}
|
<div
|
||||||
onMouseOver={
|
className={`relative w-full ${className ?? ""} ${visible ? "visible" : "hidden"}`}
|
||||||
isDesktop
|
onMouseOver={
|
||||||
? () => {
|
isDesktop
|
||||||
setControls(true);
|
? () => {
|
||||||
}
|
setControls(true);
|
||||||
: undefined
|
}
|
||||||
}
|
: undefined
|
||||||
onMouseOut={
|
}
|
||||||
isDesktop
|
onMouseOut={
|
||||||
? () => {
|
isDesktop
|
||||||
setControls(controlsOpen);
|
? () => {
|
||||||
}
|
setControls(controlsOpen);
|
||||||
: undefined
|
}
|
||||||
}
|
: undefined
|
||||||
onClick={isDesktop ? undefined : () => setControls(!controls)}
|
}
|
||||||
>
|
onClick={isDesktop ? undefined : () => setControls(!controls)}
|
||||||
<TransformWrapper minScale={1.0}>
|
>
|
||||||
<TransformComponent>
|
<TransformComponent
|
||||||
|
wrapperStyle={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
contentStyle={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className={`${className ?? ""} bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
|
className={`size-full bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
|
||||||
preload="auto"
|
preload="auto"
|
||||||
autoPlay
|
autoPlay
|
||||||
controls={false}
|
controls={false}
|
||||||
@ -149,46 +156,47 @@ export default function HlsVideoPlayer({
|
|||||||
unsupportedErrorCodes.includes(e.target.error.code) &&
|
unsupportedErrorCodes.includes(e.target.error.code) &&
|
||||||
videoRef.current
|
videoRef.current
|
||||||
) {
|
) {
|
||||||
|
setLoadedMetadata(false);
|
||||||
setUseHlsCompat(true);
|
setUseHlsCompat(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TransformComponent>
|
</TransformComponent>
|
||||||
</TransformWrapper>
|
<VideoControls
|
||||||
<VideoControls
|
className="absolute bottom-5 left-1/2 -translate-x-1/2"
|
||||||
className="absolute bottom-5 left-1/2 -translate-x-1/2"
|
video={videoRef.current}
|
||||||
video={videoRef.current}
|
isPlaying={isPlaying}
|
||||||
isPlaying={isPlaying}
|
show={controls}
|
||||||
show={controls}
|
controlsOpen={controlsOpen}
|
||||||
controlsOpen={controlsOpen}
|
setControlsOpen={setControlsOpen}
|
||||||
setControlsOpen={setControlsOpen}
|
playbackRate={videoRef.current?.playbackRate ?? 1}
|
||||||
playbackRate={videoRef.current?.playbackRate ?? 1}
|
hotKeys={hotKeys}
|
||||||
hotKeys={hotKeys}
|
onPlayPause={(play) => {
|
||||||
onPlayPause={(play) => {
|
if (!videoRef.current) {
|
||||||
if (!videoRef.current) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (play) {
|
if (play) {
|
||||||
videoRef.current.play();
|
videoRef.current.play();
|
||||||
} else {
|
} else {
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSeek={(diff) => {
|
onSeek={(diff) => {
|
||||||
const currentTime = videoRef.current?.currentTime;
|
const currentTime = videoRef.current?.currentTime;
|
||||||
|
|
||||||
if (!videoRef.current || !currentTime) {
|
if (!videoRef.current || !currentTime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
||||||
}}
|
}}
|
||||||
onSetPlaybackRate={(rate) =>
|
onSetPlaybackRate={(rate) =>
|
||||||
videoRef.current ? (videoRef.current.playbackRate = rate) : null
|
videoRef.current ? (videoRef.current.playbackRate = rate) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</TransformWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
|||||||
import { DynamicVideoController } from "./DynamicVideoController";
|
import { DynamicVideoController } from "./DynamicVideoController";
|
||||||
import HlsVideoPlayer from "../HlsVideoPlayer";
|
import HlsVideoPlayer from "../HlsVideoPlayer";
|
||||||
import { TimeRange, Timeline } from "@/types/timeline";
|
import { TimeRange, Timeline } from "@/types/timeline";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamically switches between video playback and scrubbing preview player.
|
* Dynamically switches between video playback and scrubbing preview player.
|
||||||
@ -54,7 +55,7 @@ export default function DynamicVideoPlayer({
|
|||||||
if (aspectRatio > 2) {
|
if (aspectRatio > 2) {
|
||||||
return "";
|
return "";
|
||||||
} else if (aspectRatio < 16 / 9) {
|
} else if (aspectRatio < 16 / 9) {
|
||||||
return "aspect-tall";
|
return isDesktop ? "" : "aspect-tall";
|
||||||
} else {
|
} else {
|
||||||
return "aspect-video";
|
return "aspect-video";
|
||||||
}
|
}
|
||||||
@ -168,9 +169,9 @@ export default function DynamicVideoPlayer({
|
|||||||
}, [controller, recordings]);
|
}, [controller, recordings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className ?? ""}`}>
|
<div className={`w-full relative ${className ?? ""}`}>
|
||||||
<HlsVideoPlayer
|
<HlsVideoPlayer
|
||||||
className={`w-full ${grow ?? ""}`}
|
className={isDesktop ? `w-full ${grow}` : "max-h-[50dvh]"}
|
||||||
videoRef={playerRef}
|
videoRef={playerRef}
|
||||||
visible={!(isScrubbing || isLoading)}
|
visible={!(isScrubbing || isLoading)}
|
||||||
currentSource={source}
|
currentSource={source}
|
||||||
@ -194,7 +195,7 @@ export default function DynamicVideoPlayer({
|
|||||||
)}
|
)}
|
||||||
</HlsVideoPlayer>
|
</HlsVideoPlayer>
|
||||||
<PreviewPlayer
|
<PreviewPlayer
|
||||||
className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${grow}`}
|
className={`${isScrubbing || isLoading ? "visible" : "hidden"} ${isDesktop ? `w-full ${grow}` : "max-h-[50dvh]"}`}
|
||||||
camera={camera}
|
camera={camera}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
cameraPreviews={cameraPreviews}
|
cameraPreviews={cameraPreviews}
|
||||||
|
@ -24,3 +24,5 @@ export type Timeline = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TimeRange = { before: number; after: number };
|
export type TimeRange = { before: number; after: number };
|
||||||
|
|
||||||
|
export type TimelineType = "timeline" | "events";
|
||||||
|
@ -496,7 +496,7 @@ function DetectionReview({
|
|||||||
>
|
>
|
||||||
{filter?.before == undefined && (
|
{filter?.before == undefined && (
|
||||||
<NewReviewData
|
<NewReviewData
|
||||||
className="absolute w-full z-50 pointer-events-none"
|
className="absolute left-1/2 -translate-x-1/2 z-50 pointer-events-none"
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
reviewItems={currentItems}
|
reviewItems={currentItems}
|
||||||
itemsToReview={itemsToReview}
|
itemsToReview={itemsToReview}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import ReviewCard from "@/components/card/ReviewCard";
|
import ReviewCard from "@/components/card/ReviewCard";
|
||||||
import FilterCheckBox from "@/components/filter/FilterCheckBox";
|
|
||||||
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
||||||
import ExportDialog from "@/components/overlay/ExportDialog";
|
import ExportDialog from "@/components/overlay/ExportDialog";
|
||||||
import PreviewPlayer, {
|
import PreviewPlayer, {
|
||||||
@ -9,7 +8,6 @@ import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideo
|
|||||||
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
|
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
|
||||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
import { ExportMode } from "@/types/filter";
|
import { ExportMode } from "@/types/filter";
|
||||||
@ -31,15 +29,17 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { FaCircle, FaVideo } from "react-icons/fa";
|
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange, TimelineType } from "@/types/timeline";
|
||||||
|
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
||||||
|
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
||||||
|
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
|
|
||||||
const SEGMENT_DURATION = 30;
|
const SEGMENT_DURATION = 30;
|
||||||
type TimelineType = "timeline" | "events";
|
|
||||||
|
|
||||||
type RecordingViewProps = {
|
type RecordingViewProps = {
|
||||||
startCamera: string;
|
startCamera: string;
|
||||||
@ -208,10 +208,14 @@ export function RecordingView({
|
|||||||
}, [config, mainCamera]);
|
}, [config, mainCamera]);
|
||||||
|
|
||||||
const grow = useMemo(() => {
|
const grow = useMemo(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
if (mainCameraAspect == "wide") {
|
if (mainCameraAspect == "wide") {
|
||||||
return "w-full aspect-wide";
|
return "w-full aspect-wide";
|
||||||
} else if (isDesktop && mainCameraAspect == "tall") {
|
} else if (isDesktop && mainCameraAspect == "tall") {
|
||||||
return "h-full aspect-tall";
|
return "h-full aspect-tall flex flex-col justify-center";
|
||||||
} else {
|
} else {
|
||||||
return "w-full aspect-video";
|
return "w-full aspect-video";
|
||||||
}
|
}
|
||||||
@ -220,59 +224,50 @@ export function RecordingView({
|
|||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="size-full flex flex-col">
|
<div ref={contentRef} className="size-full flex flex-col">
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<div className={`w-full h-10 flex items-center justify-between pr-1`}>
|
<div
|
||||||
<Button className="rounded-lg" onClick={() => navigate(-1)}>
|
className={`w-full h-10 px-2 relative flex items-center justify-between`}
|
||||||
<IoMdArrowRoundBack className="size-5 mr-[10px]" />
|
>
|
||||||
Back
|
{isMobile && (
|
||||||
|
<Logo className="absolute top-1 inset-x-1/2 -translate-x-1/2 h-8" />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2 rounded-lg"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5" size="small" />
|
||||||
|
{isDesktop && "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{isMobile && (
|
<MobileCameraDrawer
|
||||||
<Drawer>
|
allCameras={allCameras}
|
||||||
<DrawerTrigger asChild>
|
selected={mainCamera}
|
||||||
<Button
|
onSelectCamera={(cam) => {
|
||||||
className="rounded-lg capitalize flex items-center gap-2"
|
setPlaybackStart(currentTime);
|
||||||
size="sm"
|
setMainCamera(cam);
|
||||||
variant="secondary"
|
}}
|
||||||
>
|
|
||||||
<FaVideo className="text-muted-foreground" />
|
|
||||||
{mainCamera.replaceAll("_", " ")}
|
|
||||||
</Button>
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
|
||||||
{allCameras.map((cam) => (
|
|
||||||
<FilterCheckBox
|
|
||||||
key={cam}
|
|
||||||
CheckIcon={FaCircle}
|
|
||||||
iconClassName="size-2"
|
|
||||||
label={cam.replaceAll("_", " ")}
|
|
||||||
isChecked={cam == mainCamera}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
setPlaybackStart(currentTime);
|
|
||||||
setMainCamera(cam);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)}
|
|
||||||
<ExportDialog
|
|
||||||
camera={mainCamera}
|
|
||||||
currentTime={currentTime}
|
|
||||||
latestTime={timeRange.end}
|
|
||||||
mode={exportMode}
|
|
||||||
range={exportRange}
|
|
||||||
setRange={setExportRange}
|
|
||||||
setMode={setExportMode}
|
|
||||||
/>
|
|
||||||
<ReviewFilterGroup
|
|
||||||
filters={["date", "general"]}
|
|
||||||
reviewSummary={reviewSummary}
|
|
||||||
filter={filter}
|
|
||||||
onUpdateFilter={updateFilter}
|
|
||||||
motionOnly={false}
|
|
||||||
setMotionOnly={() => {}}
|
|
||||||
/>
|
/>
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
|
<ExportDialog
|
||||||
|
camera={mainCamera}
|
||||||
|
currentTime={currentTime}
|
||||||
|
latestTime={timeRange.end}
|
||||||
|
mode={exportMode}
|
||||||
|
range={exportRange}
|
||||||
|
setRange={setExportRange}
|
||||||
|
setMode={setExportMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isDesktop && (
|
||||||
|
<ReviewFilterGroup
|
||||||
|
filters={["date", "general"]}
|
||||||
|
reviewSummary={reviewSummary}
|
||||||
|
filter={filter}
|
||||||
|
onUpdateFilter={updateFilter}
|
||||||
|
motionOnly={false}
|
||||||
|
setMotionOnly={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isDesktop ? (
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
className="*:px-3 *:py-4 *:rounded-md"
|
className="*:px-3 *:py-4 *:rounded-md"
|
||||||
type="single"
|
type="single"
|
||||||
@ -297,12 +292,28 @@ export function RecordingView({
|
|||||||
<div className="">Events</div>
|
<div className="">Events</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
|
) : (
|
||||||
|
<MobileTimelineDrawer
|
||||||
|
selected={timelineType ?? "timeline"}
|
||||||
|
onSelect={setTimelineType}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MobileReviewSettingsDrawer
|
||||||
|
camera={mainCamera}
|
||||||
|
filter={filter}
|
||||||
|
currentTime={currentTime}
|
||||||
|
latestTime={timeRange.end}
|
||||||
|
mode={exportMode}
|
||||||
|
range={exportRange}
|
||||||
|
onUpdateFilter={updateFilter}
|
||||||
|
setRange={setExportRange}
|
||||||
|
setMode={setExportMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex h-full my-2 justify-center overflow-hidden ${isDesktop ? "" : "flex-col"}`}
|
className={`h-full flex my-2 justify-center overflow-hidden ${isDesktop ? "" : "flex-col gap-2"}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-wrap">
|
<div className="flex flex-1 flex-wrap">
|
||||||
<div
|
<div
|
||||||
@ -312,7 +323,7 @@ export function RecordingView({
|
|||||||
key={mainCamera}
|
key={mainCamera}
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
? `flex justify-center items mb-5 ${mainCameraAspect == "tall" ? "h-[96%]" : "w-[82%]"}`
|
? `flex justify-center mb-5 ${mainCameraAspect == "tall" ? "h-full" : "w-[78%]"}`
|
||||||
: `w-full ${mainCameraAspect == "wide" ? "" : "aspect-video"}`
|
: `w-full ${mainCameraAspect == "wide" ? "" : "aspect-video"}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -339,7 +350,7 @@ export function RecordingView({
|
|||||||
</div>
|
</div>
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<div
|
<div
|
||||||
className={`flex justify-center gap-2 ${mainCameraAspect == "tall" ? "h-full flex-col overflow-y-auto items-center" : "w-full overflow-x-auto"}`}
|
className={`flex gap-2 ${mainCameraAspect == "tall" ? "h-full w-[16%] flex-col overflow-y-auto" : "w-full justify-center overflow-x-auto"}`}
|
||||||
>
|
>
|
||||||
{allCameras.map((cam) => {
|
{allCameras.map((cam) => {
|
||||||
if (cam !== mainCamera) {
|
if (cam !== mainCamera) {
|
||||||
@ -347,7 +358,9 @@ export function RecordingView({
|
|||||||
<div key={cam}>
|
<div key={cam}>
|
||||||
<PreviewPlayer
|
<PreviewPlayer
|
||||||
className={
|
className={
|
||||||
mainCameraAspect == "tall" ? "" : "size-full"
|
mainCameraAspect == "tall"
|
||||||
|
? "size-full"
|
||||||
|
: "size-full"
|
||||||
}
|
}
|
||||||
camera={cam}
|
camera={cam}
|
||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
@ -369,36 +382,12 @@ export function RecordingView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isMobile && (
|
|
||||||
<ToggleGroup
|
|
||||||
className="py-2 *:px-3 *:py-4 *:rounded-md"
|
|
||||||
type="single"
|
|
||||||
size="sm"
|
|
||||||
value={timelineType}
|
|
||||||
onValueChange={(value: TimelineType) =>
|
|
||||||
value ? setTimelineType(value) : null
|
|
||||||
} // don't allow the severity to be unselected
|
|
||||||
>
|
|
||||||
<ToggleGroupItem
|
|
||||||
className={`${timelineType == "timeline" ? "" : "text-gray-500"}`}
|
|
||||||
value="timeline"
|
|
||||||
aria-label="Select timeline"
|
|
||||||
>
|
|
||||||
<div className="">Timeline</div>
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem
|
|
||||||
className={`${timelineType == "events" ? "" : "text-gray-500"}`}
|
|
||||||
value="events"
|
|
||||||
aria-label="Select events"
|
|
||||||
>
|
|
||||||
<div className="">Events</div>
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
)}
|
|
||||||
<Timeline
|
<Timeline
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
mainCamera={mainCamera}
|
mainCamera={mainCamera}
|
||||||
timelineType={timelineType ?? "timeline"}
|
timelineType={
|
||||||
|
(exportRange == undefined ? timelineType : "timeline") ?? "timeline"
|
||||||
|
}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
mainCameraReviewItems={mainCameraReviewItems}
|
mainCameraReviewItems={mainCameraReviewItems}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
@ -461,15 +450,17 @@ function Timeline({
|
|||||||
}
|
}
|
||||||
}, [exportRange, exportStart, exportEnd, setExportRange, setCurrentTime]);
|
}, [exportRange, exportStart, exportEnd, setExportRange, setCurrentTime]);
|
||||||
|
|
||||||
if (exportRange != undefined || timelineType == "timeline") {
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
className={`${
|
||||||
className={
|
isDesktop
|
||||||
isDesktop
|
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} mt-2 overflow-y-auto no-scrollbar`
|
||||||
? "w-[100px] mt-2 overflow-y-auto no-scrollbar"
|
: "flex-grow overflow-hidden"
|
||||||
: "flex-grow overflow-hidden"
|
} relative`}
|
||||||
}
|
>
|
||||||
>
|
<div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
|
||||||
|
<div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
|
||||||
|
{timelineType == "timeline" ? (
|
||||||
<MotionReviewTimeline
|
<MotionReviewTimeline
|
||||||
segmentDuration={30}
|
segmentDuration={30}
|
||||||
timestampSpread={15}
|
timestampSpread={15}
|
||||||
@ -490,30 +481,24 @@ function Timeline({
|
|||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
);
|
<div className="h-full flex flex-col gap-4 overflow-auto p-4 bg-secondary">
|
||||||
}
|
{mainCameraReviewItems.map((review) => {
|
||||||
|
if (review.severity == "significant_motion") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ReviewCard
|
||||||
className={`${isDesktop ? "w-60" : "w-full"} h-full relative p-4 flex flex-col gap-4 bg-secondary overflow-auto`}
|
key={review.id}
|
||||||
>
|
event={review}
|
||||||
<div className="absolute top-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-b from-secondary to-transparent pointer-events-none"></div>
|
currentTime={currentTime}
|
||||||
<div className="absolute bottom-0 inset-x-0 z-20 w-full h-[30px] bg-gradient-to-t from-secondary to-transparent pointer-events-none"></div>
|
onClick={() => setCurrentTime(review.start_time)}
|
||||||
{mainCameraReviewItems.map((review) => {
|
/>
|
||||||
if (review.severity == "significant_motion") {
|
);
|
||||||
return;
|
})}
|
||||||
}
|
</div>
|
||||||
|
)}
|
||||||
return (
|
|
||||||
<ReviewCard
|
|
||||||
key={review.id}
|
|
||||||
event={review}
|
|
||||||
currentTime={currentTime}
|
|
||||||
onClick={() => setCurrentTime(review.start_time)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user