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:
Nicolas Mowen 2023-12-20 07:33:57 -07:00 committed by Blake Blackshear
parent bdebb99b5a
commit f8d114cd33
11 changed files with 212 additions and 145 deletions

View File

@ -47,7 +47,7 @@ export default function HistoryCard({
<LuClock className="h-5 w-5 mr-2 inline" />
{formatUnixTimestampToDateTime(timeline.time, {
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",
date_style: "medium",
})}

View File

@ -61,7 +61,7 @@ export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
<div>
<div className="text-sm flex">
<LuClock className="h-4 w-4 mr-2 inline" />
<div className="hidden sm:inline">
<div>
<TimeAgo time={event.start_time * 1000} dense />
</div>
</div>
@ -70,7 +70,7 @@ export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
{event.camera.replaceAll("_", " ")}
</div>
{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" />
{event.zones.join(", ").replaceAll("_", " ")}
</div>

View File

@ -1,10 +1,11 @@
import { FrigateConfig } from "@/types/frigateConfig";
import VideoPlayer from "./VideoPlayer";
import useSWR from "swr";
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useApiHost } from "@/api";
import Player from "video.js/dist/types/player";
import { AspectRatio } from "../ui/aspect-ratio";
import { LuPlayCircle } from "react-icons/lu";
type PreviewPlayerProps = {
camera: string;
@ -32,6 +33,9 @@ export default function PreviewThumbnailPlayer({
const { data: config } = useSWR("config");
const playerRef = useRef<Player | null>(null);
const apiHost = useApiHost();
const isSafari = useMemo(() => {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}, []);
const [visible, setVisible] = useState(false);
@ -85,7 +89,12 @@ export default function PreviewThumbnailPlayer({
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);
} catch (e) {
@ -97,7 +106,10 @@ export default function PreviewThumbnailPlayer({
);
let content;
if (!relevantPreview || !visible) {
if (relevantPreview && !visible) {
content = <div />;
} else if (!relevantPreview) {
if (isCurrentHour(startTs)) {
content = (
<img
@ -105,42 +117,45 @@ export default function PreviewThumbnailPlayer({
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
/>
);
} else {
content = (
<img
className="w-[160px]"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
/>
);
}
content = (
<img
className="w-[160px]"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
/>
);
} else {
content = (
<div className={`${getPreviewWidth(camera, config)}`}>
<VideoPlayer
options={{
preload: "auto",
autoplay: false,
controls: false,
muted: true,
loadingSpinner: false,
sources: [
{
src: `${relevantPreview.src}`,
type: "video/mp4",
},
],
}}
seekOptions={{}}
onReady={(player) => {
playerRef.current = player;
player.playbackRate(8);
player.currentTime(startTs - relevantPreview.start);
}}
onDispose={() => {
playerRef.current = null;
}}
/>
</div>
<>
<div className={`${getPreviewWidth(camera, config)}`}>
<VideoPlayer
options={{
preload: "auto",
autoplay: false,
controls: false,
muted: true,
loadingSpinner: false,
sources: [
{
src: `${relevantPreview.src}`,
type: "video/mp4",
},
],
}}
seekOptions={{}}
onReady={(player) => {
playerRef.current = player;
player.playbackRate(isSafari ? 2 : 8);
player.currentTime(startTs - relevantPreview.start);
}}
onDispose={() => {
playerRef.current = null;
}}
/>
</div>
<LuPlayCircle className="absolute z-10 left-1 bottom-1 w-4 h-4 text-white text-opacity-60" />
</>
);
}

View File

@ -51,7 +51,7 @@ export default function VideoPlayer({
) as HTMLVideoElement;
videoElement.controls = true;
videoElement.playsInline = true;
videoElement.disableRemotePlayback = remotePlayback;
videoElement.disableRemotePlayback = !remotePlayback;
videoElement.classList.add("small-player");
videoElement.classList.add("video-js");
videoElement.classList.add("vjs-default-skin");

View File

@ -21,8 +21,9 @@ function ConfigEditor() {
const [success, setSuccess] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>();
const modelRef = useRef<monaco.editor.IEditorModel | null>();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const modelRef = useRef<monaco.editor.IEditorModel | null>(null);
const configRef = useRef<HTMLDivElement | null>(null);
const onHandleSaveConfig = useCallback(
async (save_option: SaveOptions) => {
@ -72,6 +73,7 @@ function ConfigEditor() {
if (modelRef.current != null) {
// we don't need to recreate the editor if it already exists
editorRef.current?.layout();
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, {
language: "yaml",
model: modelRef.current,
@ -107,6 +109,12 @@ function ConfigEditor() {
theme: theme == "dark" ? "vs-dark" : "vs-light",
});
}
return () => {
configRef.current = null;
editorRef.current = null;
modelRef.current = null;
};
});
if (!config) {
@ -149,7 +157,7 @@ function ConfigEditor() {
</div>
)}
<div id="container" className="h-full mt-2" />
<div ref={configRef} className="h-full mt-2" />
</div>
);
}

View File

@ -18,18 +18,15 @@ import { FaWalking } from "react-icons/fa";
import { LuEar } from "react-icons/lu";
import { TbMovie } from "react-icons/tb";
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";
export function Dashboard() {
const { data: config } = useSWR<FrigateConfig>("config");
const recentTimestamp = useMemo(() => {
const now = new Date();
now.setMinutes(now.getMinutes() - 30);
return now.getTime() / 1000;
}, []);
const { data: events, mutate: updateEvents } = useSWR<Event[]>([
const now = new Date();
now.setMinutes(now.getMinutes() - 30, 0, 0);
const recentTimestamp = now.getTime() / 1000;
const { data: events, mutate: updateEvents } = useSWR<FrigateEvent[]>([
"events",
{ limit: 10, after: recentTimestamp },
]);
@ -97,7 +94,7 @@ function Camera({ camera }: { camera: CameraConfig }) {
return (
<>
<Card className="">
<Card>
<a href={`/live/${camera.name}`}>
<AspectRatio
ratio={16 / 9}
@ -116,7 +113,11 @@ function Camera({ camera }: { camera: CameraConfig }) {
className={`${
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 />
</Button>
@ -130,11 +131,13 @@ function Camera({ camera }: { camera: CameraConfig }) {
: "text-gray-400"
: "text-red-500"
}
onClick={() =>
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
camera.record.enabled_in_config
? sendRecord(recordValue == "ON" ? "OFF" : "ON")
: {}
}
: {};
}}
>
<TbMovie />
</Button>
@ -144,7 +147,11 @@ function Camera({ camera }: { camera: CameraConfig }) {
className={`${
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 />
</Button>
@ -155,7 +162,11 @@ function Camera({ camera }: { camera: CameraConfig }) {
className={`${
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 />
</Button>

View File

@ -7,9 +7,10 @@ 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";
import TimelinePlayerCard from "@/components/card/TimelinePlayerCard";
import { getHourlyTimelineData } from "@/utils/historyUtil";
const API_LIMIT = 120;
const API_LIMIT = 200;
function History() {
const { data: config } = useSWR<FrigateConfig>("config");
@ -59,83 +60,7 @@ function History() {
return [];
}
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();
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;
return getHourlyTimelineData(timelinePages, detailLevel);
}, [detailLevel, timelinePages]);
const isDone =
@ -168,9 +93,6 @@ function History() {
return (
<>
<Heading as="h2">Review</Heading>
<div className="text-xs mb-4">
Dates and times are based on the timezone {timezone}
</div>
<TimelinePlayerCard
timeline={playback}
@ -183,7 +105,10 @@ function History() {
.map(([day, timelineDay], dayIdx) => {
return (
<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), {
strftime_fmt: "%A %b %d",
time_style: "medium",
@ -206,7 +131,10 @@ function History() {
<div key={hour} ref={lastRow ? lastTimelineRef : null}>
<Heading as="h4">
{formatUnixTimestampToDateTime(parseInt(hour), {
strftime_fmt: "%I:00",
strftime_fmt:
config.ui.time_format == "24hour"
? "%H:00"
: "%I:00 %p",
time_style: "medium",
date_style: "medium",
})}
@ -229,6 +157,7 @@ function History() {
preview.end > startTs
);
}
return (
<HistoryCard
key={key}

View File

@ -10,6 +10,7 @@ type Card = {
camera: string,
time: number,
entries: Timeline[],
uniqueKeys: string[],
}
type Preview = {

View 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;
}

View File

@ -39,7 +39,7 @@
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--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%;
--ring: 224.3 76.3% 48%;
}