diff --git a/web/src/components/overlay/ObjectTrackOverlay.tsx b/web/src/components/overlay/ObjectTrackOverlay.tsx index 17526bb09..2bd355306 100644 --- a/web/src/components/overlay/ObjectTrackOverlay.tsx +++ b/web/src/components/overlay/ObjectTrackOverlay.tsx @@ -15,17 +15,19 @@ import { useTranslation } from "react-i18next"; type ObjectTrackOverlayProps = { camera: string; selectedObjectId: string; + showBoundingBoxes?: boolean; currentTime: number; videoWidth: number; videoHeight: number; className?: string; - onSeekToTime?: (timestamp: number) => void; + onSeekToTime?: (timestamp: number, play?: boolean) => void; objectTimeline?: ObjectLifecycleSequence[]; }; export default function ObjectTrackOverlay({ camera, selectedObjectId, + showBoundingBoxes = false, currentTime, videoWidth, videoHeight, @@ -227,7 +229,7 @@ export default function ObjectTrackOverlay({ const handlePointClick = useCallback( (timestamp: number) => { - onSeekToTime?.(timestamp); + onSeekToTime?.(timestamp, false); }, [onSeekToTime], ); @@ -366,7 +368,7 @@ export default function ObjectTrackOverlay({ ))} - {currentBoundingBox && ( + {currentBoundingBox && showBoundingBoxes && ( void; onTimeUpdate?: (time: number) => void; onPlaying?: () => void; - onSeekToTime?: (timestamp: number) => void; + onSeekToTime?: (timestamp: number, play?: boolean) => void; setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; toggleFullscreen?: () => void; @@ -324,13 +324,14 @@ export default function HlsVideoPlayer({ key={`${selectedObjectId}-${currentTime}`} camera={camera} selectedObjectId={selectedObjectId} + showBoundingBoxes={!isPlaying} currentTime={currentTime} videoWidth={videoDimensions.width} videoHeight={videoDimensions.height} className="absolute inset-0 z-10" - onSeekToTime={(timestamp) => { + onSeekToTime={(timestamp, play) => { if (onSeekToTime) { - onSeekToTime(timestamp); + onSeekToTime(timestamp, play); } }} objectTimeline={selectedObjectTimeline} diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 2faa50042..80f8e6dbf 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -32,7 +32,7 @@ type DynamicVideoPlayerProps = { onControllerReady: (controller: DynamicVideoController) => void; onTimestampUpdate?: (timestamp: number) => void; onClipEnded?: () => void; - onSeekToTime?: (timestamp: number) => void; + onSeekToTime?: (timestamp: number, play?: boolean) => void; setFullResolution: React.Dispatch>; toggleFullscreen: () => void; containerRef?: React.MutableRefObject; @@ -267,7 +267,11 @@ export default function DynamicVideoPlayer({ onTimeUpdate={onTimeUpdate} onPlayerLoaded={onPlayerLoaded} onClipEnded={onValidateClipEnd} - onSeekToTime={onSeekToTime} + onSeekToTime={(timestamp, play) => { + if (onSeekToTime) { + onSeekToTime(timestamp, play); + } + }} onPlaying={() => { if (isScrubbing) { playerRef.current?.pause(); diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index 396523c0c..66d47d822 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -5,7 +5,10 @@ import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { useDetailStream } from "@/context/detail-stream-context"; import scrollIntoView from "scroll-into-view-if-needed"; import useUserInteraction from "@/hooks/use-user-interaction"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { + formatUnixTimestampToDateTime, + formatSecondsToDuration, +} from "@/utils/dateUtil"; import { useTranslation } from "react-i18next"; import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -13,7 +16,7 @@ import useSWR from "swr"; import ActivityIndicator from "../indicators/activity-indicator"; import { Event } from "@/types/event"; import { getIconForLabel } from "@/utils/iconUtil"; -import { ReviewSegment, REVIEW_PADDING } from "@/types/review"; +import { ReviewSegment } from "@/types/review"; import { Collapsible, CollapsibleTrigger, @@ -28,7 +31,7 @@ import { cn } from "@/lib/utils"; type DetailStreamProps = { reviewItems?: ReviewSegment[]; currentTime: number; - onSeek: (timestamp: number) => void; + onSeek: (timestamp: number, play?: boolean) => void; }; export default function DetailStream({ @@ -49,7 +52,6 @@ export default function DetailStream({ }); const effectiveTime = currentTime + annotationOffset / 1000; - const PAD = 0; // REVIEW_PADDING ?? 2; const [upload, setUpload] = useState(undefined); // Ensure we initialize the active review when reviewItems first arrive. @@ -64,8 +66,8 @@ export default function DetailStream({ let closest: { r: ReviewSegment; diff: number } | undefined; for (const r of reviewItems) { - const start = (r.start_time ?? 0) - PAD; - const end = (r.end_time ?? r.start_time ?? start) + PAD; + const start = r.start_time ?? 0; + const end = r.end_time ?? r.start_time ?? start; if (effectiveTime >= start && effectiveTime <= end) { target = r; break; @@ -78,12 +80,12 @@ export default function DetailStream({ if (!target && closest) target = closest.r; if (target) { - const start = (target.start_time ?? 0) - PAD; + const start = target.start_time ?? 0; setActiveReviewId( `review-${target.id ?? target.start_time ?? Math.floor(start)}`, ); } - }, [reviewItems, activeReviewId, effectiveTime, PAD]); + }, [reviewItems, activeReviewId, effectiveTime]); // Auto-scroll to current time useEffect(() => { @@ -99,8 +101,8 @@ export default function DetailStream({ let closest: { r: ReviewSegment; diff: number } | undefined; for (const r of items) { - const start = (r.start_time ?? 0) - PAD; - const end = (r.end_time ?? r.start_time ?? start) + PAD; + const start = r.start_time ?? 0; + const end = r.end_time ?? r.start_time ?? start; if (effectiveTime >= start && effectiveTime <= end) { target = r; break; @@ -113,7 +115,7 @@ export default function DetailStream({ if (!target && closest) target = closest.r; if (target) { - const start = (target.start_time ?? 0) - PAD; + const start = target.start_time ?? 0; const id = `review-${target.id ?? target.start_time ?? Math.floor(start)}`; const element = scrollRef.current.querySelector( `[data-review-id="${id}"]`, @@ -132,15 +134,14 @@ export default function DetailStream({ annotationOffset, userInteracting, setProgrammaticScroll, - PAD, ]); // Auto-select active review based on effectiveTime (if inside a review range) useEffect(() => { if (!reviewItems || reviewItems.length === 0) return; for (const r of reviewItems) { - const start = (r.start_time ?? 0) - PAD; - const end = (r.end_time ?? r.start_time ?? start) + PAD; + const start = r.start_time ?? 0; + const end = r.end_time ?? r.start_time ?? start; if (effectiveTime >= start && effectiveTime <= end) { setActiveReviewId( `review-${r.id ?? r.start_time ?? Math.floor(start)}`, @@ -148,7 +149,7 @@ export default function DetailStream({ return; } } - }, [effectiveTime, reviewItems, PAD]); + }, [effectiveTime, reviewItems]); if (!config) { return ; @@ -173,7 +174,7 @@ export default function DetailStream({ ) : ( reviewItems?.map((review: ReviewSegment) => { - const id = `review-${review.id ?? review.start_time ?? Math.floor((review.start_time ?? 0) - PAD)}`; + const id = `review-${review.id ?? review.start_time ?? Math.floor(review.start_time ?? 0)}`; return ( void; + onSeek: (timestamp: number, play?: boolean) => void; isActive?: boolean; onActivate?: () => void; onOpenUpload?: (e: Event) => void; @@ -219,18 +220,14 @@ function ReviewGroup({ effectiveTime, }: ReviewGroupProps) { const { t } = useTranslation("views/events"); - const PAD = REVIEW_PADDING ?? 2; + const start = review.start_time ?? 0; - // derive start timestamp from the review - const start = (review.start_time ?? 0) - PAD; - - // display time first in the header const displayTime = formatUnixTimestampToDateTime(start, { timezone: config.ui.timezone, date_format: config.ui.time_format == "24hour" - ? t("time.formattedTimestamp.24hour", { ns: "common" }) - : t("time.formattedTimestamp.12hour", { ns: "common" }), + ? t("time.formattedTimestampHourMinuteSecond.24hour", { ns: "common" }) + : t("time.formattedTimestampHourMinuteSecond.12hour", { ns: "common" }), time_style: "medium", date_style: "medium", }); @@ -268,6 +265,13 @@ function ReviewGroup({ } }, [review, t, fetchedEvents]); + const reviewDuration = + review.end_time != null + ? formatSecondsToDuration( + Math.max(0, Math.floor((review.end_time ?? 0) - start)), + ) + : null; + return (
{displayTime}
+ {reviewDuration && ( +
+ {reviewDuration} +
+ )}
{reviewInfo}
@@ -325,7 +334,7 @@ function ReviewGroup({ type EventCollapsibleProps = { event: Event; effectiveTime?: number; - onSeek: (ts: number) => void; + onSeek: (ts: number, play?: boolean) => void; onOpenUpload?: (e: Event) => void; }; function EventCollapsible({ @@ -398,7 +407,7 @@ function EventCollapsible({ event.id != selectedObjectId && (effectiveTime ?? 0) >= (event.start_time ?? 0) && (effectiveTime ?? 0) <= (event.end_time ?? event.start_time ?? 0) && - "bg-secondary-highlight/80 outline-[1px] -outline-offset-[0.8px] outline-primary/40", + "bg-secondary-highlight outline-[1.5px] -outline-offset-[1.1px] outline-primary/40", )} >
@@ -450,9 +459,7 @@ function EventCollapsible({
{ - onSeek(ts); - }} + onSeek={onSeek} effectiveTime={effectiveTime} />
@@ -464,11 +471,11 @@ function EventCollapsible({ type LifecycleItemProps = { event: ObjectLifecycleSequence; - onSeek: (timestamp: number) => void; isActive?: boolean; + onSeek?: (timestamp: number, play?: boolean) => void; }; -function LifecycleItem({ event, isActive }: LifecycleItemProps) { +function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) { const { t } = useTranslation("views/events"); const { data: config } = useSWR("config"); @@ -490,9 +497,15 @@ function LifecycleItem({ event, isActive }: LifecycleItemProps) { return (
{ + onSeek?.(event.timestamp ?? 0, false); + }} className={cn( - "flex items-center gap-2 text-sm text-primary-variant", - isActive ? "text-white" : "duration-500", + "flex cursor-pointer items-center gap-2 text-sm text-primary-variant", + isActive + ? "font-semibold text-primary dark:font-normal" + : "duration-500", )} >
@@ -513,7 +526,7 @@ function ObjectTimeline({ effectiveTime, }: { eventId: string; - onSeek: (ts: number) => void; + onSeek: (ts: number, play?: boolean) => void; effectiveTime?: number; }) { const { t } = useTranslation("views/events"); @@ -542,14 +555,12 @@ function ObjectTimeline({ const isActive = Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5; return ( -
{ - onSeek(event.timestamp); - }} - > - -
+ ); })}
diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index aa15d693f..aee60b6b2 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -283,15 +283,14 @@ export function RecordingView({ ]); const manuallySetCurrentTime = useCallback( - (time: number) => { + (time: number, play: boolean = false) => { if (!currentTimeRange) { return; } - setCurrentTime(time); if (currentTimeRange.after <= time && currentTimeRange.before >= time) { - mainControllerRef.current?.seekToTimestamp(time, true); + mainControllerRef.current?.seekToTimestamp(time, play); } else { updateSelectedSegment(time, true); } @@ -310,7 +309,7 @@ export function RecordingView({ } else { updateSelectedSegment(currentTime, true); } - } else if (playerTime != currentTime) { + } else if (playerTime != currentTime && timelineType != "detail") { mainControllerRef.current?.play(); } } @@ -1006,7 +1005,9 @@ function Timeline({ ) : timelineType == "detail" ? ( manuallySetCurrentTime(timestamp, true)} + onSeek={(timestamp, play) => + manuallySetCurrentTime(timestamp, play ?? true) + } reviewItems={mainCameraReviewItems} /> ) : ( diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index d5e0a0b80..b96f2ecca 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -36,8 +36,8 @@ --secondary-foreground: hsl(222.2, 17.4%, 36.2%); --secondary-foreground: 222.2 17.4% 36.2%; - --secondary-highlight: hsl(0, 0%, 94%); - --secondary-highlight: 0 0% 94%; + --secondary-highlight: hsl(210, 17.4%, 94%); + --secondary-highlight: 210 17.4% 94%; --neutral: hsl(0, 0%, 45.1%); --neutral: 0 0% 45.1%;