Review stream tweaks (#20662)

* tweak api to fetch multiple timelines

* support multiple selected objects in context

* rework context provider

* use toggle in detail stream

* use toggle in menu

* plot multiple object tracks

* verified icon, recognized plate, and clicking tweaks

* add plate to object lifecycle

* close menu before opening frigate+ dialog

* clean up

* normal text case for tooltip

* capitalization

* use flexbox for recording view
This commit is contained in:
Josh Hawkins
2025-10-25 16:15:36 -06:00
committed by GitHub
parent 0a6b9f98ed
commit 63042b9c08
11 changed files with 470 additions and 391 deletions
+56 -35
View File
@@ -171,7 +171,11 @@ export default function DetailStream({
<FrigatePlusDialog
upload={upload}
onClose={() => setUpload(undefined)}
onEventUploaded={() => setUpload(undefined)}
onEventUploaded={() => {
if (upload) {
upload.plus_id = "new_upload";
}
}}
/>
<div
@@ -254,7 +258,9 @@ function ReviewGroup({
const rawIconLabels: string[] = [
...(fetchedEvents
? fetchedEvents.map((e) => e.label)
? fetchedEvents.map((e) =>
e.sub_label ? e.label + "-verified" : e.label,
)
: (review.data?.objects ?? [])),
...(review.data?.audio ?? []),
];
@@ -317,7 +323,7 @@ function ReviewGroup({
<div className="ml-1 flex flex-col items-start gap-1.5">
<div className="flex flex-row gap-3">
<div className="text-sm font-medium">{displayTime}</div>
<div className="flex items-center gap-2">
<div className="relative flex items-center gap-2 text-white">
{iconLabels.slice(0, 5).map((lbl, idx) => (
<div
key={`${lbl}-${idx}`}
@@ -423,30 +429,34 @@ function EventList({
}: EventListProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const { selectedObjectId, setSelectedObjectId } = useDetailStream();
const { selectedObjectIds, toggleObjectSelection } = useDetailStream();
const isSelected = selectedObjectIds.includes(event.id);
const label = event.sub_label || getTranslatedLabel(event.label);
const handleObjectSelect = (event: Event | undefined) => {
if (event) {
onSeek(event.start_time ?? 0);
setSelectedObjectId(event.id);
// onSeek(event.start_time ?? 0);
toggleObjectSelection(event.id);
} else {
setSelectedObjectId(undefined);
toggleObjectSelection(undefined);
}
};
// Clear selectedObjectId when effectiveTime has passed this event's end_time
// Clear selection when effectiveTime has passed this event's end_time
useEffect(() => {
if (selectedObjectId === event.id && effectiveTime && event.end_time) {
if (isSelected && effectiveTime && event.end_time) {
if (effectiveTime >= event.end_time) {
setSelectedObjectId(undefined);
toggleObjectSelection(event.id);
}
}
}, [
selectedObjectId,
isSelected,
event.id,
event.end_time,
effectiveTime,
setSelectedObjectId,
toggleObjectSelection,
]);
return (
@@ -454,48 +464,59 @@ function EventList({
<div
className={cn(
"rounded-md bg-secondary p-2",
event.id == selectedObjectId
isSelected
? "bg-secondary-highlight"
: "outline-transparent duration-500",
event.id != selectedObjectId &&
!isSelected &&
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
(effectiveTime ?? 0) <=
(event.end_time ?? event.start_time ?? 0) + 0.5 &&
"bg-secondary-highlight",
)}
>
<div className="ml-1.5 flex w-full items-center justify-between">
<div
className="flex items-center gap-2 text-sm font-medium"
onClick={(e) => {
e.stopPropagation();
handleObjectSelect(
event.id == selectedObjectId ? undefined : event,
);
}}
role="button"
>
<div className="ml-1.5 flex w-full items-end justify-between">
<div className="flex flex-1 items-center gap-2 text-sm font-medium">
<div
className={cn(
"rounded-full p-1",
event.id == selectedObjectId
? "bg-selected"
: "bg-muted-foreground",
"relative rounded-full p-1 text-white",
isSelected ? "bg-selected" : "bg-muted-foreground",
)}
onClick={(e) => {
e.stopPropagation();
handleObjectSelect(isSelected ? undefined : event);
}}
>
{getIconForLabel(event.label, "size-3 text-white")}
{getIconForLabel(
event.sub_label ? event.label + "-verified" : event.label,
"size-3 text-white",
)}
</div>
<div className="flex items-end gap-2">
<span>{getTranslatedLabel(event.label)}</span>
<div
className="flex flex-1 items-center gap-2"
onClick={(e) => {
e.stopPropagation();
onSeek(event.start_time ?? 0);
}}
role="button"
>
<span className="capitalize">{label}</span>
{event.data?.recognized_license_plate && (
<>
·{" "}
<span className="text-sm text-secondary-foreground">
{event.data.recognized_license_plate}
</span>
</>
)}
</div>
</div>
<div className="mr-2 flex flex-1 flex-row justify-end">
<div className="mr-2 flex flex-row justify-end">
<EventMenu
event={event}
config={config}
onOpenUpload={(e) => onOpenUpload?.(e)}
selectedObjectId={selectedObjectId}
setSelectedObjectId={handleObjectSelect}
isSelected={isSelected}
onToggleSelection={handleObjectSelect}
/>
</div>
</div>
+12 -9
View File
@@ -12,14 +12,15 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import { useState } from "react";
type EventMenuProps = {
event: Event;
config?: FrigateConfig;
onOpenUpload?: (e: Event) => void;
onOpenSimilarity?: (e: Event) => void;
selectedObjectId?: string;
setSelectedObjectId?: (event: Event | undefined) => void;
isSelected?: boolean;
onToggleSelection?: (event: Event | undefined) => void;
};
export default function EventMenu({
@@ -27,25 +28,26 @@ export default function EventMenu({
config,
onOpenUpload,
onOpenSimilarity,
selectedObjectId,
setSelectedObjectId,
isSelected = false,
onToggleSelection,
}: EventMenuProps) {
const apiHost = useApiHost();
const navigate = useNavigate();
const { t } = useTranslation("views/explore");
const [isOpen, setIsOpen] = useState(false);
const handleObjectSelect = () => {
if (event.id === selectedObjectId) {
setSelectedObjectId?.(undefined);
if (isSelected) {
onToggleSelection?.(undefined);
} else {
setSelectedObjectId?.(event);
onToggleSelection?.(event);
}
};
return (
<>
<span tabIndex={0} className="sr-only" />
<DropdownMenu>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" />
@@ -54,7 +56,7 @@ export default function EventMenu({
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem onSelect={handleObjectSelect}>
{event.id === selectedObjectId
{isSelected
? t("itemMenu.hideObjectDetails.label")
: t("itemMenu.showObjectDetails.label")}
</DropdownMenuItem>
@@ -85,6 +87,7 @@ export default function EventMenu({
config?.plus?.enabled && (
<DropdownMenuItem
onSelect={() => {
setIsOpen(false);
onOpenUpload?.(event);
}}
>