diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 8da44708f..9728d19dd 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -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: diff --git a/web/src/components/image/AnimatedEventThumbnail.tsx b/web/src/components/card/AnimatedEventCard.tsx similarity index 87% rename from web/src/components/image/AnimatedEventThumbnail.tsx rename to web/src/components/card/AnimatedEventCard.tsx index dad85116a..2944e6c33 100644 --- a/web/src/components/image/AnimatedEventThumbnail.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -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("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 diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx new file mode 100644 index 000000000..5c579e4d4 --- /dev/null +++ b/web/src/components/card/ReviewCard.tsx @@ -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("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 ( +
+ + { + onImgLoad(); + }} + /> +
+
+ {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"); + })} +
{formattedDate}
+
+ +
+
+ ); +} diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index 0e021f634..f690b9101 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -22,7 +22,7 @@ export default function NewReviewData({ return false; } - return reviewItems.length != itemsToReview; + return reviewItems.length < itemsToReview; }, [reviewItems, itemsToReview]); return ( diff --git a/web/src/components/dynamic/TimeAgo.tsx b/web/src/components/dynamic/TimeAgo.tsx index a9993db8a..13892180e 100644 --- a/web/src/components/dynamic/TimeAgo.tsx +++ b/web/src/components/dynamic/TimeAgo.tsx @@ -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 = ({ + className, time, manualRefreshInterval, ...rest @@ -105,6 +108,6 @@ const TimeAgo: FunctionComponent = ({ [currentTime, rest, time], ); - return {timeAgoValue}; + return {timeAgoValue}; }; export default TimeAgo; diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index d4fa7a0f0..4ebad426f 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -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>; }; export default function ReviewFilterGroup({ + filters = DEFAULT_REVIEW_FILTERS, reviewSummary, filter, onUpdateFilter, - severity, motionOnly, setMotionOnly, }: ReviewFilterGroupProps) { @@ -101,27 +109,34 @@ export default function ReviewFilterGroup({ return (
- { - onUpdateFilter({ ...filter, cameras: newCameras }); - }} - /> - - {severity == "significant_motion" ? ( + {filters.includes("cameras") && ( + { + onUpdateFilter({ ...filter, cameras: newCameras }); + }} + /> + )} + {filters.includes("date") && ( + + )} + {filters.includes("motionOnly") && ( - ) : ( + )} + {filters.includes("general") && ( void; }; -export function CalendarFilterButton({ +function CalendarFilterButton({ reviewSummary, day, updateSelectedDay, diff --git a/web/src/components/indicators/ImageLoadingIndicator.tsx b/web/src/components/indicators/ImageLoadingIndicator.tsx new file mode 100644 index 000000000..32531ea2b --- /dev/null +++ b/web/src/components/indicators/ImageLoadingIndicator.tsx @@ -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 ? ( +
+ ) : ( + + ); +} diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 76a16fe9d..ff7951434 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -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(); - if (isCurrentHour(timeRange.end)) { + if (isCurrentHour(timeRange.before)) { return ( 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; - 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( - `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); } diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 12e4bd706..95b6bd6b2 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -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({ />
)} - +
); } - -function PreviewPlaceholder({ imgLoaded }: { imgLoaded: boolean }) { - if (imgLoaded) { - return; - } - - return isSafari ? ( -
- ) : ( - - ); -} diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 1421954cd..3db528165 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -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( @@ -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); diff --git a/web/src/pages/Export.tsx b/web/src/pages/Export.tsx index 82b982cb7..30148ba57 100644 --- a/web/src/pages/Export.tsx +++ b/web/src/pages/Export.tsx @@ -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("config"); const { data: exports, mutate } = useSWR( "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(); - const [playback, setPlayback] = useState(); - - const currentDate = new Date(); - currentDate.setHours(0, 0, 0, 0); - - const [date, setDate] = useState({ - from: currentDate, - }); - const [startTime, setStartTime] = useState("00:00:00"); - const [endTime, setEndTime] = useState("23:59:59"); const [deleteClip, setDeleteClip] = useState(); - 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 (
@@ -186,102 +64,6 @@ function Export() { -
- - - - - -
- - - - - - - Select Camera - - - - {Object.keys(config?.cameras || {}).map((item) => ( - - {item.replaceAll("_", " ")} - - ))} - - - - - - - - - - Select Playback - - - - - Realtime - - - Timelapse - - - - -
- -
- setStartTime(e.target.value)} - /> - setEndTime(e.target.value)} - /> -
-
- {`${ - date?.from ? format(date?.from, "LLL dd, y") : "" - } ${startTime} -> ${ - date?.to ? format(date?.to, "LLL dd, y") : "" - } ${endTime}`} - -
-
-
-
-
{exports && (
diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts index c47158f39..6c4202304 100644 --- a/web/src/types/playback.ts +++ b/web/src/types/playback.ts @@ -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; }; diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index 5bc19f03d..21ad6be9a 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -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; } diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index c0f532a17..8c752b46a 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -254,10 +254,14 @@ export default function EventView({ {selectedReviews.length <= 0 ? ( @@ -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( - 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) { diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index e9981c1be..8efe3317a 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -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", + "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([ - "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 ( -
-
+
+
-
- { - updateFilter({ - ...filter, - after: day == undefined ? undefined : day.getTime() / 1000, - before: - day == undefined ? undefined : getEndOfDayTimestamp(day), - }); - }} - /> +
{isMobile && ( @@ -258,11 +245,45 @@ export function RecordingView({ )} + {}} + /> + {isDesktop && ( + + value ? setTimelineType(value, true) : null + } // don't allow the severity to be unselected + > + +
Timeline
+
+ +
Events
+
+
+ )}
- -
- setScrubbing(scrubbing)} - /> -
+ {isMobile && ( + + value ? setTimelineType(value) : null + } // don't allow the severity to be unselected + > + +
Timeline
+
+ +
Events
+
+
+ )} +
); } + +type TimelineProps = { + contentRef: MutableRefObject; + mainCamera: string; + timelineType: TimelineType; + timeRange: { start: number; end: number }; + mainCameraReviewItems: ReviewSegment[]; + currentTime: number; + setCurrentTime: React.Dispatch>; + setScrubbing: React.Dispatch>; +}; +function Timeline({ + contentRef, + mainCamera, + timelineType, + timeRange, + mainCameraReviewItems, + currentTime, + setCurrentTime, + setScrubbing, +}: TimelineProps) { + const { data: motionData } = useSWR([ + "review/activity/motion", + { + before: timeRange.end, + after: timeRange.start, + scale: SEGMENT_DURATION / 2, + cameras: mainCamera, + }, + ]); + + if (timelineType == "timeline") { + return ( +
+ setScrubbing(scrubbing)} + /> +
+ ); + } + + return ( +
+ {mainCameraReviewItems.map((review) => { + if (review.severity == "significant_motion") { + return; + } + + return ( + setCurrentTime(review.start_time)} + /> + ); + })} +
+ ); +} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 649e102b5..bcad9751b 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -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({
{events.map((event) => { - return ; + return ; })}