From 622e9741c07f6f6403f145d4c5f83236d8776649 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Tue, 27 Feb 2024 13:41:26 -0600
Subject: [PATCH] Review timeline improvements (#10102)
* make event bars clickable
* outline and scroll when segment is clicked
* match outline colors to event type
* hover thumbnails
* make event bars clickable
* outline and scroll when segment is clicked
* match outline colors to event type
* hover thumbnails
* fix merge from rebase
* remove minimap opacity classes
* live player outline colors
* safelist shadow classes
---
web/src/components/player/LivePlayer.tsx | 2 +-
.../timeline/EventReviewTimeline.tsx | 64 +++++-----
web/src/components/timeline/EventSegment.tsx | 115 ++++++++++++++----
web/src/hooks/use-segment-utils.ts | 70 ++++++++---
web/src/views/events/DesktopEventView.tsx | 20 ++-
web/tailwind.config.js | 5 +
6 files changed, 203 insertions(+), 73 deletions(-)
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx
index 093410de0..10133244a 100644
--- a/web/src/components/player/LivePlayer.tsx
+++ b/web/src/components/player/LivePlayer.tsx
@@ -126,7 +126,7 @@ export default function LivePlayer({
diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx
index d84b3ed75..428a9ec37 100644
--- a/web/src/components/timeline/EventReviewTimeline.tsx
+++ b/web/src/components/timeline/EventReviewTimeline.tsx
@@ -10,6 +10,7 @@ import {
import EventSegment from "./EventSegment";
import { useEventUtils } from "@/hooks/use-event-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
+import { TooltipProvider } from "../ui/tooltip";
export type EventReviewTimelineProps = {
segmentDuration: number;
@@ -102,7 +103,7 @@ export function EventReviewTimeline({
return (
);
});
@@ -122,6 +124,7 @@ export function EventReviewTimeline({
showMinimap,
minimapStartTime,
minimapEndTime,
+ events,
]);
const segments = useMemo(
@@ -210,39 +213,44 @@ export function EventReviewTimeline({
]);
return (
-
-
{segments}
- {showHandlebar && (
-
-
-
+
+
+
{segments}
+ {showHandlebar && (
+
+
+ className={`bg-destructive rounded-full mx-auto ${
+ segmentDuration < 60 ? "w-20" : "w-16"
+ } h-5 flex items-center justify-center`}
+ >
+
+
+
-
-
- )}
-
+ )}
+
+
);
}
diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx
index 7113e660e..d3b249688 100644
--- a/web/src/components/timeline/EventSegment.tsx
+++ b/web/src/components/timeline/EventSegment.tsx
@@ -1,7 +1,16 @@
+import { useApiHost } from "@/api";
import { useEventUtils } from "@/hooks/use-event-utils";
import { useSegmentUtils } from "@/hooks/use-segment-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
-import React, { useEffect, useMemo, useRef } from "react";
+import React, {
+ RefObject,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+} from "react";
+import { Tooltip, TooltipContent } from "../ui/tooltip";
+import { TooltipTrigger } from "@radix-ui/react-tooltip";
type EventSegmentProps = {
events: ReviewSegment[];
@@ -12,6 +21,7 @@ type EventSegmentProps = {
minimapStartTime?: number;
minimapEndTime?: number;
severityType: ReviewSeverity;
+ contentRef: RefObject
;
};
type MinimapSegmentProps = {
@@ -131,12 +141,15 @@ export function EventSegment({
minimapStartTime,
minimapEndTime,
severityType,
+ contentRef,
}: EventSegmentProps) {
const {
getSeverity,
getReviewed,
displaySeverityType,
shouldShowRoundedCorners,
+ getEventStart,
+ getEventThumbnail,
} = useSegmentUtils(segmentDuration, events, severityType);
const { alignDateToTimeline } = useEventUtils(events, segmentDuration);
@@ -145,15 +158,35 @@ export function EventSegment({
() => getSeverity(segmentTime, displaySeverityType),
[getSeverity, segmentTime]
);
+
const reviewed = useMemo(
() => getReviewed(segmentTime),
[getReviewed, segmentTime]
);
- const { roundTop, roundBottom } = useMemo(
+
+ const {
+ roundTopPrimary,
+ roundBottomPrimary,
+ roundTopSecondary,
+ roundBottomSecondary,
+ } = useMemo(
() => shouldShowRoundedCorners(segmentTime),
[shouldShowRoundedCorners, segmentTime]
);
+ const startTimestamp = useMemo(() => {
+ const eventStart = getEventStart(segmentTime);
+ if (eventStart) {
+ return alignDateToTimeline(eventStart);
+ }
+ }, [getEventStart, segmentTime]);
+
+ const apiHost = useApiHost();
+
+ const eventThumbnail = useMemo(() => {
+ return getEventThumbnail(segmentTime);
+ }, [getEventThumbnail, segmentTime]);
+
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
@@ -204,13 +237,7 @@ export function EventSegment({
}, [showMinimap, isFirstSegmentInMinimap, events, segmentDuration]);
const segmentClasses = `flex flex-row ${
- showMinimap
- ? isInMinimapRange
- ? "bg-card"
- : isLastSegmentInMinimap
- ? ""
- : "opacity-70"
- : ""
+ showMinimap ? (isInMinimapRange ? "bg-muted" : "bg-background") : ""
} ${
isFirstSegmentInMinimap || isLastSegmentInMinimap
? "relative h-2 border-b border-gray-500"
@@ -229,6 +256,29 @@ export function EventSegment({
: "from-severity_alert-dimmed to-severity_alert",
};
+ const segmentClick = useCallback(() => {
+ if (contentRef.current && startTimestamp) {
+ const element = contentRef.current.querySelector(
+ `[data-segment-start="${startTimestamp - segmentDuration}"]`
+ );
+ if (element instanceof HTMLElement) {
+ debounceScrollIntoView(element);
+ element.classList.add(
+ `outline-severity_${severityType}`,
+ `shadow-severity_${severityType}`
+ );
+ element.classList.add("outline-4", "shadow-[0_0_6px_1px]");
+ element.classList.remove("outline-0", "shadow-none");
+
+ // Remove the classes after a short timeout
+ setTimeout(() => {
+ element.classList.remove("outline-4", "shadow-[0_0_6px_1px]");
+ element.classList.add("outline-0", "shadow-none");
+ }, 3000);
+ }
+ }
+ }, [startTimestamp]);
+
return (
(
{severityValue === displaySeverityType && (
-
+ className="mr-3 w-[8px] h-2 flex justify-left items-end"
+ data-severity={severityValue}
+ >
+
+
+
+
+
+
+
+
)}
{severityValue !== displaySeverityType && (
@@ -278,11 +339,11 @@ export function EventSegment({
)}
diff --git a/web/src/hooks/use-segment-utils.ts b/web/src/hooks/use-segment-utils.ts
index 967bd3586..57025a42b 100644
--- a/web/src/hooks/use-segment-utils.ts
+++ b/web/src/hooks/use-segment-utils.ts
@@ -84,7 +84,14 @@ export const useSegmentUtils = (
);
const shouldShowRoundedCorners = useCallback(
- (segmentTime: number): { roundTop: boolean; roundBottom: boolean } => {
+ (
+ segmentTime: number
+ ): {
+ roundTopPrimary: boolean;
+ roundBottomPrimary: boolean;
+ roundTopSecondary: boolean;
+ roundBottomSecondary: boolean;
+ } => {
const prevSegmentTime = segmentTime - segmentDuration;
const nextSegmentTime = segmentTime + segmentDuration;
@@ -134,28 +141,61 @@ export const useSegmentUtils = (
);
});
- let roundTop = false;
- let roundBottom = false;
+ let roundTopPrimary = false;
+ let roundBottomPrimary = false;
+ let roundTopSecondary = false;
+ let roundBottomSecondary = false;
if (hasOverlappingSeverityEvent) {
- roundBottom = !hasPrevSeverityEvent;
- roundTop = !hasNextSeverityEvent;
- } else if (hasOverlappingOtherEvent) {
- roundBottom = !hasPrevOtherEvent;
- roundTop = !hasNextOtherEvent;
- } else {
- roundTop = !hasNextSeverityEvent || !hasNextOtherEvent;
- roundBottom = !hasPrevSeverityEvent || !hasPrevOtherEvent;
+ roundBottomPrimary = !hasPrevSeverityEvent;
+ roundTopPrimary = !hasNextSeverityEvent;
+ }
+
+ if (hasOverlappingOtherEvent) {
+ roundBottomSecondary = !hasPrevOtherEvent;
+ roundTopSecondary = !hasNextOtherEvent;
}
return {
- roundTop,
- roundBottom,
+ roundTopPrimary,
+ roundBottomPrimary,
+ roundTopSecondary,
+ roundBottomSecondary,
};
},
[events, getSegmentStart, getSegmentEnd, segmentDuration, severityType]
);
+ const getEventStart = useCallback(
+ (time: number): number => {
+ const matchingEvent = events.find((event) => {
+ return (
+ time >= getSegmentStart(event.start_time) &&
+ time < getSegmentEnd(event.end_time) &&
+ event.severity == severityType
+ );
+ });
+
+ return matchingEvent?.start_time ?? 0;
+ },
+ [events, getSegmentStart, getSegmentEnd, severityType]
+ );
+
+ const getEventThumbnail = useCallback(
+ (time: number): string => {
+ const matchingEvent = events.find((event) => {
+ return (
+ time >= getSegmentStart(event.start_time) &&
+ time < getSegmentEnd(event.end_time) &&
+ event.severity == severityType
+ );
+ });
+
+ return matchingEvent?.thumb_path ?? "";
+ },
+ [events, getSegmentStart, getSegmentEnd, severityType]
+ );
+
return {
getSegmentStart,
getSegmentEnd,
@@ -163,5 +203,7 @@ export const useSegmentUtils = (
displaySeverityType,
getReviewed,
shouldShowRoundedCorners,
+ getEventStart,
+ getEventThumbnail
};
-};
\ No newline at end of file
+};
diff --git a/web/src/views/events/DesktopEventView.tsx b/web/src/views/events/DesktopEventView.tsx
index d6252315f..b4741c483 100644
--- a/web/src/views/events/DesktopEventView.tsx
+++ b/web/src/views/events/DesktopEventView.tsx
@@ -4,6 +4,7 @@ import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/ui/activity-indicator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { useEventUtils } from "@/hooks/use-event-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -43,6 +44,7 @@ export default function DesktopEventView({
}: DesktopEventViewProps) {
const { data: config } = useSWR
("config");
const contentRef = useRef(null);
+ const segmentDuration = 60;
// review paging
@@ -78,6 +80,11 @@ export default function DesktopEventView({
};
}, [reviewPages]);
+ const { alignDateToTimeline } = useEventUtils(
+ reviewItems.all,
+ segmentDuration
+ );
+
const currentItems = useMemo(() => {
const current = reviewItems[severity];
@@ -245,7 +252,10 @@ export default function DesktopEventView({
)}
-