diff --git a/web-new/src/App.tsx b/web-new/src/App.tsx index 096f2ebca..babafbd60 100644 --- a/web-new/src/App.tsx +++ b/web-new/src/App.tsx @@ -29,7 +29,7 @@ function App() {
-
+
} /> } /> diff --git a/web-new/src/components/card/HistoryCard.tsx b/web-new/src/components/card/HistoryCard.tsx index ddf77e47f..3a2e79920 100644 --- a/web-new/src/components/card/HistoryCard.tsx +++ b/web-new/src/components/card/HistoryCard.tsx @@ -3,32 +3,26 @@ import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer"; import { Card } from "../ui/card"; import { FrigateConfig } from "@/types/frigateConfig"; import ActivityIndicator from "../ui/activity-indicator"; -import { - LuCircle, - LuClock, - LuPlay, - LuPlayCircle, - LuTruck, -} from "react-icons/lu"; -import { IoMdExit } from "react-icons/io"; -import { - MdFaceUnlock, - MdOutlineLocationOn, - MdOutlinePictureInPictureAlt, -} from "react-icons/md"; +import { LuClock } from "react-icons/lu"; import { HiOutlineVideoCamera } from "react-icons/hi"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { + getTimelineIcon, + getTimelineItemDescription, +} from "@/utils/timelineUtil"; type HistoryCardProps = { timeline: Card; relevantPreview?: Preview; shouldAutoPlay: boolean; + onClick?: () => void; }; export default function HistoryCard({ relevantPreview, timeline, shouldAutoPlay, + onClick, }: HistoryCardProps) { const { data: config } = useSWR("config"); @@ -37,7 +31,10 @@ export default function HistoryCard({ } return ( - + ); } - -function getTimelineIcon(timelineItem: Timeline) { - switch (timelineItem.class_type) { - case "visible": - return ; - case "gone": - return ; - case "active": - return ; - case "stationary": - return ; - case "entered_zone": - return ; - case "attribute": - switch (timelineItem.data.attribute) { - case "face": - return ; - case "license_plate": - return ; - default: - return ; - } - case "sub_label": - switch (timelineItem.data.label) { - case "person": - return ; - case "car": - return ; - } - } -} - -function getTimelineItemDescription(timelineItem: Timeline) { - const label = ( - (Array.isArray(timelineItem.data.sub_label) - ? timelineItem.data.sub_label[0] - : timelineItem.data.sub_label) || timelineItem.data.label - ).replaceAll("_", " "); - - switch (timelineItem.class_type) { - case "visible": - return `${label} detected`; - case "entered_zone": - return `${label} entered ${timelineItem.data.zones - .join(" and ") - .replaceAll("_", " ")}`; - case "active": - return `${label} became active`; - case "stationary": - return `${label} became stationary`; - case "attribute": { - let title = ""; - if ( - timelineItem.data.attribute == "face" || - timelineItem.data.attribute == "license_plate" - ) { - title = `${timelineItem.data.attribute.replaceAll( - "_", - " " - )} detected for ${label}`; - } else { - title = `${ - timelineItem.data.sub_label - } recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`; - } - return title; - } - case "sub_label": - return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`; - case "gone": - return `${label} left`; - } -} diff --git a/web-new/src/components/card/TimelineCardPlayer.tsx b/web-new/src/components/card/TimelineCardPlayer.tsx new file mode 100644 index 000000000..0619d634e --- /dev/null +++ b/web-new/src/components/card/TimelineCardPlayer.tsx @@ -0,0 +1,262 @@ +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import VideoPlayer from "../player/VideoPlayer"; +import { useMemo, useRef, useState } from "react"; +import { useApiHost } from "@/api"; +import TimelineEventOverlay from "../overlay/TimelineDataOverlay"; +import ActivityIndicator from "../ui/activity-indicator"; +import { Button } from "../ui/button"; +import { + getTimelineIcon, + getTimelineItemDescription, +} from "@/utils/timelineUtil"; +import { LuAlertCircle } from "react-icons/lu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import Player from "video.js/dist/types/player"; + +type TimelinePlayerCardProps = { + timeline?: Card; + onDismiss: () => void; +}; + +export default function TimelinePlayerCard({ + timeline, + onDismiss, +}: TimelinePlayerCardProps) { + const { data: config } = useSWR("config"); + const apiHost = useApiHost(); + const playerRef = useRef(); + + const annotationOffset = useMemo(() => { + if (!config || !timeline) { + return 0; + } + + return ( + (config.cameras[timeline.camera]?.detect?.annotation_offset || 0) / 1000 + ); + }, [config, timeline]); + const [selectedItem, setSelectedItem] = useState(); + + const recordingParams = useMemo(() => { + if (!timeline) { + return {}; + } + + return { + before: timeline.entries.at(-1)!!.timestamp + 30, + after: timeline.entries.at(0)!!.timestamp, + }; + }, [timeline]); + + const { data: recordings } = useSWR( + timeline ? [`${timeline.camera}/recordings`, recordingParams] : null, + { revalidateOnFocus: false } + ); + + const playbackUri = useMemo(() => { + if (!timeline) { + return ""; + } + + const end = timeline.entries.at(-1)!!.timestamp + 30; + const start = timeline.entries.at(0)!!.timestamp; + return `${apiHost}vod/${timeline?.camera}/start/${ + Number.isInteger(start) ? start.toFixed(1) : start + }/end/${Number.isInteger(end) ? end.toFixed(1) : end}/master.m3u8`; + }, [timeline]); + + return ( + <> + { + setSelectedItem(undefined); + onDismiss(); + }} + > + e.preventDefault()} + > + + + {`${timeline?.camera?.replaceAll( + "_", + " " + )} @ ${formatUnixTimestampToDateTime(timeline?.time ?? 0, { + strftime_fmt: + config?.ui?.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S", + })}`} + + + {config && timeline && recordings && recordings.length > 0 && ( + <> + { + setSelectedItem(selected); + playerRef.current?.pause(); + playerRef.current?.currentTime(seekTime); + }} + /> +
+ { + playerRef.current = player; + player.on("playing", () => { + setSelectedItem(undefined); + }); + }} + onDispose={() => { + playerRef.current = undefined; + }} + > + {selectedItem ? ( + + ) : undefined} + +
+ + )} +
+
+ + ); +} + +type TimelineSummaryProps = { + timeline: Card; + annotationOffset: number; + recordings: Recording[]; + onFrameSelected: (timeline: Timeline, frameTime: number) => void; +}; + +function TimelineSummary({ + timeline, + annotationOffset, + recordings, + onFrameSelected, +}: TimelineSummaryProps) { + const [timeIndex, setTimeIndex] = useState(-1); + + // calculates the seek seconds by adding up all the seconds in the segments prior to the playback time + const getSeekSeconds = (seekUnix: number) => { + if (!recordings) { + return 0; + } + + let seekSeconds = 0; + recordings.every((segment) => { + // if the next segment is past the desired time, stop calculating + if (segment.start_time > seekUnix) { + return false; + } + + if (segment.end_time < seekUnix) { + seekSeconds += segment.end_time - segment.start_time; + return true; + } + + seekSeconds += + segment.end_time - segment.start_time - (segment.end_time - seekUnix); + return true; + }); + + return seekSeconds; + }; + + const onSelectMoment = async (index: number) => { + setTimeIndex(index); + onFrameSelected( + timeline.entries[index], + getSeekSeconds(timeline.entries[index].timestamp + annotationOffset) + ); + }; + + if (!timeline || !recordings) { + return ; + } + + return ( +
+
+
+ {timeline.entries.map((item, index) => ( + + + + + + +

{getTimelineItemDescription(item)}

+
+
+
+ ))} +
+
+ {timeIndex >= 0 ? ( +
+
+
+ Bounding boxes may not align +
+ + + + + + +

+ Disclaimer: This data comes from the detect feed but is + shown on the recordings. +

+

+ It is unlikely that the streams are perfectly in sync so the + bounding box and the footage will not line up perfectly. +

+

The annotation_offset field can be used to adjust this.

+
+
+
+
+
+ ) : null} +
+ ); +} diff --git a/web-new/src/components/overlay/TimelineDataOverlay.tsx b/web-new/src/components/overlay/TimelineDataOverlay.tsx new file mode 100644 index 000000000..9ab08bc16 --- /dev/null +++ b/web-new/src/components/overlay/TimelineDataOverlay.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; + +type TimelineEventOverlayProps = { + timeline: Timeline; + cameraConfig: { + detect: { + width: number; + height: number; + }; + }; +}; + +export default function TimelineEventOverlay({ + timeline, + cameraConfig, +}: TimelineEventOverlayProps) { + const boxLeftEdge = Math.round(timeline.data.box[0] * 100); + const boxTopEdge = Math.round(timeline.data.box[1] * 100); + const boxRightEdge = Math.round( + (1 - timeline.data.box[2] - timeline.data.box[0]) * 100 + ); + const boxBottomEdge = Math.round( + (1 - timeline.data.box[3] - timeline.data.box[1]) * 100 + ); + + const [isHovering, setIsHovering] = useState(false); + const getHoverStyle = () => { + if (boxLeftEdge < 15) { + // show object stats on right side + return { + left: `${boxLeftEdge + timeline.data.box[2] * 100 + 1}%`, + top: `${boxTopEdge}%`, + }; + } + + return { + right: `${boxRightEdge + timeline.data.box[2] * 100 + 1}%`, + top: `${boxTopEdge}%`, + }; + }; + + const getObjectArea = () => { + const width = timeline.data.box[2] * cameraConfig.detect.width; + const height = timeline.data.box[3] * cameraConfig.detect.height; + return Math.round(width * height); + }; + + const getObjectRatio = () => { + const width = timeline.data.box[2] * cameraConfig.detect.width; + const height = timeline.data.box[3] * cameraConfig.detect.height; + return Math.round(100 * (width / height)) / 100; + }; + + return ( + <> +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onTouchStart={() => setIsHovering(true)} + onTouchEnd={() => setIsHovering(false)} + style={{ + left: `${boxLeftEdge}%`, + top: `${boxTopEdge}%`, + right: `${boxRightEdge}%`, + bottom: `${boxBottomEdge}%`, + }} + > + {timeline.class_type == "entered_zone" ? ( +
+ ) : null} +
+ {isHovering && ( +
+
{`Area: ${getObjectArea()} px`}
+
{`Ratio: ${getObjectRatio()}`}
+
+ )} + + ); +} diff --git a/web-new/src/components/player/PreviewThumbnailPlayer.tsx b/web-new/src/components/player/PreviewThumbnailPlayer.tsx index 725e83376..12145232d 100644 --- a/web-new/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web-new/src/components/player/PreviewThumbnailPlayer.tsx @@ -1,7 +1,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import VideoPlayer from "./VideoPlayer"; import useSWR from "swr"; -import { useCallback, useRef } from "react"; +import { useCallback, useRef, useState } from "react"; import { useApiHost } from "@/api"; import Player from "video.js/dist/types/player"; import { AspectRatio } from "../ui/aspect-ratio"; @@ -33,6 +33,8 @@ export default function PreviewThumbnailPlayer({ const playerRef = useRef(null); const apiHost = useApiHost(); + const [visible, setVisible] = useState(false); + const onPlayback = useCallback( (isHovered: Boolean) => { if (!relevantPreview || !playerRef.current) { @@ -46,73 +48,73 @@ export default function PreviewThumbnailPlayer({ playerRef.current.currentTime(startTs - relevantPreview.start); } }, - [relevantPreview, startTs] + [relevantPreview, startTs, playerRef] ); - const observer = useRef(); + const autoPlayObserver = useRef(); + const preloadObserver = useRef(); const inViewRef = useCallback( (node: HTMLElement | null) => { - if (!shouldAutoPlay || observer.current) { - return; + if (!preloadObserver.current) { + try { + preloadObserver.current = new IntersectionObserver( + (entries) => { + const [{ isIntersecting }] = entries; + setVisible(isIntersecting); + }, + { + threshold: 0, + root: document.getElementById("pageRoot"), + rootMargin: "10% 0px 25% 0px", + } + ); + if (node) preloadObserver.current.observe(node); + } catch (e) { + // no op + } } - try { - observer.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) { - onPlayback(true); - } else { - onPlayback(false); - } - }, - { threshold: 1.0 } - ); - if (node) observer.current.observe(node); - } catch (e) { - // no op + if (shouldAutoPlay && !autoPlayObserver.current) { + try { + autoPlayObserver.current = new IntersectionObserver( + (entries) => { + const [{ isIntersecting }] = entries; + if (isIntersecting) { + onPlayback(true); + } else { + onPlayback(false); + } + }, + { threshold: 1.0 } + ); + if (node) autoPlayObserver.current.observe(node); + } catch (e) { + // no op + } } }, - [observer, onPlayback] + [preloadObserver, autoPlayObserver, onPlayback] ); - if (!relevantPreview) { + let content; + if (!relevantPreview || !visible) { if (isCurrentHour(startTs)) { - return ( - - - + content = ( + ); } - return ( - - - + content = ( + ); - } - - return ( - onPlayback(true)} - onMouseLeave={() => onPlayback(false)} - > + } else { + content = (
+ ); + } + + return ( + onPlayback(true)} + onMouseLeave={() => onPlayback(false)} + > + {content} ); } @@ -152,6 +166,10 @@ function isCurrentHour(timestamp: number) { function getPreviewWidth(camera: string, config: FrigateConfig) { const detect = config.cameras[camera].detect; + if (detect.width / detect.height < 1.0) { + return "w-[120px]"; + } + if (detect.width / detect.height < 1.4) { return "w-[208px]"; } diff --git a/web-new/src/components/player/VideoPlayer.tsx b/web-new/src/components/player/VideoPlayer.tsx index 36b600104..68e500565 100644 --- a/web-new/src/components/player/VideoPlayer.tsx +++ b/web-new/src/components/player/VideoPlayer.tsx @@ -1,74 +1,90 @@ import { useEffect, useRef, ReactElement } from "react"; -import videojs from 'video.js'; -import 'videojs-playlist'; -import 'video.js/dist/video-js.css'; +import videojs from "video.js"; +import "videojs-playlist"; +import "video.js/dist/video-js.css"; import Player from "video.js/dist/types/player"; type VideoPlayerProps = { - children?: ReactElement | ReactElement[], - options?: { - [key: string]: any - }, - seekOptions?: { - forward?: number, - backward?: number, - }, - onReady?: (player: Player) => void, - onDispose?: () => void, -} + children?: ReactElement | ReactElement[]; + options?: { + [key: string]: any; + }; + seekOptions?: { + forward?: number; + backward?: number; + }; + remotePlayback?: boolean; + onReady?: (player: Player) => void; + onDispose?: () => void; +}; -export default function VideoPlayer({ children, options, seekOptions = {forward:30, backward: 10}, onReady = (_) => {}, onDispose = () => {} }: VideoPlayerProps) { - const videoRef = useRef(null); - const playerRef = useRef(null); +export default function VideoPlayer({ + children, + options, + seekOptions = { forward: 30, backward: 10 }, + remotePlayback = false, + onReady = (_) => {}, + onDispose = () => {}, +}: VideoPlayerProps) { + const videoRef = useRef(null); + const playerRef = useRef(null); - useEffect(() => { - const defaultOptions = { - controls: true, - controlBar: { - skipButtons: seekOptions, - }, - playbackRates: [0.5, 1, 2, 4, 8], - fluid: true, - }; + useEffect(() => { + const defaultOptions = { + controls: true, + controlBar: { + skipButtons: seekOptions, + }, + playbackRates: [0.5, 1, 2, 4, 8], + fluid: true, + }; + if (!videojs.browser.IS_FIREFOX) { + defaultOptions.playbackRates.push(16); + } - if (!videojs.browser.IS_FIREFOX) { - defaultOptions.playbackRates.push(16); - } + // Make sure Video.js player is only initialized once + if (!playerRef.current) { + // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode. + const videoElement = document.createElement("video-js"); + // @ts-ignore we know this is a video element + videoElement.controls = true; + // @ts-ignore + videoElement.playsInline = true; + // @ts-ignore + videoElement.disableRemotePlayback = remotePlayback; + videoElement.classList.add("small-player"); + videoElement.classList.add("video-js"); + videoElement.classList.add("vjs-default-skin"); + videoRef.current?.appendChild(videoElement); - // Make sure Video.js player is only initialized once - if (!playerRef.current) { - // The Video.js player needs to be _inside_ the component el for React 18 Strict Mode. - const videoElement = document.createElement("video-js"); - - videoElement.classList.add('small-player'); - videoElement.classList.add('video-js'); - videoElement.classList.add('vjs-default-skin'); - videoRef.current?.appendChild(videoElement); - - const player = playerRef.current = videojs(videoElement, { ...defaultOptions, ...options }, () => { + const player = (playerRef.current = videojs( + videoElement, + { ...defaultOptions, ...options }, + () => { onReady && onReady(player); - }); - } - }, [options, videoRef]); - - // Dispose the Video.js player when the functional component unmounts - useEffect(() => { - const player = playerRef.current; - - return () => { - if (player && !player.isDisposed()) { - player.dispose(); - playerRef.current = null; - onDispose(); } - }; - }, [playerRef]); + )); + } + }, [options, videoRef]); - return ( -
-
- {children} -
- ); - } \ No newline at end of file + // Dispose the Video.js player when the functional component unmounts + useEffect(() => { + const player = playerRef.current; + + return () => { + if (player && !player.isDisposed()) { + player.dispose(); + playerRef.current = null; + onDispose(); + } + }; + }, [playerRef]); + + return ( +
+
+ {children} +
+ ); +} diff --git a/web-new/src/components/ui/calendar.tsx b/web-new/src/components/ui/calendar.tsx index b065f8e0c..a02526753 100644 --- a/web-new/src/components/ui/calendar.tsx +++ b/web-new/src/components/ui/calendar.tsx @@ -1,11 +1,11 @@ -import * as React from "react" -import { ChevronLeft, ChevronRight } from "lucide-react" -import { DayPicker } from "react-day-picker" +import * as React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { DayPicker } from "react-day-picker"; -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; -export type CalendarProps = React.ComponentProps +export type CalendarProps = React.ComponentProps; function Calendar({ className, @@ -52,13 +52,15 @@ function Calendar({ ...classNames, }} components={{ + // @ts-ignore IconLeft: ({ ...props }) => , + // @ts-ignore IconRight: ({ ...props }) => , }} {...props} /> - ) + ); } -Calendar.displayName = "Calendar" +Calendar.displayName = "Calendar"; -export { Calendar } +export { Calendar }; diff --git a/web-new/src/pages/History.tsx b/web-new/src/pages/History.tsx index f97072836..e01835598 100644 --- a/web-new/src/pages/History.tsx +++ b/web-new/src/pages/History.tsx @@ -7,8 +7,9 @@ import ActivityIndicator from "@/components/ui/activity-indicator"; import HistoryCard from "@/components/card/HistoryCard"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import axios from "axios"; +import TimelinePlayerCard from "@/components/card/TimelineCardPlayer"; -const API_LIMIT = 100; +const API_LIMIT = 120; function History() { const { data: config } = useSWR("config"); @@ -32,27 +33,26 @@ function History() { return ["timeline/hourly", { timezone, limit: API_LIMIT }]; }, []); - const shouldAutoPlay = useMemo(() => { - return window.innerWidth < 480; - }, []); - const { data: timelinePages, - mutate, size, setSize, isValidating, } = useSWRInfinite(getKey, timelineFetcher); const { data: allPreviews } = useSWR( - `preview/all/start/${(timelinePages ?? [])?.at(0)?.start ?? 0}/end/${ - (timelinePages ?? [])?.at(-1)?.end ?? 0 - }`, + timelinePages + ? `preview/all/start/${timelinePages?.at(0) + ?.start}/end/${timelinePages?.at(-1)?.end}` + : null, { revalidateOnFocus: false } ); - const [detailLevel, setDetailLevel] = useState<"normal" | "extra" | "full">( - "normal" - ); + const [detailLevel, _] = useState<"normal" | "extra" | "full">("normal"); + const [playback, setPlayback] = useState(); + + const shouldAutoPlay = useMemo(() => { + return playback == undefined && window.innerWidth < 480; + }, [playback]); const timelineCards: CardsData | never[] = useMemo(() => { if (!timelinePages) { @@ -161,7 +161,7 @@ function History() { [size, setSize, isValidating, isDone] ); - if (!config || !timelineCards ||timelineCards.length == 0) { + if (!config || !timelineCards || timelineCards.length == 0) { return ; } @@ -172,6 +172,11 @@ function History() { Dates and times are based on the timezone {timezone}
+ setPlayback(undefined)} + /> +
{Object.entries(timelineCards) .reverse() @@ -204,7 +209,7 @@ function History() {
- {Object.entries(timelineHour).map( + {Object.entries(timelineHour).reverse().map( ([key, timeline]) => { const startTs = Object.values(timeline.entries)[0] .timestamp; @@ -225,6 +230,9 @@ function History() { timeline={timeline} shouldAutoPlay={shouldAutoPlay} relevantPreview={relevantPreview} + onClick={() => { + setPlayback(timeline); + }} /> ); } diff --git a/web-new/src/pages/Storage.tsx b/web-new/src/pages/Storage.tsx index a37c8983d..b3e99cf18 100644 --- a/web-new/src/pages/Storage.tsx +++ b/web-new/src/pages/Storage.tsx @@ -71,7 +71,7 @@ function Storage() {
-
+
Data @@ -137,7 +137,7 @@ function Storage() { -
+
Memory @@ -187,7 +187,7 @@ function Storage() {
-
+
Cameras diff --git a/web-new/src/types/frigateConfig.ts b/web-new/src/types/frigateConfig.ts index 50539759c..8b5aa8ab2 100644 --- a/web-new/src/types/frigateConfig.ts +++ b/web-new/src/types/frigateConfig.ts @@ -1,11 +1,11 @@ export interface UiConfig { - timezone: string; - time_format: "browser" | "12hour" | "24hour"; - date_style: "full" | "long" | "medium" | "short"; - time_style: "full" | "long" | "medium" | "short"; - strftime_fmt: string; - live_mode: string; - use_experimental: boolean; + timezone?: string; + time_format?: 'browser' | '12hour' | '24hour'; + date_style?: 'full' | 'long' | 'medium' | 'short'; + time_style?: 'full' | 'long' | 'medium' | 'short'; + strftime_fmt?: string; + live_mode?: string; + use_experimental?: boolean; } export interface CameraConfig { diff --git a/web-new/src/types/record.ts b/web-new/src/types/record.ts new file mode 100644 index 000000000..20a98d886 --- /dev/null +++ b/web-new/src/types/record.ts @@ -0,0 +1,11 @@ +type Recording = { + id: string, + camera: string, + start_time: number, + end_time: number, + path: string, + segment_size: number, + motion: number, + objects: number, + dBFS: number, +} \ No newline at end of file diff --git a/web-new/src/utils/dateUtil.ts b/web-new/src/utils/dateUtil.ts index 2ea940ec2..464c4a342 100644 --- a/web-new/src/utils/dateUtil.ts +++ b/web-new/src/utils/dateUtil.ts @@ -143,8 +143,8 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC // fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat // This works even tough the timezone is undefined, it will use the runtime's default time zone if (!containsTime) { - const dateOptions = { ...formatMap[date_style]?.date, timeZone: options.timeZone, hour12: options.hour12 }; - const timeOptions = { ...formatMap[time_style]?.time, timeZone: options.timeZone, hour12: options.hour12 }; + const dateOptions = { ...formatMap[date_style ?? ""]?.date, timeZone: options.timeZone, hour12: options.hour12 }; + const timeOptions = { ...formatMap[time_style ?? ""]?.time, timeZone: options.timeZone, hour12: options.hour12 }; return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`; } diff --git a/web-new/src/utils/timelineUtil.tsx b/web-new/src/utils/timelineUtil.tsx new file mode 100644 index 000000000..f85be6ad8 --- /dev/null +++ b/web-new/src/utils/timelineUtil.tsx @@ -0,0 +1,80 @@ +import { LuCircle, LuPlay, LuPlayCircle, LuTruck } from "react-icons/lu"; +import { IoMdExit } from "react-icons/io"; +import { + MdFaceUnlock, + MdOutlineLocationOn, + MdOutlinePictureInPictureAlt, +} from "react-icons/md"; + +export function getTimelineIcon(timelineItem: Timeline) { + switch (timelineItem.class_type) { + case "visible": + return ; + case "gone": + return ; + case "active": + return ; + case "stationary": + return ; + case "entered_zone": + return ; + case "attribute": + switch (timelineItem.data.attribute) { + case "face": + return ; + case "license_plate": + return ; + default: + return ; + } + case "sub_label": + switch (timelineItem.data.label) { + case "person": + return ; + case "car": + return ; + } + } +} + +export function getTimelineItemDescription(timelineItem: Timeline) { + const label = ( + (Array.isArray(timelineItem.data.sub_label) + ? timelineItem.data.sub_label[0] + : timelineItem.data.sub_label) || timelineItem.data.label + ).replaceAll("_", " "); + + switch (timelineItem.class_type) { + case "visible": + return `${label} detected`; + case "entered_zone": + return `${label} entered ${timelineItem.data.zones + .join(" and ") + .replaceAll("_", " ")}`; + case "active": + return `${label} became active`; + case "stationary": + return `${label} became stationary`; + case "attribute": { + let title = ""; + if ( + timelineItem.data.attribute == "face" || + timelineItem.data.attribute == "license_plate" + ) { + title = `${timelineItem.data.attribute.replaceAll( + "_", + " " + )} detected for ${label}`; + } else { + title = `${ + timelineItem.data.sub_label + } recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`; + } + return title; + } + case "sub_label": + return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`; + case "gone": + return `${label} left`; + } +}