mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-16 13:47:07 +02:00
Webui cleanups (#8991)
* Fix mobile event timeago * Reduce preview playback rate for safari browser * Fix dashboard buttons * Update recent events correctly * Fix opening page on icon toggle * Fix video player remote playback check * fix history image * Add sticky headers to history page * Fix iOS empty frame * reduce duplicate items and improve time format * Organize data more effictively and ensure data is not overwritten * Use icon to indicate preview
This commit is contained in:
parent
bdebb99b5a
commit
f8d114cd33
@ -47,7 +47,7 @@ export default function HistoryCard({
|
|||||||
<LuClock className="h-5 w-5 mr-2 inline" />
|
<LuClock className="h-5 w-5 mr-2 inline" />
|
||||||
{formatUnixTimestampToDateTime(timeline.time, {
|
{formatUnixTimestampToDateTime(timeline.time, {
|
||||||
strftime_fmt:
|
strftime_fmt:
|
||||||
config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S",
|
config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S %p",
|
||||||
time_style: "medium",
|
time_style: "medium",
|
||||||
date_style: "medium",
|
date_style: "medium",
|
||||||
})}
|
})}
|
||||||
|
@ -61,7 +61,7 @@ export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-sm flex">
|
<div className="text-sm flex">
|
||||||
<LuClock className="h-4 w-4 mr-2 inline" />
|
<LuClock className="h-4 w-4 mr-2 inline" />
|
||||||
<div className="hidden sm:inline">
|
<div>
|
||||||
<TimeAgo time={event.start_time * 1000} dense />
|
<TimeAgo time={event.start_time * 1000} dense />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -70,7 +70,7 @@ export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
|
|||||||
{event.camera.replaceAll("_", " ")}
|
{event.camera.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
{event.zones.length ? (
|
{event.zones.length ? (
|
||||||
<div className="capitalize text-sm flex align-center">
|
<div className="capitalize whitespace-nowrap text-sm flex align-center">
|
||||||
<MdOutlineLocationOn className="w-4 h-4 mr-2 inline" />
|
<MdOutlineLocationOn className="w-4 h-4 mr-2 inline" />
|
||||||
{event.zones.join(", ").replaceAll("_", " ")}
|
{event.zones.join(", ").replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import VideoPlayer from "./VideoPlayer";
|
import VideoPlayer from "./VideoPlayer";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import Player from "video.js/dist/types/player";
|
import Player from "video.js/dist/types/player";
|
||||||
import { AspectRatio } from "../ui/aspect-ratio";
|
import { AspectRatio } from "../ui/aspect-ratio";
|
||||||
|
import { LuPlayCircle } from "react-icons/lu";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
@ -32,6 +33,9 @@ export default function PreviewThumbnailPlayer({
|
|||||||
const { data: config } = useSWR("config");
|
const { data: config } = useSWR("config");
|
||||||
const playerRef = useRef<Player | null>(null);
|
const playerRef = useRef<Player | null>(null);
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
|
const isSafari = useMemo(() => {
|
||||||
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
@ -85,7 +89,12 @@ export default function PreviewThumbnailPlayer({
|
|||||||
onPlayback(false);
|
onPlayback(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ threshold: 1.0 }
|
{
|
||||||
|
threshold: 1.0,
|
||||||
|
root: document.getElementById("pageRoot"),
|
||||||
|
// iOS has bug where poster is empty frame until video starts playing so playback needs to begin earlier
|
||||||
|
rootMargin: isSafari ? "10% 0px 25% 0px" : "0px",
|
||||||
|
}
|
||||||
);
|
);
|
||||||
if (node) autoPlayObserver.current.observe(node);
|
if (node) autoPlayObserver.current.observe(node);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -97,7 +106,10 @@ export default function PreviewThumbnailPlayer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
if (!relevantPreview || !visible) {
|
|
||||||
|
if (relevantPreview && !visible) {
|
||||||
|
content = <div />;
|
||||||
|
} else if (!relevantPreview) {
|
||||||
if (isCurrentHour(startTs)) {
|
if (isCurrentHour(startTs)) {
|
||||||
content = (
|
content = (
|
||||||
<img
|
<img
|
||||||
@ -105,16 +117,17 @@ export default function PreviewThumbnailPlayer({
|
|||||||
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
|
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
content = (
|
content = (
|
||||||
<img
|
<img
|
||||||
className="w-[160px]"
|
className="w-[160px]"
|
||||||
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
|
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content = (
|
content = (
|
||||||
|
<>
|
||||||
<div className={`${getPreviewWidth(camera, config)}`}>
|
<div className={`${getPreviewWidth(camera, config)}`}>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
options={{
|
options={{
|
||||||
@ -133,7 +146,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
seekOptions={{}}
|
seekOptions={{}}
|
||||||
onReady={(player) => {
|
onReady={(player) => {
|
||||||
playerRef.current = player;
|
playerRef.current = player;
|
||||||
player.playbackRate(8);
|
player.playbackRate(isSafari ? 2 : 8);
|
||||||
player.currentTime(startTs - relevantPreview.start);
|
player.currentTime(startTs - relevantPreview.start);
|
||||||
}}
|
}}
|
||||||
onDispose={() => {
|
onDispose={() => {
|
||||||
@ -141,6 +154,8 @@ export default function PreviewThumbnailPlayer({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<LuPlayCircle className="absolute z-10 left-1 bottom-1 w-4 h-4 text-white text-opacity-60" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,7 +51,7 @@ export default function VideoPlayer({
|
|||||||
) as HTMLVideoElement;
|
) as HTMLVideoElement;
|
||||||
videoElement.controls = true;
|
videoElement.controls = true;
|
||||||
videoElement.playsInline = true;
|
videoElement.playsInline = true;
|
||||||
videoElement.disableRemotePlayback = remotePlayback;
|
videoElement.disableRemotePlayback = !remotePlayback;
|
||||||
videoElement.classList.add("small-player");
|
videoElement.classList.add("small-player");
|
||||||
videoElement.classList.add("video-js");
|
videoElement.classList.add("video-js");
|
||||||
videoElement.classList.add("vjs-default-skin");
|
videoElement.classList.add("vjs-default-skin");
|
||||||
|
@ -21,8 +21,9 @@ function ConfigEditor() {
|
|||||||
const [success, setSuccess] = useState<string | undefined>();
|
const [success, setSuccess] = useState<string | undefined>();
|
||||||
const [error, setError] = useState<string | undefined>();
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>();
|
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||||
const modelRef = useRef<monaco.editor.IEditorModel | null>();
|
const modelRef = useRef<monaco.editor.IEditorModel | null>(null);
|
||||||
|
const configRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const onHandleSaveConfig = useCallback(
|
const onHandleSaveConfig = useCallback(
|
||||||
async (save_option: SaveOptions) => {
|
async (save_option: SaveOptions) => {
|
||||||
@ -72,6 +73,7 @@ function ConfigEditor() {
|
|||||||
|
|
||||||
if (modelRef.current != null) {
|
if (modelRef.current != null) {
|
||||||
// we don't need to recreate the editor if it already exists
|
// we don't need to recreate the editor if it already exists
|
||||||
|
editorRef.current?.layout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,9 +99,9 @@ function ConfigEditor() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const container = document.getElementById("container");
|
const container = configRef.current;
|
||||||
|
|
||||||
if (container != undefined) {
|
if (container != null) {
|
||||||
editorRef.current = monaco.editor.create(container, {
|
editorRef.current = monaco.editor.create(container, {
|
||||||
language: "yaml",
|
language: "yaml",
|
||||||
model: modelRef.current,
|
model: modelRef.current,
|
||||||
@ -107,6 +109,12 @@ function ConfigEditor() {
|
|||||||
theme: theme == "dark" ? "vs-dark" : "vs-light",
|
theme: theme == "dark" ? "vs-dark" : "vs-light",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
configRef.current = null;
|
||||||
|
editorRef.current = null;
|
||||||
|
modelRef.current = null;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@ -149,7 +157,7 @@ function ConfigEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div id="container" className="h-full mt-2" />
|
<div ref={configRef} className="h-full mt-2" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -18,18 +18,15 @@ import { FaWalking } from "react-icons/fa";
|
|||||||
import { LuEar } from "react-icons/lu";
|
import { LuEar } from "react-icons/lu";
|
||||||
import { TbMovie } from "react-icons/tb";
|
import { TbMovie } from "react-icons/tb";
|
||||||
import MiniEventCard from "@/components/card/MiniEventCard";
|
import MiniEventCard from "@/components/card/MiniEventCard";
|
||||||
import { Event } from "@/types/event";
|
import { Event as FrigateEvent } from "@/types/event";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const recentTimestamp = useMemo(() => {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setMinutes(now.getMinutes() - 30);
|
now.setMinutes(now.getMinutes() - 30, 0, 0);
|
||||||
return now.getTime() / 1000;
|
const recentTimestamp = now.getTime() / 1000;
|
||||||
}, []);
|
const { data: events, mutate: updateEvents } = useSWR<FrigateEvent[]>([
|
||||||
const { data: events, mutate: updateEvents } = useSWR<Event[]>([
|
|
||||||
"events",
|
"events",
|
||||||
{ limit: 10, after: recentTimestamp },
|
{ limit: 10, after: recentTimestamp },
|
||||||
]);
|
]);
|
||||||
@ -97,7 +94,7 @@ function Camera({ camera }: { camera: CameraConfig }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="">
|
<Card>
|
||||||
<a href={`/live/${camera.name}`}>
|
<a href={`/live/${camera.name}`}>
|
||||||
<AspectRatio
|
<AspectRatio
|
||||||
ratio={16 / 9}
|
ratio={16 / 9}
|
||||||
@ -116,7 +113,11 @@ function Camera({ camera }: { camera: CameraConfig }) {
|
|||||||
className={`${
|
className={`${
|
||||||
detectValue == "ON" ? "text-primary" : "text-gray-400"
|
detectValue == "ON" ? "text-primary" : "text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => sendDetect(detectValue == "ON" ? "OFF" : "ON")}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
sendDetect(detectValue == "ON" ? "OFF" : "ON");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FaWalking />
|
<FaWalking />
|
||||||
</Button>
|
</Button>
|
||||||
@ -130,11 +131,13 @@ function Camera({ camera }: { camera: CameraConfig }) {
|
|||||||
: "text-gray-400"
|
: "text-gray-400"
|
||||||
: "text-red-500"
|
: "text-red-500"
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
camera.record.enabled_in_config
|
camera.record.enabled_in_config
|
||||||
? sendRecord(recordValue == "ON" ? "OFF" : "ON")
|
? sendRecord(recordValue == "ON" ? "OFF" : "ON")
|
||||||
: {}
|
: {};
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<TbMovie />
|
<TbMovie />
|
||||||
</Button>
|
</Button>
|
||||||
@ -144,7 +147,11 @@ function Camera({ camera }: { camera: CameraConfig }) {
|
|||||||
className={`${
|
className={`${
|
||||||
snapshotValue == "ON" ? "text-primary" : "text-gray-400"
|
snapshotValue == "ON" ? "text-primary" : "text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => sendSnapshot(detectValue == "ON" ? "OFF" : "ON")}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
sendSnapshot(detectValue == "ON" ? "OFF" : "ON");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AiOutlinePicture />
|
<AiOutlinePicture />
|
||||||
</Button>
|
</Button>
|
||||||
@ -155,7 +162,11 @@ function Camera({ camera }: { camera: CameraConfig }) {
|
|||||||
className={`${
|
className={`${
|
||||||
audioValue == "ON" ? "text-primary" : "text-gray-400"
|
audioValue == "ON" ? "text-primary" : "text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => sendAudio(detectValue == "ON" ? "OFF" : "ON")}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
sendAudio(detectValue == "ON" ? "OFF" : "ON");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<LuEar />
|
<LuEar />
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -7,9 +7,10 @@ import ActivityIndicator from "@/components/ui/activity-indicator";
|
|||||||
import HistoryCard from "@/components/card/HistoryCard";
|
import HistoryCard from "@/components/card/HistoryCard";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import TimelinePlayerCard from "@/components/card/TimelineCardPlayer";
|
import TimelinePlayerCard from "@/components/card/TimelinePlayerCard";
|
||||||
|
import { getHourlyTimelineData } from "@/utils/historyUtil";
|
||||||
|
|
||||||
const API_LIMIT = 120;
|
const API_LIMIT = 200;
|
||||||
|
|
||||||
function History() {
|
function History() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -59,83 +60,7 @@ function History() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards: CardsData = {};
|
return getHourlyTimelineData(timelinePages, detailLevel);
|
||||||
timelinePages.forEach((hourlyTimeline) => {
|
|
||||||
Object.keys(hourlyTimeline["hours"])
|
|
||||||
.reverse()
|
|
||||||
.forEach((hour) => {
|
|
||||||
const day = new Date(parseInt(hour) * 1000);
|
|
||||||
day.setHours(0, 0, 0, 0);
|
|
||||||
const dayKey = (day.getTime() / 1000).toString();
|
|
||||||
const source_to_types: { [key: string]: string[] } = {};
|
|
||||||
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
|
||||||
const time = new Date(i.timestamp * 1000);
|
|
||||||
time.setSeconds(0);
|
|
||||||
time.setMilliseconds(0);
|
|
||||||
const key = `${i.source_id}-${time.getMinutes()}`;
|
|
||||||
if (key in source_to_types) {
|
|
||||||
source_to_types[key].push(i.class_type);
|
|
||||||
} else {
|
|
||||||
source_to_types[key] = [i.class_type];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!Object.keys(cards).includes(dayKey)) {
|
|
||||||
cards[dayKey] = {};
|
|
||||||
}
|
|
||||||
cards[dayKey][hour] = {};
|
|
||||||
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
|
||||||
const time = new Date(i.timestamp * 1000);
|
|
||||||
const key = `${i.camera}-${time.getMinutes()}`;
|
|
||||||
|
|
||||||
// detail level for saving items
|
|
||||||
// detail level determines which timeline items for each moment is returned
|
|
||||||
// values can be normal, extra, or full
|
|
||||||
// normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
|
|
||||||
// extra: return all items except attribute / gone / visible unless that is the only item
|
|
||||||
// full: return all items
|
|
||||||
|
|
||||||
let add = true;
|
|
||||||
if (detailLevel == "normal") {
|
|
||||||
if (
|
|
||||||
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
|
|
||||||
1 &&
|
|
||||||
[
|
|
||||||
"active",
|
|
||||||
"attribute",
|
|
||||||
"gone",
|
|
||||||
"stationary",
|
|
||||||
"visible",
|
|
||||||
].includes(i.class_type)
|
|
||||||
) {
|
|
||||||
add = false;
|
|
||||||
}
|
|
||||||
} else if (detailLevel == "extra") {
|
|
||||||
if (
|
|
||||||
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
|
|
||||||
1 &&
|
|
||||||
i.class_type in ["attribute", "gone", "visible"]
|
|
||||||
) {
|
|
||||||
add = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (add) {
|
|
||||||
if (key in cards[dayKey][hour]) {
|
|
||||||
cards[dayKey][hour][key].entries.push(i);
|
|
||||||
} else {
|
|
||||||
cards[dayKey][hour][key] = {
|
|
||||||
camera: i.camera,
|
|
||||||
time: time.getTime() / 1000,
|
|
||||||
entries: [i],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return cards;
|
|
||||||
}, [detailLevel, timelinePages]);
|
}, [detailLevel, timelinePages]);
|
||||||
|
|
||||||
const isDone =
|
const isDone =
|
||||||
@ -168,9 +93,6 @@ function History() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading as="h2">Review</Heading>
|
<Heading as="h2">Review</Heading>
|
||||||
<div className="text-xs mb-4">
|
|
||||||
Dates and times are based on the timezone {timezone}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TimelinePlayerCard
|
<TimelinePlayerCard
|
||||||
timeline={playback}
|
timeline={playback}
|
||||||
@ -183,7 +105,10 @@ function History() {
|
|||||||
.map(([day, timelineDay], dayIdx) => {
|
.map(([day, timelineDay], dayIdx) => {
|
||||||
return (
|
return (
|
||||||
<div key={day}>
|
<div key={day}>
|
||||||
<Heading as="h3">
|
<Heading
|
||||||
|
className="sticky py-2 -top-4 left-0 bg-background w-full z-10"
|
||||||
|
as="h3"
|
||||||
|
>
|
||||||
{formatUnixTimestampToDateTime(parseInt(day), {
|
{formatUnixTimestampToDateTime(parseInt(day), {
|
||||||
strftime_fmt: "%A %b %d",
|
strftime_fmt: "%A %b %d",
|
||||||
time_style: "medium",
|
time_style: "medium",
|
||||||
@ -206,7 +131,10 @@ function History() {
|
|||||||
<div key={hour} ref={lastRow ? lastTimelineRef : null}>
|
<div key={hour} ref={lastRow ? lastTimelineRef : null}>
|
||||||
<Heading as="h4">
|
<Heading as="h4">
|
||||||
{formatUnixTimestampToDateTime(parseInt(hour), {
|
{formatUnixTimestampToDateTime(parseInt(hour), {
|
||||||
strftime_fmt: "%I:00",
|
strftime_fmt:
|
||||||
|
config.ui.time_format == "24hour"
|
||||||
|
? "%H:00"
|
||||||
|
: "%I:00 %p",
|
||||||
time_style: "medium",
|
time_style: "medium",
|
||||||
date_style: "medium",
|
date_style: "medium",
|
||||||
})}
|
})}
|
||||||
@ -229,6 +157,7 @@ function History() {
|
|||||||
preview.end > startTs
|
preview.end > startTs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HistoryCard
|
<HistoryCard
|
||||||
key={key}
|
key={key}
|
||||||
|
@ -10,6 +10,7 @@ type Card = {
|
|||||||
camera: string,
|
camera: string,
|
||||||
time: number,
|
time: number,
|
||||||
entries: Timeline[],
|
entries: Timeline[],
|
||||||
|
uniqueKeys: string[],
|
||||||
}
|
}
|
||||||
|
|
||||||
type Preview = {
|
type Preview = {
|
||||||
|
103
web/src/utils/historyUtil.ts
Normal file
103
web/src/utils/historyUtil.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
// group history cards by 60 seconds of activity
|
||||||
|
const GROUP_SECONDS = 60;
|
||||||
|
|
||||||
|
export function getHourlyTimelineData(
|
||||||
|
timelinePages: HourlyTimeline[],
|
||||||
|
detailLevel: string
|
||||||
|
) {
|
||||||
|
const cards: CardsData = {};
|
||||||
|
timelinePages.forEach((hourlyTimeline) => {
|
||||||
|
Object.keys(hourlyTimeline["hours"])
|
||||||
|
.reverse()
|
||||||
|
.forEach((hour) => {
|
||||||
|
const day = new Date(parseInt(hour) * 1000);
|
||||||
|
day.setHours(0, 0, 0, 0);
|
||||||
|
const dayKey = (day.getTime() / 1000).toString();
|
||||||
|
|
||||||
|
// build a map of course to the types that are included in this hour
|
||||||
|
// which allows us to know what items to keep depending on detail level
|
||||||
|
const source_to_types: { [key: string]: string[] } = {};
|
||||||
|
let cardTypeStart: { [camera: string]: number } = {};
|
||||||
|
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
||||||
|
if (i.timestamp > (cardTypeStart[i.camera] ?? 0) + GROUP_SECONDS) {
|
||||||
|
cardTypeStart[i.camera] = i.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupKey = `${i.source_id}-${cardTypeStart[i.camera]}`;
|
||||||
|
|
||||||
|
if (groupKey in source_to_types) {
|
||||||
|
source_to_types[groupKey].push(i.class_type);
|
||||||
|
} else {
|
||||||
|
source_to_types[groupKey] = [i.class_type];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(dayKey in cards)) {
|
||||||
|
cards[dayKey] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(hour in cards[dayKey])) {
|
||||||
|
cards[dayKey][hour] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cardStart: { [camera: string]: number } = {};
|
||||||
|
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
||||||
|
if (i.timestamp > (cardStart[i.camera] ?? 0) + GROUP_SECONDS) {
|
||||||
|
cardStart[i.camera] = i.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = new Date(i.timestamp * 1000);
|
||||||
|
const groupKey = `${i.camera}-${cardStart[i.camera]}`;
|
||||||
|
const sourceKey = `${i.source_id}-${cardStart[i.camera]}`;
|
||||||
|
const uniqueKey = `${i.source_id}-${i.class_type}`;
|
||||||
|
|
||||||
|
// detail level for saving items
|
||||||
|
// detail level determines which timeline items for each moment is returned
|
||||||
|
// values can be normal, extra, or full
|
||||||
|
// normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
|
||||||
|
// extra: return all items except attribute / gone / visible unless that is the only item
|
||||||
|
// full: return all items
|
||||||
|
|
||||||
|
let add = true;
|
||||||
|
if (detailLevel == "normal") {
|
||||||
|
if (
|
||||||
|
source_to_types[sourceKey].length > 1 &&
|
||||||
|
["active", "attribute", "gone", "stationary", "visible"].includes(
|
||||||
|
i.class_type
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
add = false;
|
||||||
|
}
|
||||||
|
} else if (detailLevel == "extra") {
|
||||||
|
if (
|
||||||
|
source_to_types[sourceKey].length > 1 &&
|
||||||
|
i.class_type in ["attribute", "gone", "visible"]
|
||||||
|
) {
|
||||||
|
add = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (add) {
|
||||||
|
if (groupKey in cards[dayKey][hour]) {
|
||||||
|
if (
|
||||||
|
!cards[dayKey][hour][groupKey].uniqueKeys.includes(uniqueKey) ||
|
||||||
|
detailLevel == "full"
|
||||||
|
) {
|
||||||
|
cards[dayKey][hour][groupKey].entries.push(i);
|
||||||
|
cards[dayKey][hour][groupKey].uniqueKeys.push(uniqueKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cards[dayKey][hour][groupKey] = {
|
||||||
|
camera: i.camera,
|
||||||
|
time: time.getTime() / 1000,
|
||||||
|
entries: [i],
|
||||||
|
uniqueKeys: [uniqueKey],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
@ -39,7 +39,7 @@
|
|||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 210 40% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 36.6% 12.5%;
|
||||||
--input: 217.2 38.6% 29.5%;
|
--input: 217.2 38.6% 29.5%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--ring: 224.3 76.3% 48%;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user