From 61549a0151c0cac770ae7946618b00b16e4fb2a5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:39:07 -0500 Subject: [PATCH] No recordings indicator on History timeline (#20715) * black background * fix backend logic * fixes * ensure data being sent to api is segment aligned * tweak * tweaks to keep motion review as-is * fix for half segment fractional seconds when using zooming --- frigate/api/media.py | 34 +++--- .../timeline/MotionReviewTimeline.tsx | 4 + web/src/components/timeline/MotionSegment.tsx | 104 +++++++++++------- .../components/timeline/ReviewTimeline.tsx | 27 +++++ .../timeline/VirtualizedMotionSegments.tsx | 20 ++++ web/src/hooks/use-motion-segment-utils.ts | 13 ++- web/src/views/events/EventView.tsx | 18 ++- web/src/views/recording/RecordingView.tsx | 19 +++- web/tailwind.config.cjs | 2 +- 9 files changed, 170 insertions(+), 71 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 87456978e..642245a1d 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -589,7 +589,7 @@ async def no_recordings( ) scale = params.scale - clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] + clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)] if cameras != "all": camera_list = cameras.split(",") clauses.append((Recordings.camera << camera_list)) @@ -608,33 +608,39 @@ async def no_recordings( # Convert recordings to list of (start, end) tuples recordings = [(r["start_time"], r["end_time"]) for r in data] - # Generate all time segments - current = after + # Iterate through time segments and check if each has any recording no_recording_segments = [] - current_start = None + current = after + current_gap_start = None while current < before: - segment_end = current + scale - # Check if segment overlaps with any recording + segment_end = min(current + scale, before) + + # Check if this segment overlaps with any recording has_recording = any( - start <= segment_end and end >= current for start, end in recordings + rec_start < segment_end and rec_end > current + for rec_start, rec_end in recordings ) + if not has_recording: - if current_start is None: - current_start = current # Start a new gap + # This segment has no recordings + if current_gap_start is None: + current_gap_start = current # Start a new gap else: - if current_start is not None: + # This segment has recordings + if current_gap_start is not None: # End the current gap and append it no_recording_segments.append( - {"start_time": int(current_start), "end_time": int(current)} + {"start_time": int(current_gap_start), "end_time": int(current)} ) - current_start = None + current_gap_start = None + current = segment_end # Append the last gap if it exists - if current_start is not None: + if current_gap_start is not None: no_recording_segments.append( - {"start_time": int(current_start), "end_time": int(before)} + {"start_time": int(current_gap_start), "end_time": int(before)} ) return JSONResponse(content=no_recording_segments) diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 662ccf150..4136aa4b3 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -46,6 +46,7 @@ export type MotionReviewTimelineProps = { dense?: boolean; isZooming: boolean; zoomDirection: TimelineZoomDirection; + alwaysShowMotionLine?: boolean; }; export function MotionReviewTimeline({ @@ -75,6 +76,7 @@ export function MotionReviewTimeline({ dense = false, isZooming, zoomDirection, + alwaysShowMotionLine = false, }: MotionReviewTimelineProps) { const internalTimelineRef = useRef(null); const selectedTimelineRef = timelineRef || internalTimelineRef; @@ -203,6 +205,7 @@ export function MotionReviewTimeline({ scrollToSegment={scrollToSegment} isZooming={isZooming} zoomDirection={zoomDirection} + getRecordingAvailability={getRecordingAvailability} > ); diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index d87bfdda3..90ce5e1a5 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -16,6 +16,8 @@ type MotionSegmentProps = { firstHalfMotionValue: number; secondHalfMotionValue: number; hasRecording?: boolean; + prevIsNoRecording?: boolean; + nextIsNoRecording?: boolean; motionOnly: boolean; showMinimap: boolean; minimapStartTime?: number; @@ -23,6 +25,7 @@ type MotionSegmentProps = { setHandlebarTime?: React.Dispatch>; scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; dense: boolean; + alwaysShowMotionLine?: boolean; }; export function MotionSegment({ @@ -33,6 +36,8 @@ export function MotionSegment({ firstHalfMotionValue, secondHalfMotionValue, hasRecording, + prevIsNoRecording, + nextIsNoRecording, motionOnly, showMinimap, minimapStartTime, @@ -40,6 +45,7 @@ export function MotionSegment({ setHandlebarTime, scrollToSegment, dense, + alwaysShowMotionLine = false, }: MotionSegmentProps) { const severityType = "all"; const { getSeverity, getReviewed, displaySeverityType } = @@ -116,6 +122,16 @@ export function MotionSegment({ return showMinimap && segmentTime === alignedMinimapEndTime; }, [showMinimap, segmentTime, alignedMinimapEndTime]); + // Bottom border: current segment HAS recording, but next segment (below/earlier) has NO recording + const isFirstSegmentWithoutRecording = useMemo(() => { + return hasRecording === true && nextIsNoRecording === true; + }, [hasRecording, nextIsNoRecording]); + + // Top border: current segment HAS recording, but prev segment (above/later) has NO recording + const isLastSegmentWithoutRecording = useMemo(() => { + return hasRecording === true && prevIsNoRecording === true; + }, [hasRecording, prevIsNoRecording]); + const firstMinimapSegmentRef = useRef(null); useEffect(() => { @@ -178,16 +194,17 @@ export function MotionSegment({ segmentClasses, severity[0] && "bg-gradient-to-r", severity[0] && severityColorsBg[severity[0]], - // TODO: will update this for 0.17 - false && - hasRecording == false && - firstHalfMotionValue == 0 && - secondHalfMotionValue == 0 && - "bg-slashes", + hasRecording == false && "bg-background", )} onClick={segmentClick} onTouchEnd={(event) => handleTouchStart(event, segmentClick)} > + {isFirstSegmentWithoutRecording && ( +
+ )} + {isLastSegmentWithoutRecording && ( +
+ )} {!motionOnly && ( <> {showMinimap && ( @@ -218,45 +235,50 @@ export function MotionSegment({ )} -
-
-
-
+ {(hasRecording || + firstHalfSegmentWidth > 0 || + secondHalfSegmentWidth > 0 || + alwaysShowMotionLine) && ( +
+
+
+
+
-
-
-
-
+
+
+
+
-
+ )}
)} diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx index d2238a718..2f0620ebc 100644 --- a/web/src/components/timeline/ReviewTimeline.tsx +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -36,6 +36,7 @@ export type ReviewTimelineProps = { scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; isZooming: boolean; zoomDirection: TimelineZoomDirection; + getRecordingAvailability?: (time: number) => boolean | undefined; children: ReactNode; }; @@ -61,6 +62,7 @@ export function ReviewTimeline({ scrollToSegment, isZooming, zoomDirection, + getRecordingAvailability, children, }: ReviewTimelineProps) { const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false); @@ -326,6 +328,25 @@ export function ReviewTimeline({ } }, [isDraggingHandlebar, onHandlebarDraggingChange]); + const isHandlebarInNoRecordingPeriod = useMemo(() => { + if (!getRecordingAvailability || handlebarTime === undefined) return false; + + // Check current segment + const currentAvailability = getRecordingAvailability(handlebarTime); + if (currentAvailability !== false) return false; + + // Check if at least one adjacent segment also has no recordings + const beforeAvailability = getRecordingAvailability( + handlebarTime - segmentDuration, + ); + const afterAvailability = getRecordingAvailability( + handlebarTime + segmentDuration, + ); + + // If current segment has no recordings AND at least one adjacent segment also has no recordings + return beforeAvailability === false || afterAvailability === false; + }, [getRecordingAvailability, handlebarTime, segmentDuration]); + return (
+ {/* TODO: determine if we should keep this tooltip */} + {false && isHandlebarInNoRecordingPeriod && ( +
+ No recordings +
+ )}
)} {showExportHandles && ( diff --git a/web/src/components/timeline/VirtualizedMotionSegments.tsx b/web/src/components/timeline/VirtualizedMotionSegments.tsx index fc7a8224f..f868e158f 100644 --- a/web/src/components/timeline/VirtualizedMotionSegments.tsx +++ b/web/src/components/timeline/VirtualizedMotionSegments.tsx @@ -25,6 +25,7 @@ type VirtualizedMotionSegmentsProps = { motionOnly: boolean; getMotionSegmentValue: (timestamp: number) => number; getRecordingAvailability: (timestamp: number) => boolean | undefined; + alwaysShowMotionLine: boolean; }; export interface VirtualizedMotionSegmentsRef { @@ -57,6 +58,7 @@ export const VirtualizedMotionSegments = forwardRef< motionOnly, getMotionSegmentValue, getRecordingAvailability, + alwaysShowMotionLine, }, ref, ) => { @@ -158,6 +160,20 @@ export const VirtualizedMotionSegments = forwardRef< const hasRecording = getRecordingAvailability(segmentTime); + // Check if previous and next segments have recordings + // This is important because in motionOnly mode, the segments array is filtered + const prevSegmentTime = segmentTime + segmentDuration; + const nextSegmentTime = segmentTime - segmentDuration; + + const prevHasRecording = getRecordingAvailability(prevSegmentTime); + const nextHasRecording = getRecordingAvailability(nextSegmentTime); + + // Check if prev/next segments have no recording available + // Note: We only check hasRecording, not motion values, because segments can have + // recordings available but no motion (eg, start of a recording before motion begins) + const prevIsNoRecording = prevHasRecording === false; + const nextIsNoRecording = nextHasRecording === false; + if ((!segmentMotion || overlappingReviewItems) && motionOnly) { return null; // Skip rendering this segment in motion only mode } @@ -177,6 +193,8 @@ export const VirtualizedMotionSegments = forwardRef< firstHalfMotionValue={firstHalfMotionValue} secondHalfMotionValue={secondHalfMotionValue} hasRecording={hasRecording} + prevIsNoRecording={prevIsNoRecording} + nextIsNoRecording={nextIsNoRecording} segmentDuration={segmentDuration} segmentTime={segmentTime} timestampSpread={timestampSpread} @@ -187,6 +205,7 @@ export const VirtualizedMotionSegments = forwardRef< setHandlebarTime={setHandlebarTime} scrollToSegment={scrollToSegment} dense={dense} + alwaysShowMotionLine={alwaysShowMotionLine} />
); @@ -205,6 +224,7 @@ export const VirtualizedMotionSegments = forwardRef< dense, timestampSpread, visibleRange.start, + alwaysShowMotionLine, ], ); diff --git a/web/src/hooks/use-motion-segment-utils.ts b/web/src/hooks/use-motion-segment-utils.ts index 0482e776e..91391c550 100644 --- a/web/src/hooks/use-motion-segment-utils.ts +++ b/web/src/hooks/use-motion-segment-utils.ts @@ -43,9 +43,14 @@ export const useMotionSegmentUtils = ( const segmentStart = getSegmentStart(time); const segmentEnd = getSegmentEnd(time); const matchingEvents = motion_events.filter((event) => { - return ( - event.start_time >= segmentStart && event.start_time < segmentEnd - ); + // Use integer ms math to avoid floating point rounding issues + // when halfSegmentDuration is not an integer + // (eg, 2.5 seconds from timeline zooming) + const eventMs = Math.round(event.start_time * 1000); + const halfMs = Math.round(halfSegmentDuration * 1000); + const eventBucketMs = Math.round(eventMs / halfMs) * halfMs; + const eventRounded = eventBucketMs / 1000; + return eventRounded >= segmentStart && eventRounded < segmentEnd; }); const totalMotion = matchingEvents.reduce( @@ -55,7 +60,7 @@ export const useMotionSegmentUtils = ( return totalMotion; }, - [motion_events, getSegmentStart, getSegmentEnd], + [motion_events, getSegmentStart, getSegmentEnd, halfSegmentDuration], ); const getAudioSegmentValue = useCallback( diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index bcc2859b9..2737b7be0 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -895,11 +895,20 @@ function MotionReview({ // motion data + const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils( + { + segmentDuration, + }, + ); + + const alignedAfter = alignStartDateToTimeline(timeRange.after); + const alignedBefore = alignEndDateToTimeline(timeRange.before); + const { data: motionData } = useSWR([ "review/activity/motion", { - before: timeRange.before, - after: timeRange.after, + before: alignedBefore, + after: alignedAfter, scale: segmentDuration / 2, cameras: filter?.cameras?.join(",") ?? null, }, @@ -1006,10 +1015,6 @@ function MotionReview({ } }, [playing, playbackRate, nextTimestamp, setPlaying, timeRange]); - const { alignStartDateToTimeline } = useTimelineUtils({ - segmentDuration, - }); - const getDetectionType = useCallback( (cameraName: string) => { if (motionOnly) { @@ -1159,6 +1164,7 @@ function MotionReview({ dense={isMobileOnly} isZooming={false} zoomDirection={null} + alwaysShowMotionLine={true} /> ) : ( diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 44a3d0aab..2d3600288 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -56,6 +56,7 @@ import { useFullscreen } from "@/hooks/use-fullscreen"; import { useTimezone } from "@/hooks/use-date-utils"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { useTranslation } from "react-i18next"; +import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { Tooltip, TooltipContent, @@ -908,12 +909,20 @@ function Timeline({ }); // motion data + const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils( + { + segmentDuration: zoomSettings.segmentDuration, + }, + ); + + const alignedAfter = alignStartDateToTimeline(timeRange.after); + const alignedBefore = alignEndDateToTimeline(timeRange.before); const { data: motionData, isLoading } = useSWR([ "review/activity/motion", { - before: timeRange.before, - after: timeRange.after, + before: alignedBefore, + after: alignedAfter, scale: Math.round(zoomSettings.segmentDuration / 2), cameras: mainCamera, }, @@ -922,9 +931,9 @@ function Timeline({ const { data: noRecordings } = useSWR([ "recordings/unavailable", { - before: timeRange.before, - after: timeRange.after, - scale: Math.round(zoomSettings.segmentDuration / 2), + before: alignedBefore, + after: alignedAfter, + scale: Math.round(zoomSettings.segmentDuration), cameras: mainCamera, }, ]); diff --git a/web/tailwind.config.cjs b/web/tailwind.config.cjs index 33d403e4f..eb1195bda 100644 --- a/web/tailwind.config.cjs +++ b/web/tailwind.config.cjs @@ -44,7 +44,7 @@ module.exports = { }, backgroundImage: { slashes: - "repeating-linear-gradient(45deg, hsl(var(--primary-variant) / 0.2), hsl(var(--primary-variant) / 0.2) 2px, transparent 2px, transparent 8px)", + "repeating-linear-gradient(135deg, hsl(var(--primary-variant) / 0.3), hsl(var(--primary-variant) / 0.3) 2px, transparent 2px, transparent 8px), linear-gradient(to right, hsl(var(--background)), hsl(var(--background)))", }, colors: { border: "hsl(var(--border))",