WebUI Improvements and fixes (#9613)

* Show toast instead of text for success and errors

* Show correct times

* Start playing next hour when current hour ends

* Fix refreshing camera image

* Fix timeline
This commit is contained in:
Nicolas Mowen
2024-02-05 16:54:08 -07:00
committed by GitHub
parent 3df8b5829c
commit f4310862aa
10 changed files with 435 additions and 57 deletions

View File

@@ -24,6 +24,9 @@ export default function DynamicCameraImage({
aspect,
}: DynamicCameraImageProps) {
const [key, setKey] = useState(Date.now());
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>(
undefined
);
const [activeObjects, setActiveObjects] = useState<string[]>([]);
const hasActiveObjects = useMemo(
() => activeObjects.length > 0,
@@ -58,6 +61,8 @@ export default function DynamicCameraImage({
if (eventIndex == -1) {
const newActiveObjects = [...activeObjects, event.after.id];
setActiveObjects(newActiveObjects);
clearTimeout(timeoutId);
setKey(Date.now());
}
}
}
@@ -69,12 +74,13 @@ export default function DynamicCameraImage({
? INTERVAL_ACTIVE_MS
: INTERVAL_INACTIVE_MS;
setTimeout(
const tId = setTimeout(
() => {
setKey(Date.now());
},
loadTime > loadInterval ? 1 : loadInterval
);
setTimeoutId(tId);
}, [key]);
return (

View File

@@ -228,6 +228,7 @@ export default function DynamicVideoPlayer({
player.on("timeupdate", () => {
controller.updateProgress(player.currentTime() || 0);
});
player.on("ended", () => controller.fireClipEndEvent());
if (onControllerReady) {
onControllerReady(controller);
@@ -284,6 +285,7 @@ export class DynamicVideoController {
// playback
private recordings: Recording[] = [];
private onPlaybackTimestamp: ((time: number) => void) | undefined = undefined;
private onClipEnded: (() => void) | undefined = undefined;
private annotationOffset: number;
private timeToStart: number | undefined = undefined;
@@ -393,6 +395,16 @@ export class DynamicVideoController {
this.onPlaybackTimestamp = listener;
}
onClipEndedEvent(listener: () => void) {
this.onClipEnded = listener;
}
fireClipEndEvent() {
if (this.onClipEnded) {
this.onClipEnded();
}
}
scrubToTimestamp(time: number) {
if (this.playerMode != "scrubbing") {
this.playerMode = "scrubbing";

View File

@@ -0,0 +1,32 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
success:
"group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
error: "group toast group-[.toaster]:bg-danger group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
import axios from "axios";
import copy from "copy-to-clipboard";
import { useTheme } from "@/context/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
type SaveOptions = "saveonly" | "restart";
@@ -18,7 +20,6 @@ function ConfigEditor() {
const { data: config } = useSWR<string>("config/raw");
const { theme } = useTheme();
const [success, setSuccess] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
@@ -42,11 +43,11 @@ function ConfigEditor() {
.then((response) => {
if (response.status === 200) {
setError("");
setSuccess(response.data.message);
toast.success(response.data.message, { position: "top-center" });
}
})
.catch((error) => {
setSuccess("");
toast.error("Error saving config", { position: "top-center" });
if (error.response) {
setError(error.response.data.message);
@@ -150,7 +151,6 @@ function ConfigEditor() {
</div>
</div>
{success && <div className="max-h-20 text-success">{success}</div>}
{error && (
<div className="p-4 overflow-scroll text-danger whitespace-pre-wrap">
{error}
@@ -158,6 +158,7 @@ function ConfigEditor() {
)}
<div ref={configRef} className="h-full mt-2" />
<Toaster />
</div>
);
}

View File

@@ -34,11 +34,13 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Toaster } from "@/components/ui/sonner";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { format } from "date-fns";
import { useCallback, useState } from "react";
import { DateRange } from "react-day-picker";
import { toast } from "sonner";
import useSWR from "swr";
type ExportItem = {
@@ -55,7 +57,6 @@ function Export() {
// Export States
const [camera, setCamera] = useState<string | undefined>();
const [playback, setPlayback] = useState<string | undefined>();
const [message, setMessage] = useState({ text: "", error: false });
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
@@ -70,23 +71,21 @@ function Export() {
const [deleteClip, setDeleteClip] = useState<string | undefined>();
const onHandleExport = () => {
if (camera == "select") {
setMessage({ text: "A camera needs to be selected.", error: true });
if (!camera) {
toast.error("A camera needs to be selected.", { position: "top-center" });
return;
}
if (playback == "select") {
setMessage({
text: "A playback factor needs to be selected.",
error: true,
if (!playback) {
toast.error("A playback factor needs to be selected.", {
position: "top-center",
});
return;
}
if (!date?.from || !startTime || !endTime) {
setMessage({
text: "A start and end time needs to be selected",
error: true,
toast.error("A start and end time needs to be selected", {
position: "top-center",
});
return;
}
@@ -106,9 +105,8 @@ function Export() {
const end = endDate.getTime() / 1000;
if (end <= start) {
setMessage({
text: "The end time must be after the start time.",
error: true,
toast.error("The end time must be after the start time.", {
position: "top-center",
});
return;
}
@@ -117,24 +115,23 @@ function Export() {
.post(`export/${camera}/start/${start}/end/${end}`, { playback })
.then((response) => {
if (response.status == 200) {
setMessage({
text: "Successfully started export. View the file in the /exports folder.",
error: false,
});
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" }
);
}
mutate();
})
.catch((error) => {
if (error.response?.data?.message) {
setMessage({
text: `Failed to start export: ${error.response.data.message}`,
error: true,
});
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" }
);
} else {
setMessage({
text: `Failed to start export: ${error.message}`,
error: true,
toast.error(`Failed to start export: ${error.message}`, {
position: "top-center",
});
}
});
@@ -156,16 +153,7 @@ function Export() {
return (
<>
<Heading as="h2">Export</Heading>
{message.text && (
<div
className={`max-h-20 ${
message.error ? "text-danger" : "text-success"
}`}
>
{message.text}
</div>
)}
<Toaster />
<AlertDialog
open={deleteClip != undefined}

View File

@@ -284,8 +284,13 @@ export function getRangeForTimestamp(timestamp: number) {
date.setHours(date.getHours() + 1);
// ensure not to go past current time
const end = Math.min(new Date().getTime() / 1000, date.getTime() / 1000);
return { start, end };
return { start, end: endOfHourOrCurrentTime(date.getTime() / 1000) };
}
export function endOfHourOrCurrentTime(timestamp: number) {
const now = new Date();
now.setMilliseconds(0);
return Math.min(timestamp, now.getTime() / 1000);
}
export function isCurrentHour(timestamp: number) {

View File

@@ -1,3 +1,5 @@
import { endOfHourOrCurrentTime } from "./dateUtil";
// group history cards by 120 seconds of activity
const GROUP_SECONDS = 120;
@@ -169,7 +171,7 @@ export function getTimelineHoursForDay(
break;
}
end = startDay.getTime() / 1000;
end = endOfHourOrCurrentTime(startDay.getTime() / 1000);
const hour = Object.values(day).find((cards) => {
const card = Object.values(cards)[0];
if (card == undefined || card.time < start || card.time > end) {

View File

@@ -34,9 +34,6 @@ export default function DesktopTimelineView({
const controllerRef = useRef<DynamicVideoController | undefined>(undefined);
const initialScrollRef = useRef<HTMLDivElement | null>(null);
const [selectedPlayback, setSelectedPlayback] = useState(initialPlayback);
const [timelineTime, setTimelineTime] = useState(0);
// handle scrolling to initial timeline item
useEffect(() => {
if (initialScrollRef.current != null) {
@@ -50,17 +47,45 @@ export default function DesktopTimelineView({
});
}, []);
const [timelineTime, setTimelineTime] = useState(0);
const timelineStack = useMemo(
() =>
getTimelineHoursForDay(
selectedPlayback.camera,
initialPlayback.camera,
timelineData,
cameraPreviews,
selectedPlayback.range.start + 60
initialPlayback.range.start + 60
),
[]
);
const [selectedPlaybackIdx, setSelectedPlaybackIdx] = useState(
timelineStack.playbackItems.findIndex((playback) => {
return (
playback.range.start == initialPlayback.range.start &&
playback.range.end == initialPlayback.range.end
);
})
);
const selectedPlayback = useMemo(
() => timelineStack.playbackItems[selectedPlaybackIdx],
[selectedPlaybackIdx]
);
// handle moving to next clip
useEffect(() => {
if (!controllerRef.current) {
return;
}
if (selectedPlaybackIdx > 0) {
controllerRef.current.onClipEndedEvent(() => {
console.log("setting to " + (selectedPlaybackIdx - 1));
setSelectedPlaybackIdx(selectedPlaybackIdx - 1);
});
}
}, [controllerRef, selectedPlaybackIdx]);
const { data: activity } = useSWR<RecordingActivity>(
[
`${initialPlayback.camera}/recording/hourly/activity`,
@@ -148,12 +173,14 @@ export default function DesktopTimelineView({
</div>
<div className="relative mt-4 w-full h-full">
<div className="absolute left-0 top-0 right-0 bottom-0 overflow-auto">
{timelineStack.playbackItems.map((timeline) => {
{timelineStack.playbackItems.map((timeline, tIdx) => {
const isInitiallySelected =
initialPlayback.range.start == timeline.range.start;
const isSelected =
timeline.range.start == selectedPlayback.range.start;
const graphData = timelineGraphData[timeline.range.start];
const start = new Date(timeline.range.start * 1000);
const end = new Date(timeline.range.end * 1000);
return (
<div
@@ -178,10 +205,10 @@ export default function DesktopTimelineView({
}
options={{
snap: null,
min: new Date(timeline.range.start * 1000),
max: new Date(timeline.range.end * 1000),
start: new Date(timeline.range.start * 1000),
end: new Date(timeline.range.end * 1000),
min: start,
max: end,
start: start,
end: end,
zoomable: false,
height: "120px",
}}
@@ -220,7 +247,7 @@ export default function DesktopTimelineView({
startTime={timeline.range.start}
graphData={graphData}
onClick={() => {
setSelectedPlayback(timeline);
setSelectedPlaybackIdx(tIdx);
let startTs;
if (timeline.timelineItems.length > 0) {