Compare commits

...

2 Commits

Author SHA1 Message Date
Josh Hawkins
dbbe40bd27
Improve Details Settings (#20718)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* detail stream settings

* fix mobile landscape

* mobile landscape

* tweak

* tweaks
2025-10-29 16:03:38 -06:00
Josh Hawkins
901002a0a5
Add zoom icons to timeline (#20717)
* add icons to zoom timeline

* fix current zoom level handling

* ensure mobile buttons don't stay selected

* remove icons on event review timeline

* add tooltips
2025-10-29 12:04:29 -06:00
12 changed files with 449 additions and 199 deletions

View File

@ -13,6 +13,8 @@
},
"timeline": "Timeline",
"timeline.aria": "Select timeline",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"events": {
"label": "Events",
"aria": "Select events",
@ -24,7 +26,12 @@
"aria": "Toggle detail view",
"trackedObject_one": "object",
"trackedObject_other": "objects",
"noObjectDetailData": "No object detail data available."
"noObjectDetailData": "No object detail data available.",
"settings": "Detail View Settings",
"alwaysExpandActive": {
"title": "Always expand active",
"desc": "Always expand the active review item's object details when available."
}
},
"objectTrack": {
"trackedPoint": "Tracked point",

View File

@ -71,7 +71,7 @@
},
"offset": {
"label": "Annotation Offset",
"desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. However, the <code>annotation_offset</code> field can be used to adjust this.",
"desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. You can use this setting to offset the annotations forward or backward in time to better align them with the recorded footage.",
"millisecondsToOffset": "Milliseconds to offset detect annotations by. <em>Default: 0</em>",
"tips": "TIP: Imagine there is an event clip with a person walking from left to right. If the event timeline bounding box is consistently to the left of the person then the value should be decreased. Similarly, if a person is walking from left to right and the bounding box is consistently ahead of the person then the value should be increased.",
"toast": {

View File

@ -1,11 +1,15 @@
import { useCallback, useState } from "react";
import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
import { useDetailStream } from "@/context/detail-stream-context";
import axios from "axios";
import { useSWRConfig } from "swr";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { LuInfo } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
type Props = {
className?: string;
@ -65,30 +69,67 @@ export default function AnnotationOffsetSlider({ className }: Props) {
return (
<div
className={`absolute bottom-0 left-0 right-0 z-30 flex items-center gap-3 bg-background p-3 ${className ?? ""}`}
style={{ pointerEvents: "auto" }}
className={cn(
"flex flex-col gap-0.5",
isMobile && "landscape:gap-3",
className,
)}
>
<div className="w-56 text-sm">
Annotation offset (ms): {annotationOffset}
<div
className={cn(
"flex items-center gap-3",
isMobile &&
"landscape:flex-col landscape:items-start landscape:gap-4",
)}
>
<div className="flex max-w-28 flex-row items-center gap-2 text-sm md:max-w-48">
<span className="max-w-24 md:max-w-44">
{t("trackingDetails.annotationSettings.offset.label")}:
</span>
<span className="text-primary-variant">{annotationOffset}</span>
</div>
<div className="w-full flex-1 landscape:flex">
<Slider
value={[annotationOffset]}
min={-1500}
max={1500}
step={50}
onValueChange={handleChange}
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={reset}>
{t("button.reset", { ns: "common" })}
</Button>
<Button size="sm" onClick={save} disabled={isSaving}>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
</div>
</div>
<div className="flex-1">
<Slider
value={[annotationOffset]}
min={-1500}
max={1500}
step={50}
onValueChange={handleChange}
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={reset}>
Reset
</Button>
<Button size="sm" onClick={save} disabled={isSaving}>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
<div
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground",
isMobile && "landscape:flex-col landscape:items-start",
)}
>
<Trans ns="views/explore">
trackingDetails.annotationSettings.offset.millisecondsToOffset
</Trans>
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t("trackingDetails.annotationSettings.offset.desc")}
>
<LuInfo className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("trackingDetails.annotationSettings.offset.desc")}
</PopoverContent>
</Popover>
</div>
</div>
);

View File

@ -16,13 +16,21 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { Event } from "@/types/event";
import { getIconForLabel } from "@/utils/iconUtil";
import { ReviewSegment } from "@/types/review";
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
import {
LuChevronDown,
LuCircle,
LuChevronRight,
LuSettings,
} from "react-icons/lu";
import { getTranslatedLabel } from "@/utils/i18n";
import EventMenu from "@/components/timeline/EventMenu";
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Link } from "react-router-dom";
import { Switch } from "@/components/ui/switch";
import { usePersistence } from "@/hooks/use-persistence";
import { isDesktop } from "react-device-detect";
type DetailStreamProps = {
reviewItems?: ReviewSegment[];
@ -51,6 +59,11 @@ export default function DetailStream({
const effectiveTime = currentTime + annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
"detailStreamActiveExpanded",
true,
);
const onSeekCheckPlaying = (timestamp: number) => {
onSeek(timestamp, isPlaying);
@ -168,7 +181,7 @@ export default function DetailStream({
}
return (
<div className="relative">
<>
<FrigatePlusDialog
upload={upload}
onClose={() => setUpload(undefined)}
@ -179,38 +192,80 @@ export default function DetailStream({
}}
/>
<div
ref={scrollRef}
className="scrollbar-container h-[calc(100vh-70px)] overflow-y-auto"
>
<div className="space-y-4 py-2">
{reviewItems?.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
{t("detail.noDataFound")}
<div className="relative flex h-full flex-col">
<div
ref={scrollRef}
className="scrollbar-container flex-1 overflow-y-auto pb-14"
>
<div className="space-y-4 py-2">
{reviewItems?.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
{t("detail.noDataFound")}
</div>
) : (
reviewItems?.map((review: ReviewSegment) => {
const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`;
return (
<ReviewGroup
key={id}
id={id}
review={review}
config={config}
onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime}
isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)}
onOpenUpload={(e) => setUpload(e)}
alwaysExpandActive={alwaysExpandActive}
/>
);
})
)}
</div>
</div>
<div
className={cn(
"absolute bottom-0 left-0 right-0 z-30 rounded-t-md border border-secondary-highlight bg-background_alt shadow-md",
isDesktop && "border-b-0",
)}
>
<button
onClick={() => setControlsExpanded(!controlsExpanded)}
className="flex w-full items-center justify-between p-3"
>
<div className="flex items-center gap-2 text-sm font-medium">
<LuSettings className="size-4" />
<span>{t("detail.settings")}</span>
</div>
{controlsExpanded ? (
<LuChevronDown className="size-4 text-primary-variant" />
) : (
<LuChevronRight className="size-4 text-primary-variant" />
)}
</button>
{controlsExpanded && (
<div className="space-y-3 px-3 pb-3">
<AnnotationOffsetSlider />
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">
{t("detail.alwaysExpandActive.title")}
</label>
<Switch
checked={alwaysExpandActive}
onCheckedChange={setAlwaysExpandActive}
/>
</div>
<div className="text-xs text-muted-foreground">
{t("detail.alwaysExpandActive.desc")}
</div>
</div>
</div>
) : (
reviewItems?.map((review: ReviewSegment) => {
const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`;
return (
<ReviewGroup
key={id}
id={id}
review={review}
config={config}
onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime}
isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)}
onOpenUpload={(e) => setUpload(e)}
/>
);
})
)}
</div>
</div>
<AnnotationOffsetSlider />
</div>
</>
);
}
@ -223,6 +278,7 @@ type ReviewGroupProps = {
onActivate?: () => void;
onOpenUpload?: (e: Event) => void;
effectiveTime?: number;
alwaysExpandActive?: boolean;
};
function ReviewGroup({
@ -234,11 +290,19 @@ function ReviewGroup({
onActivate,
onOpenUpload,
effectiveTime,
alwaysExpandActive = false,
}: ReviewGroupProps) {
const { t } = useTranslation("views/events");
const [open, setOpen] = useState(false);
const start = review.start_time ?? 0;
// Auto-expand when this review becomes active and alwaysExpandActive is enabled
useEffect(() => {
if (isActive && alwaysExpandActive) {
setOpen(true);
}
}, [isActive, alwaysExpandActive]);
const displayTime = formatUnixTimestampToDateTime(start, {
timezone: config.ui.timezone,
date_format:

View File

@ -10,6 +10,7 @@ import {
ReviewSegment,
ReviewSeverity,
TimelineZoomDirection,
ZoomLevel,
} from "@/types/review";
import ReviewTimeline from "./ReviewTimeline";
import {
@ -42,6 +43,9 @@ export type EventReviewTimelineProps = {
isZooming: boolean;
zoomDirection: TimelineZoomDirection;
dense?: boolean;
onZoomChange?: (newZoomLevel: number) => void;
possibleZoomLevels?: ZoomLevel[];
currentZoomLevel?: number;
};
export function EventReviewTimeline({
@ -69,6 +73,9 @@ export function EventReviewTimeline({
isZooming,
zoomDirection,
dense = false,
onZoomChange,
possibleZoomLevels,
currentZoomLevel,
}: EventReviewTimelineProps) {
const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
@ -157,6 +164,9 @@ export function EventReviewTimeline({
scrollToSegment={scrollToSegment}
isZooming={isZooming}
zoomDirection={zoomDirection}
onZoomChange={onZoomChange}
possibleZoomLevels={possibleZoomLevels}
currentZoomLevel={currentZoomLevel}
>
<VirtualizedEventSegments
ref={virtualizedSegmentsRef}

View File

@ -235,7 +235,7 @@ export function EventSegment({
<div className="flex w-[20px] flex-row justify-center md:w-[40px]">
<div className="flex justify-center">
<div
className="absolute left-1/2 z-10 ml-[2px] h-[8px] w-[8px] -translate-x-1/2 transform cursor-pointer"
className="absolute left-1/2 z-10 ml-[2px] h-[8px] w-[8px] -translate-x-1/2 transform cursor-pointer md:ml-0"
data-severity={severityValue}
>
<div

View File

@ -10,6 +10,7 @@ import {
MotionData,
ReviewSegment,
TimelineZoomDirection,
ZoomLevel,
} from "@/types/review";
import ReviewTimeline from "./ReviewTimeline";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
@ -47,6 +48,9 @@ export type MotionReviewTimelineProps = {
isZooming: boolean;
zoomDirection: TimelineZoomDirection;
alwaysShowMotionLine?: boolean;
onZoomChange?: (newZoomLevel: number) => void;
possibleZoomLevels?: ZoomLevel[];
currentZoomLevel?: number;
};
export function MotionReviewTimeline({
@ -77,6 +81,9 @@ export function MotionReviewTimeline({
isZooming,
zoomDirection,
alwaysShowMotionLine = false,
onZoomChange,
possibleZoomLevels,
currentZoomLevel,
}: MotionReviewTimelineProps) {
const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
@ -206,6 +213,9 @@ export function MotionReviewTimeline({
isZooming={isZooming}
zoomDirection={zoomDirection}
getRecordingAvailability={getRecordingAvailability}
onZoomChange={onZoomChange}
possibleZoomLevels={possibleZoomLevels}
currentZoomLevel={currentZoomLevel}
>
<VirtualizedMotionSegments
ref={virtualizedSegmentsRef}

View File

@ -2,7 +2,7 @@ import useDraggableElement from "@/hooks/use-draggable-element";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { cn } from "@/lib/utils";
import { DraggableElement } from "@/types/draggable-element";
import { TimelineZoomDirection } from "@/types/review";
import { TimelineZoomDirection, ZoomLevel } from "@/types/review";
import {
ReactNode,
RefObject,
@ -13,6 +13,11 @@ import {
useState,
} from "react";
import { isIOS, isMobile } from "react-device-detect";
import { Button } from "../ui/button";
import { LuZoomIn, LuZoomOut } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
export type ReviewTimelineProps = {
timelineRef: RefObject<HTMLDivElement>;
@ -37,6 +42,9 @@ export type ReviewTimelineProps = {
isZooming: boolean;
zoomDirection: TimelineZoomDirection;
getRecordingAvailability?: (time: number) => boolean | undefined;
onZoomChange?: (newZoomLevel: number) => void;
possibleZoomLevels?: ZoomLevel[];
currentZoomLevel?: number;
children: ReactNode;
};
@ -63,8 +71,12 @@ export function ReviewTimeline({
isZooming,
zoomDirection,
getRecordingAvailability,
onZoomChange,
possibleZoomLevels,
currentZoomLevel,
children,
}: ReviewTimelineProps) {
const { t } = useTranslation("views/events");
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
const [isDraggingExportStart, setIsDraggingExportStart] = useState(false);
const [isDraggingExportEnd, setIsDraggingExportEnd] = useState(false);
@ -78,6 +90,13 @@ export function ReviewTimeline({
const exportEndRef = useRef<HTMLDivElement>(null);
const exportEndTimeRef = useRef<HTMLDivElement>(null);
// Use provided zoom levels or fallback to empty array
const zoomLevels = possibleZoomLevels ?? [];
const currentZoomLevelIndex =
currentZoomLevel ??
zoomLevels.findIndex((level) => level.segmentDuration === segmentDuration);
const isDragging = useMemo(
() => isDraggingHandlebar || isDraggingExportStart || isDraggingExportEnd,
[isDraggingHandlebar, isDraggingExportStart, isDraggingExportEnd],
@ -348,148 +367,204 @@ export function ReviewTimeline({
}, [getRecordingAvailability, handlebarTime, segmentDuration]);
return (
<div
ref={timelineRef}
className={cn(
"no-scrollbar relative h-full select-none overflow-y-auto bg-secondary transition-all duration-500 ease-in-out",
isZooming && zoomDirection === "in" && "animate-timeline-zoom-in",
isZooming && zoomDirection === "out" && "animate-timeline-zoom-out",
isDragging && (showHandlebar || showExportHandles)
? "cursor-grabbing"
: "cursor-auto",
)}
>
<div ref={segmentsRef} className="relative flex flex-col">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
{children}
<>
<div
ref={timelineRef}
className={cn(
"no-scrollbar relative h-full select-none overflow-y-auto bg-secondary transition-all duration-500 ease-in-out",
isZooming && zoomDirection === "in" && "animate-timeline-zoom-in",
isZooming && zoomDirection === "out" && "animate-timeline-zoom-out",
isDragging && (showHandlebar || showExportHandles)
? "cursor-grabbing"
: "cursor-auto",
)}
>
<div ref={segmentsRef} className="relative flex flex-col">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
{children}
</div>
{children && (
<>
{showHandlebar && (
<div
className={`absolute left-0 top-0 ${isDraggingHandlebar && isIOS ? "" : "z-20"} w-full`}
role="scrollbar"
ref={handlebarRef}
>
<div
className="flex touch-none select-none items-center justify-center"
onMouseDown={handleHandlebar}
onTouchStart={handleHandlebar}
>
<div
className={`relative w-full ${
isDraggingHandlebar ? "cursor-grabbing" : "cursor-grab"
}`}
>
<div
className={`mx-auto rounded-full bg-destructive ${
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingHandlebar && isMobile ? "fixed left-1/2 top-[18px] z-20 h-[30px] w-auto -translate-x-1/2 transform bg-destructive/80 px-3" : "static"} flex items-center justify-center`}
>
<div
ref={handlebarTimeRef}
className={`pointer-events-none text-white ${textSizeClasses("handlebar")} z-10`}
></div>
</div>
<div
className={`absolute h-[4px] w-full bg-destructive ${isDraggingHandlebar && isMobile ? "top-1" : "top-1/2 -translate-y-1/2 transform"}`}
></div>
</div>
</div>
{/* TODO: determine if we should keep this tooltip */}
{false && isHandlebarInNoRecordingPeriod && (
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 rounded-md bg-destructive/80 px-4 py-1 text-center text-xs text-white shadow-lg">
No recordings
</div>
)}
</div>
)}
{showExportHandles && (
<>
<div
className={`export-end absolute left-0 top-0 ${isDraggingExportEnd && isIOS ? "" : "z-20"} w-full`}
role="scrollbar"
ref={exportEndRef}
>
<div
className="flex touch-none select-none items-center justify-center"
onMouseDown={handleExportEnd}
onTouchStart={handleExportEnd}
>
<div
className={`relative mt-[6.5px] w-full ${
isDraggingExportEnd ? "cursor-grabbing" : "cursor-grab"
}`}
>
<div
className={`mx-auto -mt-4 bg-selected ${
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingExportEnd && isMobile ? "fixed left-1/2 top-[18px] z-20 mt-0 h-[30px] w-auto -translate-x-1/2 transform rounded-full bg-selected/80 px-3" : "static rounded-tl-lg rounded-tr-lg"} flex items-center justify-center`}
>
<div
ref={exportEndTimeRef}
className={`pointer-events-none text-white ${isDraggingExportEnd && isMobile ? "mt-0" : ""} ${textSizeClasses("export_end")} z-10`}
></div>
</div>
<div
className={`absolute h-[4px] w-full bg-selected ${isDraggingExportEnd && isMobile ? "top-0" : "top-1/2 -translate-y-1/2 transform"}`}
></div>
</div>
</div>
</div>
<div
ref={exportSectionRef}
className="absolute w-full bg-selected/50"
></div>
<div
className={`export-start absolute left-0 top-0 ${isDraggingExportStart && isIOS ? "" : "z-20"} w-full`}
role="scrollbar"
ref={exportStartRef}
>
<div
className="flex touch-none select-none items-center justify-center"
onMouseDown={handleExportStart}
onTouchStart={handleExportStart}
>
<div
className={`relative -mt-[6.5px] w-full ${
isDragging ? "cursor-grabbing" : "cursor-grab"
}`}
>
<div
className={`absolute h-[4px] w-full bg-selected ${isDraggingExportStart && isMobile ? "top-[12px]" : "top-1/2 -translate-y-1/2 transform"}`}
></div>
<div
className={`mx-auto mt-4 bg-selected ${
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingExportStart && isMobile ? "fixed left-1/2 top-[4px] z-20 mt-0 h-[30px] w-auto -translate-x-1/2 transform rounded-full bg-selected/80 px-3" : "static rounded-bl-lg rounded-br-lg"} flex items-center justify-center`}
>
<div
ref={exportStartTimeRef}
className={`pointer-events-none text-white ${isDraggingExportStart && isMobile ? "mt-0" : ""} ${textSizeClasses("export_start")} z-10`}
></div>
</div>
</div>
</div>
</div>
</>
)}
</>
)}
</div>
{children && (
<>
{showHandlebar && (
<div
className={`absolute left-0 top-0 ${isDraggingHandlebar && isIOS ? "" : "z-20"} w-full`}
role="scrollbar"
ref={handlebarRef}
>
<div
className="flex touch-none select-none items-center justify-center"
onMouseDown={handleHandlebar}
onTouchStart={handleHandlebar}
{onZoomChange && currentZoomLevelIndex !== -1 && (
<div
className={`absolute z-30 flex gap-2 ${
isMobile
? "bottom-4 right-1 flex-col gap-3"
: "bottom-2 left-1/2 -translate-x-1/2"
}`}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={(e) => {
const newLevel = Math.max(0, currentZoomLevelIndex - 1);
onZoomChange(newLevel);
e.currentTarget.blur();
}}
variant="outline"
disabled={currentZoomLevelIndex === 0}
className="bg-background_alt p-3 hover:bg-accent hover:text-accent-foreground active:scale-95 [@media(hover:none)]:hover:bg-background_alt"
type="button"
>
<div
className={`relative w-full ${
isDraggingHandlebar ? "cursor-grabbing" : "cursor-grab"
}`}
>
<div
className={`mx-auto rounded-full bg-destructive ${
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingHandlebar && isMobile ? "fixed left-1/2 top-[18px] z-20 h-[30px] w-auto -translate-x-1/2 transform bg-destructive/80 px-3" : "static"} flex items-center justify-center`}
>
<div
ref={handlebarTimeRef}
className={`pointer-events-none text-white ${textSizeClasses("handlebar")} z-10`}
></div>
</div>
<div
className={`absolute h-[4px] w-full bg-destructive ${isDraggingHandlebar && isMobile ? "top-1" : "top-1/2 -translate-y-1/2 transform"}`}
></div>
</div>
</div>
{/* TODO: determine if we should keep this tooltip */}
{false && isHandlebarInNoRecordingPeriod && (
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 rounded-md bg-destructive/80 px-4 py-1 text-center text-xs text-white shadow-lg">
No recordings
</div>
)}
</div>
)}
{showExportHandles && (
<>
<div
className={`export-end absolute left-0 top-0 ${isDraggingExportEnd && isIOS ? "" : "z-20"} w-full`}
role="scrollbar"
ref={exportEndRef}
<LuZoomOut className={cn("size-5 text-primary-variant")} />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("zoomIn")}</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={(e) => {
const newLevel = Math.min(
zoomLevels.length - 1,
currentZoomLevelIndex + 1,
);
onZoomChange(newLevel);
e.currentTarget.blur();
}}
variant="outline"
disabled={currentZoomLevelIndex === zoomLevels.length - 1}
className="bg-background_alt p-3 hover:bg-accent hover:text-accent-foreground active:scale-95 [@media(hover:none)]:hover:bg-background_alt"
type="button"
>
<div
className="flex touch-none select-none items-center justify-center"
onMouseDown={handleExportEnd}
onTouchStart={handleExportEnd}
>
<div
className={`relative mt-[6.5px] w-full ${
isDraggingExportEnd ? "cursor-grabbing" : "cursor-grab"
}`}
>
<div
className={`mx-auto -mt-4 bg-selected ${
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingExportEnd && isMobile ? "fixed left-1/2 top-[18px] z-20 mt-0 h-[30px] w-auto -translate-x-1/2 transform rounded-full bg-selected/80 px-3" : "static rounded-tl-lg rounded-tr-lg"} flex items-center justify-center`}
>
<div
ref={exportEndTimeRef}
className={`pointer-events-none text-white ${isDraggingExportEnd && isMobile ? "mt-0" : ""} ${textSizeClasses("export_end")} z-10`}
></div>
</div>
<div
className={`absolute h-[4px] w-full bg-selected ${isDraggingExportEnd && isMobile ? "top-0" : "top-1/2 -translate-y-1/2 transform"}`}
></div>
</div>
</div>
</div>
<div
ref={exportSectionRef}
className="absolute w-full bg-selected/50"
></div>
<div
className={`export-start absolute left-0 top-0 ${isDraggingExportStart && isIOS ? "" : "z-20"} w-full`}
role="scrollbar"
ref={exportStartRef}
>
<div
className="flex touch-none select-none items-center justify-center"
onMouseDown={handleExportStart}
onTouchStart={handleExportStart}
>
<div
className={`relative -mt-[6.5px] w-full ${
isDragging ? "cursor-grabbing" : "cursor-grab"
}`}
>
<div
className={`absolute h-[4px] w-full bg-selected ${isDraggingExportStart && isMobile ? "top-[12px]" : "top-1/2 -translate-y-1/2 transform"}`}
></div>
<div
className={`mx-auto mt-4 bg-selected ${
dense
? "w-12 md:w-20"
: segmentDuration < 60
? "w-[80px]"
: "w-20"
} h-5 ${isDraggingExportStart && isMobile ? "fixed left-1/2 top-[4px] z-20 mt-0 h-[30px] w-auto -translate-x-1/2 transform rounded-full bg-selected/80 px-3" : "static rounded-bl-lg rounded-br-lg"} flex items-center justify-center`}
>
<div
ref={exportStartTimeRef}
className={`pointer-events-none text-white ${isDraggingExportStart && isMobile ? "mt-0" : ""} ${textSizeClasses("export_start")} z-10`}
></div>
</div>
</div>
</div>
</div>
</>
)}
</>
<LuZoomIn className={cn("size-5 text-primary-variant")} />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("zoomOut")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
)}
</div>
</>
);
}

View File

@ -9,6 +9,7 @@ import {
ReviewData,
ReviewSegment,
ReviewSeverity,
ZoomLevel,
} from "@/types/review";
import { Button } from "@/components/ui/button";
import CameraActivityIndicator from "@/components/indicators/CameraActivityIndicator";
@ -180,7 +181,7 @@ function UIPlayground() {
timestampSpread: 15,
});
const possibleZoomLevels = useMemo(
const possibleZoomLevels: ZoomLevel[] = useMemo(
() => [
{ segmentDuration: 60, timestampSpread: 15 },
{ segmentDuration: 30, timestampSpread: 5 },
@ -196,6 +197,14 @@ function UIPlayground() {
[possibleZoomLevels],
);
const currentZoomLevel = useMemo(
() =>
possibleZoomLevels.findIndex(
(level) => level.segmentDuration === zoomSettings.segmentDuration,
),
[possibleZoomLevels, zoomSettings.segmentDuration],
);
const { zoomLevel, handleZoom, isZooming, zoomDirection } = useTimelineZoom({
zoomSettings,
zoomLevels: possibleZoomLevels,
@ -414,6 +423,9 @@ function UIPlayground() {
dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps
isZooming={isZooming} // is the timeline actively zooming?
zoomDirection={zoomDirection} // is the timeline zooming in or out
onZoomChange={handleZoomChange}
possibleZoomLevels={possibleZoomLevels}
currentZoomLevel={currentZoomLevel}
/>
)}
{isEventsReviewTimeline && (
@ -441,6 +453,9 @@ function UIPlayground() {
dense // dense will produce a smaller handlebar and only minute resolution on timestamps
isZooming={isZooming} // is the timeline actively zooming?
zoomDirection={zoomDirection} // is the timeline zooming in or out
onZoomChange={handleZoomChange}
possibleZoomLevels={possibleZoomLevels}
currentZoomLevel={currentZoomLevel}
/>
)}
</div>

View File

@ -81,6 +81,11 @@ export type ConsolidatedSegmentData = {
export type TimelineZoomDirection = "in" | "out" | null;
export type ZoomLevel = {
segmentDuration: number;
timestampSpread: number;
};
export enum ThreatLevel {
SUSPICIOUS = 1,
DANGER = 2,

View File

@ -19,6 +19,7 @@ import {
ReviewSeverity,
ReviewSummary,
SegmentedReviewData,
ZoomLevel,
} from "@/types/review";
import { getChunkedTimeRange } from "@/utils/timelineUtil";
import axios from "axios";
@ -501,7 +502,7 @@ function DetectionReview({
timestampSpread: 15,
});
const possibleZoomLevels = useMemo(
const possibleZoomLevels: ZoomLevel[] = useMemo(
() => [
{ segmentDuration: 60, timestampSpread: 15 },
{ segmentDuration: 30, timestampSpread: 5 },
@ -517,6 +518,14 @@ function DetectionReview({
[possibleZoomLevels],
);
const currentZoomLevel = useMemo(
() =>
possibleZoomLevels.findIndex(
(level) => level.segmentDuration === zoomSettings.segmentDuration,
),
[possibleZoomLevels, zoomSettings.segmentDuration],
);
const { isZooming, zoomDirection } = useTimelineZoom({
zoomSettings,
zoomLevels: possibleZoomLevels,
@ -799,7 +808,7 @@ function DetectionReview({
</div>
</div>
<div className="flex w-[65px] flex-row md:w-[110px]">
<div className="no-scrollbar w-[55px] md:w-[100px]">
<div className="no-scrollbar relative w-[55px] md:w-[100px]">
{loading ? (
<Skeleton className="size-full" />
) : (
@ -821,6 +830,8 @@ function DetectionReview({
dense={isMobile}
isZooming={isZooming}
zoomDirection={zoomDirection}
possibleZoomLevels={possibleZoomLevels}
currentZoomLevel={currentZoomLevel}
/>
)}
</div>

View File

@ -22,6 +22,7 @@ import {
ReviewFilter,
ReviewSegment,
ReviewSummary,
ZoomLevel,
} from "@/types/review";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import {
@ -884,7 +885,7 @@ function Timeline({
timestampSpread: 15,
});
const possibleZoomLevels = useMemo(
const possibleZoomLevels: ZoomLevel[] = useMemo(
() => [
{ segmentDuration: 30, timestampSpread: 15 },
{ segmentDuration: 15, timestampSpread: 5 },
@ -900,6 +901,14 @@ function Timeline({
[possibleZoomLevels],
);
const currentZoomLevel = useMemo(
() =>
possibleZoomLevels.findIndex(
(level) => level.segmentDuration === zoomSettings.segmentDuration,
),
[possibleZoomLevels, zoomSettings.segmentDuration],
);
const { isZooming, zoomDirection } = useTimelineZoom({
zoomSettings,
zoomLevels: possibleZoomLevels,
@ -1010,6 +1019,9 @@ function Timeline({
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
isZooming={isZooming}
zoomDirection={zoomDirection}
onZoomChange={handleZoomChange}
possibleZoomLevels={possibleZoomLevels}
currentZoomLevel={currentZoomLevel}
/>
) : (
<Skeleton className="size-full" />