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({
)} -
+
{currentItems ? ( currentItems.map((value, segIdx) => { const lastRow = segIdx == reviewItems[severity].length - 1; @@ -263,6 +273,10 @@ export default function DesktopEventView({ key={value.id} ref={lastRow ? lastReviewRef : minimapRef} data-start={value.start_time} + data-segment-start={ + alignDateToTimeline(value.start_time) - segmentDuration + } + className="outline outline-offset-1 outline-0 rounded-lg shadow-none transition-all duration-500" >
-
+