Redesign Recordings View (#10690)

* Use full width top bar

* Make each item in review filter group optional

* Remove export creation from export page

* Consolidate packages and fix opening recording from event

* Use common type for time range

* Move timeline to separate component

* Add events list view to recordings view

* Fix loading of images

* Fix incorrect labels

* use overlay state for selected timeline type

* Fix up for mobile view for now

* replace overlay state

* fix comparison

* remove unused
This commit is contained in:
Nicolas Mowen 2024-03-26 15:03:58 -06:00 committed by GitHub
parent 1cd374d3ad
commit 1377d33e25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 378 additions and 363 deletions

View File

@ -265,7 +265,14 @@ class ReviewSegmentMaintainer(threading.Thread):
self.frame_manager.close(frame_id)
elif len(motion) >= 20:
self.active_review_segments[camera] = PendingReviewSegment(
camera, frame_time, SeverityEnum.signification_motion, motion=motion
camera,
frame_time,
SeverityEnum.signification_motion,
detections=set(),
objects=set(),
sub_labels=set(),
motion=motion,
zones=set(),
)
def run(self) -> None:

View File

@ -8,11 +8,12 @@ import { ReviewSegment } from "@/types/review";
import { useNavigate } from "react-router-dom";
import { Skeleton } from "../ui/skeleton";
import { RecordingStartingPoint } from "@/types/record";
import axios from "axios";
type AnimatedEventThumbnailProps = {
type AnimatedEventCardProps = {
event: ReviewSegment;
};
export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
const { data: config } = useSWR<FrigateConfig>("config");
// interaction
@ -21,11 +22,15 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
const onOpenReview = useCallback(() => {
navigate("events", {
state: {
camera: event.camera,
startTime: event.start_time,
severity: event.severity,
} as RecordingStartingPoint,
recording: {
camera: event.camera,
startTime: event.start_time,
severity: event.severity,
} as RecordingStartingPoint,
},
});
axios.post(`reviews/viewed`, { ids: [event.id] });
}, [navigate, event]);
// image behavior

View File

@ -0,0 +1,73 @@
import { baseUrl } from "@/api/baseUrl";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil";
import { isSafari } from "react-device-detect";
import useSWR from "swr";
import TimeAgo from "../dynamic/TimeAgo";
import { useMemo } from "react";
import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
type ReviewCardProps = {
event: ReviewSegment;
currentTime: number;
onClick?: () => void;
};
export default function ReviewCard({
event,
currentTime,
onClick,
}: ReviewCardProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const formattedDate = useFormattedTimestamp(
event.start_time,
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
);
const isSelected = useMemo(
() => event.start_time <= currentTime && event.end_time >= currentTime,
[event, currentTime],
);
return (
<div
className="w-full flex flex-col gap-1.5 cursor-pointer"
onClick={onClick}
>
<ImageLoadingIndicator
className="size-full aspect-video"
imgLoaded={imgLoaded}
/>
<img
ref={imgRef}
className={`size-full rounded-lg ${isSelected ? "outline outline-3 outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`}
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"}
onLoad={() => {
onImgLoad();
}}
/>
<div className="flex justify-between items-center">
<div className="flex justify-evenly items-center gap-1">
{event.data.objects.map((object) => {
return getIconForLabel(object, "size-3 text-white");
})}
{event.data.audio.map((audio) => {
return getIconForLabel(audio, "size-3 text-white");
})}
{event.data.sub_labels?.map((sub) => {
return getIconForSubLabel(sub, "size-3 text-white");
})}
<div className="font-extra-light text-xs">{formattedDate}</div>
</div>
<TimeAgo
className="text-xs text-muted-foreground"
time={event.start_time * 1000}
dense
/>
</div>
</div>
);
}

View File

@ -22,7 +22,7 @@ export default function NewReviewData({
return false;
}
return reviewItems.length != itemsToReview;
return reviewItems.length < itemsToReview;
}, [reviewItems, itemsToReview]);
return (

View File

@ -1,6 +1,8 @@
import { FunctionComponent, useEffect, useMemo, useState } from "react";
interface IProp {
/** OPTIONAL: classname */
className?: string;
/** The time to calculate time-ago from */
time: number;
/** OPTIONAL: overwrite current time */
@ -73,6 +75,7 @@ const timeAgo = ({
};
const TimeAgo: FunctionComponent<IProp> = ({
className,
time,
manualRefreshInterval,
...rest
@ -105,6 +108,6 @@ const TimeAgo: FunctionComponent<IProp> = ({
[currentTime, rest, time],
);
return <span>{timeAgoValue}</span>;
return <span className={className}>{timeAgoValue}</span>;
};
export default TimeAgo;

View File

@ -10,7 +10,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
import { ReviewFilter, ReviewSummary } from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FaCalendarAlt, FaFilter, FaRunning, FaVideo } from "react-icons/fa";
@ -22,21 +22,29 @@ import FilterCheckBox from "./FilterCheckBox";
import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
const REVIEW_FILTERS = ["cameras", "date", "general", "motionOnly"] as const;
type ReviewFilters = (typeof REVIEW_FILTERS)[number];
const DEFAULT_REVIEW_FILTERS: ReviewFilters[] = [
"cameras",
"date",
"general",
"motionOnly",
];
type ReviewFilterGroupProps = {
filters?: ReviewFilters[];
reviewSummary?: ReviewSummary;
filter?: ReviewFilter;
onUpdateFilter: (filter: ReviewFilter) => void;
severity: ReviewSeverity;
motionOnly: boolean;
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function ReviewFilterGroup({
filters = DEFAULT_REVIEW_FILTERS,
reviewSummary,
filter,
onUpdateFilter,
severity,
motionOnly,
setMotionOnly,
}: ReviewFilterGroupProps) {
@ -101,27 +109,34 @@ export default function ReviewFilterGroup({
return (
<div className="flex justify-center">
<CamerasFilterButton
allCameras={filterValues.cameras}
groups={groups}
selectedCameras={filter?.cameras}
updateCameraFilter={(newCameras) => {
onUpdateFilter({ ...filter, cameras: newCameras });
}}
/>
<CalendarFilterButton
reviewSummary={reviewSummary}
day={
filter?.after == undefined ? undefined : new Date(filter.after * 1000)
}
updateSelectedDay={onUpdateSelectedDay}
/>
{severity == "significant_motion" ? (
{filters.includes("cameras") && (
<CamerasFilterButton
allCameras={filterValues.cameras}
groups={groups}
selectedCameras={filter?.cameras}
updateCameraFilter={(newCameras) => {
onUpdateFilter({ ...filter, cameras: newCameras });
}}
/>
)}
{filters.includes("date") && (
<CalendarFilterButton
reviewSummary={reviewSummary}
day={
filter?.after == undefined
? undefined
: new Date(filter.after * 1000)
}
updateSelectedDay={onUpdateSelectedDay}
/>
)}
{filters.includes("motionOnly") && (
<ShowMotionOnlyButton
motionOnly={motionOnly}
setMotionOnly={setMotionOnly}
/>
) : (
)}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
@ -293,7 +308,7 @@ type CalendarFilterButtonProps = {
day?: Date;
updateSelectedDay: (day?: Date) => void;
};
export function CalendarFilterButton({
function CalendarFilterButton({
reviewSummary,
day,
updateSelectedDay,

View File

@ -0,0 +1,20 @@
import { isSafari } from "react-device-detect";
import { Skeleton } from "../ui/skeleton";
export default function ImageLoadingIndicator({
className,
imgLoaded,
}: {
className?: string;
imgLoaded: boolean;
}) {
if (imgLoaded) {
return;
}
return isSafari ? (
<div className={`bg-gray-300 pointer-events-none ${className ?? ""}`} />
) : (
<Skeleton className={`pointer-events-none ${className ?? ""}`} />
);
}

View File

@ -14,11 +14,12 @@ import { isCurrentHour } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { isAndroid, isChrome, isMobile, isSafari } from "react-device-detect";
import { Skeleton } from "../ui/skeleton";
import { TimeRange } from "@/types/timeline";
type PreviewPlayerProps = {
className?: string;
camera: string;
timeRange: { start: number; end: number };
timeRange: TimeRange;
cameraPreviews: Preview[];
startTime?: number;
isScrubbing: boolean;
@ -37,7 +38,7 @@ export default function PreviewPlayer({
}: PreviewPlayerProps) {
const [currentHourFrame, setCurrentHourFrame] = useState<string>();
if (isCurrentHour(timeRange.end)) {
if (isCurrentHour(timeRange.before)) {
return (
<PreviewFramesPlayer
className={className}
@ -84,7 +85,7 @@ export abstract class PreviewController {
type PreviewVideoPlayerProps = {
className?: string;
camera: string;
timeRange: { start: number; end: number };
timeRange: TimeRange;
cameraPreviews: Preview[];
startTime?: number;
isScrubbing: boolean;
@ -148,8 +149,8 @@ function PreviewVideoPlayer({
return cameraPreviews.find(
(preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.start &&
Math.floor(preview.end) <= timeRange.end,
Math.round(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before,
);
// we only want to calculate this once
@ -179,8 +180,8 @@ function PreviewVideoPlayer({
const preview = cameraPreviews.find(
(preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.start &&
Math.floor(preview.end) <= timeRange.end,
Math.round(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before,
);
if (preview != currentPreview) {
@ -292,7 +293,7 @@ function PreviewVideoPlayer({
class PreviewVideoController extends PreviewController {
// main state
private previewRef: MutableRefObject<HTMLVideoElement | null>;
private timeRange: { start: number; end: number } | undefined = undefined;
private timeRange: TimeRange | undefined = undefined;
// preview
private preview: Preview | undefined = undefined;
@ -377,7 +378,7 @@ class PreviewVideoController extends PreviewController {
type PreviewFramesPlayerProps = {
className?: string;
camera: string;
timeRange: { start: number; end: number };
timeRange: TimeRange;
startTime?: number;
onControllerReady: (controller: PreviewController) => void;
onClick?: () => void;
@ -395,8 +396,8 @@ function PreviewFramesPlayer({
// frames data
const { data: previewFrames } = useSWR<string[]>(
`preview/${camera}/start/${Math.floor(timeRange.start)}/end/${Math.ceil(
timeRange.end,
`preview/${camera}/start/${Math.floor(timeRange.after)}/end/${Math.ceil(
timeRange.before,
)}/frames`,
{ revalidateOnFocus: false },
);
@ -457,7 +458,7 @@ function PreviewFramesPlayer({
}
if (!startTime) {
controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.start);
controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.after);
} else {
controller.scrubToTimestamp(startTime);
}

View File

@ -17,9 +17,9 @@ import { isFirefox, isMobile, isSafari } from "react-device-detect";
import Chip from "@/components/indicators/Chip";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useImageLoaded from "@/hooks/use-image-loaded";
import { Skeleton } from "../ui/skeleton";
import { useSwipeable } from "react-swipeable";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
type PreviewPlayerProps = {
review: ReviewSegment;
@ -187,11 +187,14 @@ export default function PreviewThumbnailPlayer({
/>
</div>
)}
<PreviewPlaceholder imgLoaded={imgLoaded} />
<ImageLoadingIndicator
className="absolute inset-0"
imgLoaded={imgLoaded}
/>
<div className={`${imgLoaded ? "visible" : "invisible"}`}>
<img
ref={imgRef}
className={`w-full h-full transition-opacity ${
className={`size-full transition-opacity ${
playingBack ? "opacity-0" : "opacity-100"
}`}
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
@ -700,15 +703,3 @@ function InProgressPreview({
</div>
);
}
function PreviewPlaceholder({ imgLoaded }: { imgLoaded: boolean }) {
if (imgLoaded) {
return;
}
return isSafari ? (
<div className={`absolute inset-0 bg-gray-300 pointer-events-none`} />
) : (
<Skeleton className={`absolute inset-0 pointer-events-none`} />
);
}

View File

@ -8,7 +8,7 @@ import { Preview } from "@/types/preview";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer from "../HlsVideoPlayer";
import { Timeline } from "@/types/timeline";
import { TimeRange, Timeline } from "@/types/timeline";
/**
* Dynamically switches between video playback and scrubbing preview player.
@ -16,7 +16,7 @@ import { Timeline } from "@/types/timeline";
type DynamicVideoPlayerProps = {
className?: string;
camera: string;
timeRange: { start: number; end: number };
timeRange: TimeRange;
cameraPreviews: Preview[];
startTimestamp?: number;
isScrubbing: boolean;
@ -100,7 +100,7 @@ export default function DynamicVideoPlayer({
const [isLoading, setIsLoading] = useState(false);
const [source, setSource] = useState(
`${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
);
// start at correct time
@ -134,8 +134,8 @@ export default function DynamicVideoPlayer({
const recordingParams = useMemo(() => {
return {
before: timeRange.end,
after: timeRange.start,
before: timeRange.before,
after: timeRange.after,
};
}, [timeRange]);
const { data: recordings } = useSWR<Recording[]>(
@ -153,7 +153,7 @@ export default function DynamicVideoPlayer({
}
setSource(
`${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
);
setIsLoading(true);

View File

@ -10,27 +10,9 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
DropdownMenuRadioGroup,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { Toaster } from "@/components/ui/sonner";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { format } from "date-fns";
import { useCallback, useEffect, useState } from "react";
import { DateRange } from "react-day-picker";
import { isDesktop } from "react-device-detect";
import { useLocation } from "react-router-dom";
import { toast } from "sonner";
import { useCallback, useState } from "react";
import useSWR from "swr";
type ExportItem = {
@ -38,96 +20,13 @@ type ExportItem = {
};
function Export() {
const { data: config } = useSWR<FrigateConfig>("config");
const { data: exports, mutate } = useSWR<ExportItem[]>(
"exports/",
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
);
const location = useLocation();
const [dialogOpen, setDialogOpen] = useState(false);
// Export States
const [camera, setCamera] = useState<string | undefined>();
const [playback, setPlayback] = useState<string | undefined>();
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
const [date, setDate] = useState<DateRange | undefined>({
from: currentDate,
});
const [startTime, setStartTime] = useState("00:00:00");
const [endTime, setEndTime] = useState("23:59:59");
const [deleteClip, setDeleteClip] = useState<string | undefined>();
const onHandleExport = () => {
if (!camera) {
toast.error("A camera needs to be selected.", { position: "top-center" });
return;
}
if (!playback) {
toast.error("A playback factor needs to be selected.", {
position: "top-center",
});
return;
}
if (!date?.from || !startTime || !endTime) {
toast.error("A start and end time needs to be selected", {
position: "top-center",
});
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) {
toast.error("The end time must be after the start time.", {
position: "top-center",
});
return;
}
axios
.post(`export/${camera}/start/${start}/end/${end}`, { playback })
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
}
mutate();
})
.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",
});
}
});
};
const onHandleDelete = useCallback(() => {
if (!deleteClip) {
return;
@ -141,27 +40,6 @@ function Export() {
});
}, [deleteClip, mutate]);
const Create = isDesktop ? Dialog : Drawer;
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
const Content = isDesktop ? DialogContent : DrawerContent;
useEffect(() => {
if (location.state && location.state.start && location.state.end) {
const startTimeString = format(
new Date(location.state.start * 1000),
"HH:mm:ss",
);
const endTimeString = format(
new Date(location.state.end * 1000),
"HH:mm:ss",
);
setStartTime(startTimeString);
setEndTime(endTimeString);
setDialogOpen(true);
}
}, [location.state]);
return (
<div className="size-full p-2 overflow-hidden flex flex-col">
<Toaster />
@ -186,102 +64,6 @@ function Export() {
</AlertDialogContent>
</AlertDialog>
<div className="w-full h-14">
<Create open={dialogOpen} onOpenChange={setDialogOpen}>
<Trigger>
<Button variant="select">New Export</Button>
</Trigger>
<Content className="flex flex-col justify-center items-center">
<div className="w-full flex justify-evenly items-center mt-4 md:mt-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="secondary">
{camera?.replaceAll("_", " ") || "Select Camera"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className="flex justify-center items-center">
Select Camera
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={camera}
onValueChange={setCamera}
>
{Object.keys(config?.cameras || {}).map((item) => (
<DropdownMenuRadioItem
className="capitalize"
key={item}
value={item}
>
{item.replaceAll("_", " ")}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="secondary">
{playback?.split("_")[0] || "Select Playback"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className="flex justify-center items-center">
Select Playback
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={playback}
onValueChange={setPlayback}
>
<DropdownMenuRadioItem value="realtime">
Realtime
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="timelapse_25x">
Timelapse
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Calendar mode="range" selected={date} onSelect={setDate} />
<div className="w-full flex justify-evenly">
<input
className="w-36 p-1 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startTime}
step="1"
onChange={(e) => setStartTime(e.target.value)}
/>
<input
className="w-36 p-1 mx-2 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endTime}
step="1"
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
<div className="w-full flex items-center justify-between px-4">
{`${
date?.from ? format(date?.from, "LLL dd, y") : ""
} ${startTime} -> ${
date?.to ? format(date?.to, "LLL dd, y") : ""
} ${endTime}`}
<Button
className="my-4"
variant="select"
onClick={() => onHandleExport()}
>
Submit
</Button>
</div>
</Content>
</Create>
</div>
<div className="size-full overflow-hidden">
{exports && (
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">

View File

@ -1,5 +1,6 @@
import { Preview } from "./preview";
import { Recording } from "./record";
import { TimeRange } from "./timeline";
export type DynamicPlayback = {
recordings: Recording[];
@ -7,5 +8,5 @@ export type DynamicPlayback = {
export type PreviewPlayback = {
preview: Preview | undefined;
timeRange: { end: number; start: number };
timeRange: TimeRange;
};

View File

@ -21,7 +21,7 @@ import {
} from "react-icons/md";
import { FaBicycle } from "react-icons/fa";
import { endOfHourOrCurrentTime } from "./dateUtil";
import { Timeline } from "@/types/timeline";
import { TimeRange, Timeline } from "@/types/timeline";
export function getTimelineIcon(timelineItem: Timeline) {
switch (timelineItem.class_type) {
@ -124,7 +124,7 @@ export function getTimelineItemDescription(timelineItem: Timeline) {
export function getChunkedTimeDay(timestamp: number) {
const endOfThisHour = new Date();
endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
const data: { start: number; end: number }[] = [];
const data: TimeRange[] = [];
const startDay = new Date(timestamp * 1000);
startDay.setHours(0, 0, 0, 0);
const startTimestamp = startDay.getTime() / 1000;
@ -140,8 +140,8 @@ export function getChunkedTimeDay(timestamp: number) {
end = endOfHourOrCurrentTime(startDay.getTime() / 1000);
data.push({
start,
end,
after: start,
before: end,
});
start = startDay.getTime() / 1000;
}
@ -155,7 +155,7 @@ export function getChunkedTimeRange(
) {
const endOfThisHour = new Date();
endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
const data: { start: number; end: number }[] = [];
const data: TimeRange[] = [];
const startDay = new Date(startTimestamp * 1000);
startDay.setMinutes(0, 0, 0);
let start = startDay.getTime() / 1000;
@ -170,8 +170,8 @@ export function getChunkedTimeRange(
end = endOfHourOrCurrentTime(startDay.getTime() / 1000);
data.push({
start,
end,
after: start,
before: end,
});
start = startDay.getTime() / 1000;
}

View File

@ -254,10 +254,14 @@ export default function EventView({
{selectedReviews.length <= 0 ? (
<ReviewFilterGroup
filters={
severity == "significant_motion"
? ["cameras", "date", "motionOnly"]
: ["cameras", "date", "general"]
}
reviewSummary={reviewSummary}
filter={filter}
onUpdateFilter={updateFilter}
severity={severity}
motionOnly={motionOnly}
setMotionOnly={setMotionOnly}
/>
@ -667,7 +671,7 @@ function MotionReview({
}
return timeRangeSegments.ranges.findIndex(
(seg) => seg.start <= startTime && seg.end >= startTime,
(seg) => seg.after <= startTime && seg.before >= startTime,
);
// only render once
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -675,7 +679,7 @@ function MotionReview({
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
const [currentTime, setCurrentTime] = useState<number>(
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.end,
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.before,
);
const currentTimeRange = useMemo(
() => timeRangeSegments.ranges[selectedRangeIdx],
@ -689,11 +693,11 @@ function MotionReview({
useEffect(() => {
if (
currentTime > currentTimeRange.end + 60 ||
currentTime < currentTimeRange.start - 60
currentTime > currentTimeRange.before + 60 ||
currentTime < currentTimeRange.after - 60
) {
const index = timeRangeSegments.ranges.findIndex(
(seg) => seg.start <= currentTime && seg.end >= currentTime,
(seg) => seg.after <= currentTime && seg.before >= currentTime,
);
if (index != -1) {

View File

@ -1,5 +1,6 @@
import ReviewCard from "@/components/card/ReviewCard";
import FilterCheckBox from "@/components/filter/FilterCheckBox";
import { CalendarFilterButton } from "@/components/filter/ReviewFilterGroup";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import PreviewPlayer, {
PreviewController,
} from "@/components/player/PreviewPlayer";
@ -8,6 +9,8 @@ import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import {
@ -16,9 +19,15 @@ import {
ReviewSegment,
ReviewSummary,
} from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { FaCircle, FaVideo } from "react-icons/fa";
import { IoMdArrowRoundBack } from "react-icons/io";
@ -26,6 +35,7 @@ import { useNavigate } from "react-router-dom";
import useSWR from "swr";
const SEGMENT_DURATION = 30;
type TimelineType = "timeline" | "events";
type RecordingViewProps = {
startCamera: string;
@ -64,12 +74,17 @@ export function RecordingView({
[reviewItems, mainCamera],
);
// timeline time
// timeline
const [timelineType, setTimelineType] = useOverlayState<TimelineType>(
"timelineType",
"timeline",
);
const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]);
const [selectedRangeIdx, setSelectedRangeIdx] = useState(
timeRange.ranges.findIndex((chunk) => {
return chunk.start <= startTime && chunk.end >= startTime;
return chunk.after <= startTime && chunk.before >= startTime;
}),
);
const currentTimeRange = useMemo(
@ -98,7 +113,7 @@ export function RecordingView({
const updateSelectedSegment = useCallback(
(currentTime: number, updateStartTime: boolean) => {
const index = timeRange.ranges.findIndex(
(seg) => seg.start <= currentTime && seg.end >= currentTime,
(seg) => seg.after <= currentTime && seg.before >= currentTime,
);
if (index != -1) {
@ -115,8 +130,8 @@ export function RecordingView({
useEffect(() => {
if (scrubbing) {
if (
currentTime > currentTimeRange.end + 60 ||
currentTime < currentTimeRange.start - 60
currentTime > currentTimeRange.before + 60 ||
currentTime < currentTimeRange.after - 60
) {
updateSelectedSegment(currentTime, false);
return;
@ -140,8 +155,8 @@ export function RecordingView({
if (!scrubbing) {
if (Math.abs(currentTime - playerTime) > 10) {
if (
currentTimeRange.start <= currentTime &&
currentTimeRange.end >= currentTime
currentTimeRange.after <= currentTime &&
currentTimeRange.before >= currentTime
) {
mainControllerRef.current?.seekToTimestamp(currentTime, true);
} else {
@ -165,16 +180,6 @@ export function RecordingView({
// motion timeline data
const { data: motionData } = useSWR<MotionData[]>([
"review/activity/motion",
{
before: timeRange.end,
after: timeRange.start,
scale: SEGMENT_DURATION / 2,
cameras: mainCamera,
},
]);
const mainCameraAspect = useMemo(() => {
if (!config) {
return "normal";
@ -204,31 +209,13 @@ export function RecordingView({
}, [mainCameraAspect]);
return (
<div ref={contentRef} className="relative size-full">
<div
className={`absolute left-0 top-0 mr-2 flex items-center justify-between ${isMobile ? "right-0" : "right-24"}`}
>
<div ref={contentRef} className="size-full flex flex-col">
<div className={`w-full h-10 flex items-center justify-between pr-1`}>
<Button className="rounded-lg" onClick={() => navigate(-1)}>
<IoMdArrowRoundBack className="size-5 mr-[10px]" />
Back
</Button>
<div className="flex items-center justify-end">
<CalendarFilterButton
day={
filter?.after == undefined
? undefined
: new Date(filter.after * 1000)
}
reviewSummary={reviewSummary}
updateSelectedDay={(day) => {
updateFilter({
...filter,
after: day == undefined ? undefined : day.getTime() / 1000,
before:
day == undefined ? undefined : getEndOfDayTimestamp(day),
});
}}
/>
<div className="flex items-center justify-end gap-2">
{isMobile && (
<Drawer>
<DrawerTrigger asChild>
@ -258,11 +245,45 @@ export function RecordingView({
</DrawerContent>
</Drawer>
)}
<ReviewFilterGroup
filters={["date", "general"]}
reviewSummary={reviewSummary}
filter={filter}
onUpdateFilter={updateFilter}
motionOnly={false}
setMotionOnly={() => {}}
/>
{isDesktop && (
<ToggleGroup
className="*:px-3 *:py-4 *:rounded-md"
type="single"
size="sm"
value={timelineType}
onValueChange={(value: TimelineType) =>
value ? setTimelineType(value, true) : 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>
)}
</div>
</div>
<div
className={`flex h-full justify-center overflow-hidden ${isDesktop ? "" : "flex-col pt-12"}`}
className={`flex h-full mb-2 justify-center overflow-hidden ${isDesktop ? "" : "flex-col"}`}
>
<div className="flex flex-1 flex-wrap">
<div
@ -328,31 +349,123 @@ export function RecordingView({
)}
</div>
</div>
<div
className={
isDesktop
? "w-[100px] mt-2 overflow-y-auto no-scrollbar"
: "flex-grow overflow-hidden"
}
>
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineStart={timeRange.end}
timelineEnd={timeRange.start}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
onlyInitialHandlebarScroll={true}
events={mainCameraReviewItems}
motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
</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
contentRef={contentRef}
mainCamera={mainCamera}
timelineType={timelineType ?? "timeline"}
timeRange={timeRange}
mainCameraReviewItems={mainCameraReviewItems}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
setScrubbing={setScrubbing}
/>
</div>
</div>
);
}
type TimelineProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
mainCamera: string;
timelineType: TimelineType;
timeRange: { start: number; end: number };
mainCameraReviewItems: ReviewSegment[];
currentTime: number;
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
};
function Timeline({
contentRef,
mainCamera,
timelineType,
timeRange,
mainCameraReviewItems,
currentTime,
setCurrentTime,
setScrubbing,
}: TimelineProps) {
const { data: motionData } = useSWR<MotionData[]>([
"review/activity/motion",
{
before: timeRange.end,
after: timeRange.start,
scale: SEGMENT_DURATION / 2,
cameras: mainCamera,
},
]);
if (timelineType == "timeline") {
return (
<div
className={
isDesktop
? "w-[100px] mt-2 overflow-y-auto no-scrollbar"
: "flex-grow overflow-hidden"
}
>
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineStart={timeRange.end}
timelineEnd={timeRange.start}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
onlyInitialHandlebarScroll={true}
events={mainCameraReviewItems}
motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
/>
</div>
);
}
return (
<div
className={`${isDesktop ? "w-60" : "w-full"} h-full p-4 flex flex-col gap-4 bg-secondary overflow-auto`}
>
{mainCameraReviewItems.map((review) => {
if (review.severity == "significant_motion") {
return;
}
return (
<ReviewCard
key={review.id}
event={review}
currentTime={currentTime}
onClick={() => setCurrentTime(review.start_time)}
/>
);
})}
</div>
);
}

View File

@ -2,7 +2,7 @@ import { useFrigateReviews } from "@/api/ws";
import Logo from "@/components/Logo";
import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector";
import { LiveGridIcon, LiveListIcon } from "@/components/icons/LiveIcons";
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
import { AnimatedEventCard } from "@/components/card/AnimatedEventCard";
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
@ -166,7 +166,7 @@ export default function LiveDashboardView({
<TooltipProvider>
<div className="flex gap-2 items-center">
{events.map((event) => {
return <AnimatedEventThumbnail key={event.id} event={event} />;
return <AnimatedEventCard key={event.id} event={event} />;
})}
</div>
</TooltipProvider>