From 485057abc1528ca9a4f2eb1744ba2d8252eb22e2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 28 Feb 2024 07:18:08 -0600 Subject: [PATCH 1/3] Adapt review timeline for mobile devices (#10120) * adapt timeline to mobile * remove unused * tweaks * pointer cursor on segments --- web/package-lock.json | 32 +++++ web/package.json | 1 + web/src/components/player/LivePlayer.tsx | 4 +- .../timeline/EventReviewTimeline.tsx | 72 +++++----- web/src/components/timeline/EventSegment.tsx | 125 +++++++++--------- web/src/components/ui/hover-card.tsx | 27 ++++ web/src/hooks/use-event-utils.ts | 14 +- web/src/hooks/use-handle-dragging.ts | 7 +- web/src/views/events/EventView.tsx | 7 +- 9 files changed, 183 insertions(+), 106 deletions(-) create mode 100644 web/src/components/ui/hover-card.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 34b159fa1..5b279adc3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.1.3", @@ -1392,6 +1393,37 @@ } } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.0.7.tgz", + "integrity": "sha512-OcUN2FU0YpmajD/qkph3XzMcK/NmSk9hGWnjV68p6QiZMgILugusgQwnLSDs3oFSJYGKf3Y49zgFedhGh04k9A==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", diff --git a/web/package.json b/web/package.json index ebdb6dceb..8eae592c4 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.1.3", diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 8efb7f972..5eede4575 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -127,8 +127,8 @@ export default function LivePlayer({
diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 428a9ec37..6637dda3d 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -10,7 +10,6 @@ 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; @@ -56,14 +55,18 @@ export function EventReviewTimeline({ [timelineEnd, timelineStart] ); - const { alignDateToTimeline } = useEventUtils(events, segmentDuration); + const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( + events, + segmentDuration + ); const { handleMouseDown, handleMouseUp, handleMouseMove } = useDraggableHandler({ contentRef, timelineRef, scrollTimeRef, - alignDateToTimeline, + alignStartDateToTimeline, + alignEndDateToTimeline, segmentDuration, showHandlebar, timelineDuration, @@ -96,7 +99,7 @@ export function EventReviewTimeline({ // Generate segments for the timeline const generateSegments = useCallback(() => { const segmentCount = timelineDuration / segmentDuration; - const segmentAlignedTime = alignDateToTimeline(timelineStart); + const segmentAlignedTime = alignStartDateToTimeline(timelineStart); return Array.from({ length: segmentCount }, (_, index) => { const segmentTime = segmentAlignedTime - index * segmentDuration; @@ -172,7 +175,7 @@ export function EventReviewTimeline({ timelineHeight / (timelineDuration / segmentDuration); // Calculate the segment index corresponding to the target time - const alignedHandlebarTime = alignDateToTimeline(handlebarTime); + const alignedHandlebarTime = alignStartDateToTimeline(handlebarTime); const segmentIndex = Math.ceil( (timelineStart - alignedHandlebarTime) / segmentDuration ); @@ -213,44 +216,39 @@ export function EventReviewTimeline({ ]); return ( - -
-
{segments}
- {showHandlebar && ( -
-
+
+
{segments}
+ {showHandlebar && ( +
+
+
-
-
-
+ ref={currentTimeRef} + className="text-white text-xs z-10" + >
+
- )} -
- +
+ )} +
); } diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index d3b249688..78d54d578 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -9,8 +9,13 @@ import React, { useMemo, useRef, } from "react"; -import { Tooltip, TooltipContent } from "../ui/tooltip"; -import { TooltipTrigger } from "@radix-ui/react-tooltip"; +import { isDesktop } from "react-device-detect"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "../ui/hover-card"; +import { HoverCardPortal } from "@radix-ui/react-hover-card"; type EventSegmentProps = { events: ReviewSegment[]; @@ -33,8 +38,6 @@ type MinimapSegmentProps = { }; type TickSegmentProps = { - isFirstSegmentInMinimap: boolean; - isLastSegmentInMinimap: boolean; timestamp: Date; timestampSpread: number; }; @@ -58,25 +61,23 @@ function MinimapBounds({ <> {isFirstSegmentInMinimap && (
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - month: "short", - day: "2-digit", + ...(isDesktop && { month: "short", day: "2-digit" }), })}
)} {isLastSegmentInMinimap && ( -
+
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - month: "short", - day: "2-digit", + ...(isDesktop && { month: "short", day: "2-digit" }), })}
)} @@ -84,15 +85,10 @@ function MinimapBounds({ ); } -function Tick({ - isFirstSegmentInMinimap, - isLastSegmentInMinimap, - timestamp, - timestampSpread, -}: TickSegmentProps) { +function Tick({ timestamp, timestampSpread }: TickSegmentProps) { return ( -
- {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( +
+
- )} +
); } @@ -114,7 +110,7 @@ function Timestamp({ segmentKey, }: TimestampSegmentProps) { return ( -
+
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
getSeverity(segmentTime, displaySeverityType), @@ -177,7 +176,7 @@ export function EventSegment({ const startTimestamp = useMemo(() => { const eventStart = getEventStart(segmentTime); if (eventStart) { - return alignDateToTimeline(eventStart); + return alignStartDateToTimeline(eventStart); } }, [getEventStart, segmentTime]); @@ -191,23 +190,26 @@ export function EventSegment({ const segmentKey = useMemo(() => segmentTime, [segmentTime]); const alignedMinimapStartTime = useMemo( - () => alignDateToTimeline(minimapStartTime ?? 0), - [minimapStartTime, alignDateToTimeline] + () => alignStartDateToTimeline(minimapStartTime ?? 0), + [minimapStartTime, alignStartDateToTimeline] ); const alignedMinimapEndTime = useMemo( - () => alignDateToTimeline(minimapEndTime ?? 0), - [minimapEndTime, alignDateToTimeline] + () => alignEndDateToTimeline(minimapEndTime ?? 0), + [minimapEndTime, alignEndDateToTimeline] ); const isInMinimapRange = useMemo(() => { return ( showMinimap && - minimapStartTime && - minimapEndTime && - segmentTime > minimapStartTime && - segmentTime < minimapEndTime + segmentTime >= alignedMinimapStartTime && + segmentTime < alignedMinimapEndTime ); - }, [showMinimap, minimapStartTime, minimapEndTime, segmentTime]); + }, [ + showMinimap, + alignedMinimapStartTime, + alignedMinimapEndTime, + segmentTime, + ]); const isFirstSegmentInMinimap = useMemo(() => { return showMinimap && segmentTime === alignedMinimapStartTime; @@ -236,11 +238,17 @@ export function EventSegment({ } }, [showMinimap, isFirstSegmentInMinimap, events, segmentDuration]); - const segmentClasses = `flex flex-row ${ - showMinimap ? (isInMinimapRange ? "bg-muted" : "bg-background") : "" + const segmentClasses = `h-2 relative w-[55px] md:w-[100px] ${ + showMinimap + ? isInMinimapRange + ? "bg-card" + : isLastSegmentInMinimap + ? "" + : "opacity-70" + : "" } ${ isFirstSegmentInMinimap || isLastSegmentInMinimap - ? "relative h-2 border-b border-gray-500" + ? "relative h-2 border-b-2 border-gray-500" : "" }`; @@ -280,7 +288,11 @@ export function EventSegment({ }, [startTimestamp]); return ( -
+
- + ( {severityValue === displaySeverityType && ( - +
- +
-
- - - + + + + + +
-
+ )} {severityValue !== displaySeverityType && ( -
+
, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/web/src/hooks/use-event-utils.ts b/web/src/hooks/use-event-utils.ts index b8483d8e6..ac4300c5d 100644 --- a/web/src/hooks/use-event-utils.ts +++ b/web/src/hooks/use-event-utils.ts @@ -42,7 +42,7 @@ export const useEventUtils = ( [segmentDuration] ); - const alignDateToTimeline = useCallback( + const alignEndDateToTimeline = useCallback( (time: number): number => { const remainder = time % segmentDuration; const adjustment = remainder !== 0 ? segmentDuration - remainder : 0; @@ -51,11 +51,21 @@ export const useEventUtils = ( [segmentDuration] ); + const alignStartDateToTimeline = useCallback( + (time: number): number => { + const remainder = time % segmentDuration; + const adjustment = remainder === 0 ? 0 : -(remainder); + return time + adjustment; + }, + [segmentDuration] + ); + return { isStartOfEvent, isEndOfEvent, getSegmentStart, getSegmentEnd, - alignDateToTimeline, + alignEndDateToTimeline, + alignStartDateToTimeline, }; }; diff --git a/web/src/hooks/use-handle-dragging.ts b/web/src/hooks/use-handle-dragging.ts index cf887095f..9f70374b2 100644 --- a/web/src/hooks/use-handle-dragging.ts +++ b/web/src/hooks/use-handle-dragging.ts @@ -4,7 +4,8 @@ interface DragHandlerProps { contentRef: React.RefObject; timelineRef: React.RefObject; scrollTimeRef: React.RefObject; - alignDateToTimeline: (time: number) => number; + alignStartDateToTimeline: (time: number) => number; + alignEndDateToTimeline: (time: number) => number; segmentDuration: number; showHandlebar: boolean; timelineDuration: number; @@ -20,7 +21,7 @@ function useDraggableHandler({ contentRef, timelineRef, scrollTimeRef, - alignDateToTimeline, + alignStartDateToTimeline, segmentDuration, showHandlebar, timelineDuration, @@ -94,7 +95,7 @@ function useDraggableHandler({ ); const segmentIndex = Math.floor(newHandlePosition / segmentHeight); - const segmentStartTime = alignDateToTimeline( + const segmentStartTime = alignStartDateToTimeline( timelineStart - segmentIndex * segmentDuration ); diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index dbc20e84e..d6057a184 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -82,7 +82,7 @@ export default function EventView({ }; }, [reviewPages]); - const { alignDateToTimeline } = useEventUtils( + const { alignStartDateToTimeline } = useEventUtils( reviewItems.all, segmentDuration ); @@ -270,7 +270,8 @@ export default function EventView({ ref={lastRow ? lastReviewRef : minimapRef} data-start={value.start_time} data-segment-start={ - alignDateToTimeline(value.start_time) - segmentDuration + alignStartDateToTimeline(value.start_time) - + segmentDuration } className="outline outline-offset-1 outline-0 rounded-lg shadow-none transition-all duration-500 my-1 md:my-0" > @@ -291,7 +292,7 @@ export default function EventView({ )}
-
+
Date: Wed, 28 Feb 2024 07:16:16 -0700 Subject: [PATCH 2/3] Use persistence for live layout (#10114) * Use persistence for live layout * Fix typing * Fix persistence typing * remove type * More type fixing --- web/src/components/camera/DebugCameraImage.tsx | 6 +++--- web/src/hooks/use-camera-live-mode.ts | 8 ++++---- web/src/hooks/use-persistence.ts | 16 ++++++++-------- web/src/pages/Live.tsx | 4 +++- web/src/types/frigateConfig.ts | 4 +++- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/web/src/components/camera/DebugCameraImage.tsx b/web/src/components/camera/DebugCameraImage.tsx index e7352c17a..34588aa06 100644 --- a/web/src/components/camera/DebugCameraImage.tsx +++ b/web/src/components/camera/DebugCameraImage.tsx @@ -22,7 +22,7 @@ export default function DebugCameraImage({ cameraConfig, }: DebugCameraImageProps) { const [showSettings, setShowSettings] = useState(false); - const [options, setOptions] = usePersistence( + const [options, setOptions] = usePersistence( `${cameraConfig?.name}-feed`, emptyObject ); @@ -36,7 +36,7 @@ export default function DebugCameraImage({ const searchParams = useMemo( () => new URLSearchParams( - Object.keys(options).reduce((memo, key) => { + Object.keys(options || {}).reduce((memo, key) => { //@ts-ignore we know this is correct memo.push([key, options[key] === true ? "1" : "0"]); return memo; @@ -68,7 +68,7 @@ export default function DebugCameraImage({ diff --git a/web/src/hooks/use-camera-live-mode.ts b/web/src/hooks/use-camera-live-mode.ts index 214713dc1..f13622556 100644 --- a/web/src/hooks/use-camera-live-mode.ts +++ b/web/src/hooks/use-camera-live-mode.ts @@ -7,7 +7,7 @@ import { LivePlayerMode } from "@/types/live"; export default function useCameraLiveMode( cameraConfig: CameraConfig, preferredMode?: string -): LivePlayerMode { +): LivePlayerMode | undefined { const { data: config } = useSWR("config"); const restreamEnabled = useMemo(() => { @@ -22,10 +22,10 @@ export default function useCameraLiveMode( ) ); }, [config, cameraConfig]); - const defaultLiveMode = useMemo(() => { + const defaultLiveMode = useMemo(() => { if (config && cameraConfig) { if (restreamEnabled) { - return cameraConfig.ui.live_mode || config?.ui.live_mode; + return cameraConfig.ui.live_mode || config.ui.live_mode; } return "jsmpeg"; @@ -33,7 +33,7 @@ export default function useCameraLiveMode( return undefined; }, [cameraConfig, restreamEnabled]); - const [viewSource] = usePersistence( + const [viewSource] = usePersistence( `${cameraConfig.name}-source`, defaultLiveMode ); diff --git a/web/src/hooks/use-persistence.ts b/web/src/hooks/use-persistence.ts index 48a03d7e1..3d61cfa17 100644 --- a/web/src/hooks/use-persistence.ts +++ b/web/src/hooks/use-persistence.ts @@ -1,21 +1,21 @@ import { useEffect, useState, useCallback } from "react"; import { get as getData, set as setData } from "idb-keyval"; -type usePersistenceReturn = [ - value: any | undefined, - setValue: (value: string | boolean) => void, +type usePersistenceReturn = [ + value: S | undefined, + setValue: (value: S) => void, loaded: boolean, ]; -export function usePersistence( +export function usePersistence( key: string, - defaultValue: any | undefined = undefined -): usePersistenceReturn { - const [value, setInternalValue] = useState(defaultValue); + defaultValue: S | undefined = undefined +): usePersistenceReturn { + const [value, setInternalValue] = useState(defaultValue); const [loaded, setLoaded] = useState(false); const setValue = useCallback( - (value: string | boolean) => { + (value: S) => { setInternalValue(value); async function update() { await setData(key, value); diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index cb6ada236..e4c9f71a2 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -5,6 +5,7 @@ import LivePlayer from "@/components/player/LivePlayer"; import { Button } from "@/components/ui/button"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { usePersistence } from "@/hooks/use-persistence"; import { Event as FrigateEvent } from "@/types/event"; import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -17,7 +18,8 @@ function Live() { // layout - const [layout, setLayout] = useState<"grid" | "list">( + const [layout, setLayout] = usePersistence<"grid" | "list">( + "live-layout", isDesktop ? "grid" : "list" ); diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 77c51c879..5149264d8 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -1,10 +1,12 @@ +import { LivePlayerMode } from "./live"; + export interface UiConfig { timezone?: string; time_format?: "browser" | "12hour" | "24hour"; date_style?: "full" | "long" | "medium" | "short"; time_style?: "full" | "long" | "medium" | "short"; strftime_fmt?: string; - live_mode?: string; + live_mode?: LivePlayerMode; use_experimental?: boolean; dashboard: boolean; order: number; From a978adc5a9b502941d90c80f6d51d67a07077c5a Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 28 Feb 2024 07:16:32 -0700 Subject: [PATCH 3/3] Fix reload (#10109) * Fix reloading data * Don't show new review data when not looking at last 24 hours * Fix refresh button and no items text * Cleanup --- web/src/pages/Events.tsx | 12 +++++------- web/src/pages/Logs.tsx | 7 ++++--- web/src/views/events/EventView.tsx | 26 +++++++++++++++----------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index f3d185c16..fc821a06f 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -28,9 +28,10 @@ export default function Events() { // review paging + const [beforeTs, setBeforeTs] = useState(Date.now() / 1000); const last24Hours = useMemo(() => { - return { before: Date.now() / 1000, after: getHoursAgo(24) }; - }, []); + return { before: beforeTs, after: getHoursAgo(24) }; + }, [beforeTs]); const selectedTimeRange = useMemo(() => { if (reviewSearchParams["after"] == undefined) { return last24Hours; @@ -73,7 +74,7 @@ export default function Events() { }; return ["review", params]; }, - [reviewSearchParams] + [reviewSearchParams, last24Hours] ); const { @@ -96,10 +97,7 @@ export default function Events() { setSize(size + 1); }, [size]); - const reloadData = useCallback(() => { - setSize(1); - updateSegments(); - }, []); + const reloadData = useCallback(() => setBeforeTs(Date.now() / 1000), []); // preview videos diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index f912ed3b5..591fe30f9 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -97,8 +97,9 @@ function Logs() {
{!endVisible && ( -
contentRef.current?.scrollTo({ top: contentRef.current?.scrollHeight, @@ -107,7 +108,7 @@ function Logs() { } > Jump to Bottom -
+ )}
- + {isMobile && ( + + )} - + {filter?.before == undefined && ( + + )} - {reachedEnd && currentItems == null && ( + {!isValidating && currentItems == null && (
There are no {severity} items to review @@ -287,9 +291,9 @@ export default function EventView({
); }) - ) : ( + ) : severity != "alert" ? (
- )} + ) : null}