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
This commit is contained in:
Josh Hawkins 2025-10-29 09:39:07 -05:00 committed by GitHub
parent 9917fc3169
commit 61549a0151
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 170 additions and 71 deletions

View File

@ -589,7 +589,7 @@ async def no_recordings(
) )
scale = params.scale scale = params.scale
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)] clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
if cameras != "all": if cameras != "all":
camera_list = cameras.split(",") camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list)) clauses.append((Recordings.camera << camera_list))
@ -608,33 +608,39 @@ async def no_recordings(
# Convert recordings to list of (start, end) tuples # Convert recordings to list of (start, end) tuples
recordings = [(r["start_time"], r["end_time"]) for r in data] recordings = [(r["start_time"], r["end_time"]) for r in data]
# Generate all time segments # Iterate through time segments and check if each has any recording
current = after
no_recording_segments = [] no_recording_segments = []
current_start = None current = after
current_gap_start = None
while current < before: while current < before:
segment_end = current + scale segment_end = min(current + scale, before)
# Check if segment overlaps with any recording
# Check if this segment overlaps with any recording
has_recording = any( 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 not has_recording:
if current_start is None: # This segment has no recordings
current_start = current # Start a new gap if current_gap_start is None:
current_gap_start = current # Start a new gap
else: 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 # End the current gap and append it
no_recording_segments.append( 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 current = segment_end
# Append the last gap if it exists # Append the last gap if it exists
if current_start is not None: if current_gap_start is not None:
no_recording_segments.append( 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) return JSONResponse(content=no_recording_segments)

View File

@ -46,6 +46,7 @@ export type MotionReviewTimelineProps = {
dense?: boolean; dense?: boolean;
isZooming: boolean; isZooming: boolean;
zoomDirection: TimelineZoomDirection; zoomDirection: TimelineZoomDirection;
alwaysShowMotionLine?: boolean;
}; };
export function MotionReviewTimeline({ export function MotionReviewTimeline({
@ -75,6 +76,7 @@ export function MotionReviewTimeline({
dense = false, dense = false,
isZooming, isZooming,
zoomDirection, zoomDirection,
alwaysShowMotionLine = false,
}: MotionReviewTimelineProps) { }: MotionReviewTimelineProps) {
const internalTimelineRef = useRef<HTMLDivElement>(null); const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef; const selectedTimelineRef = timelineRef || internalTimelineRef;
@ -203,6 +205,7 @@ export function MotionReviewTimeline({
scrollToSegment={scrollToSegment} scrollToSegment={scrollToSegment}
isZooming={isZooming} isZooming={isZooming}
zoomDirection={zoomDirection} zoomDirection={zoomDirection}
getRecordingAvailability={getRecordingAvailability}
> >
<VirtualizedMotionSegments <VirtualizedMotionSegments
ref={virtualizedSegmentsRef} ref={virtualizedSegmentsRef}
@ -221,6 +224,7 @@ export function MotionReviewTimeline({
motionOnly={motionOnly} motionOnly={motionOnly}
getMotionSegmentValue={getMotionSegmentValue} getMotionSegmentValue={getMotionSegmentValue}
getRecordingAvailability={getRecordingAvailability} getRecordingAvailability={getRecordingAvailability}
alwaysShowMotionLine={alwaysShowMotionLine}
/> />
</ReviewTimeline> </ReviewTimeline>
); );

View File

@ -16,6 +16,8 @@ type MotionSegmentProps = {
firstHalfMotionValue: number; firstHalfMotionValue: number;
secondHalfMotionValue: number; secondHalfMotionValue: number;
hasRecording?: boolean; hasRecording?: boolean;
prevIsNoRecording?: boolean;
nextIsNoRecording?: boolean;
motionOnly: boolean; motionOnly: boolean;
showMinimap: boolean; showMinimap: boolean;
minimapStartTime?: number; minimapStartTime?: number;
@ -23,6 +25,7 @@ type MotionSegmentProps = {
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>; setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
dense: boolean; dense: boolean;
alwaysShowMotionLine?: boolean;
}; };
export function MotionSegment({ export function MotionSegment({
@ -33,6 +36,8 @@ export function MotionSegment({
firstHalfMotionValue, firstHalfMotionValue,
secondHalfMotionValue, secondHalfMotionValue,
hasRecording, hasRecording,
prevIsNoRecording,
nextIsNoRecording,
motionOnly, motionOnly,
showMinimap, showMinimap,
minimapStartTime, minimapStartTime,
@ -40,6 +45,7 @@ export function MotionSegment({
setHandlebarTime, setHandlebarTime,
scrollToSegment, scrollToSegment,
dense, dense,
alwaysShowMotionLine = false,
}: MotionSegmentProps) { }: MotionSegmentProps) {
const severityType = "all"; const severityType = "all";
const { getSeverity, getReviewed, displaySeverityType } = const { getSeverity, getReviewed, displaySeverityType } =
@ -116,6 +122,16 @@ export function MotionSegment({
return showMinimap && segmentTime === alignedMinimapEndTime; return showMinimap && segmentTime === alignedMinimapEndTime;
}, [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<HTMLDivElement>(null); const firstMinimapSegmentRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@ -178,16 +194,17 @@ export function MotionSegment({
segmentClasses, segmentClasses,
severity[0] && "bg-gradient-to-r", severity[0] && "bg-gradient-to-r",
severity[0] && severityColorsBg[severity[0]], severity[0] && severityColorsBg[severity[0]],
// TODO: will update this for 0.17 hasRecording == false && "bg-background",
false &&
hasRecording == false &&
firstHalfMotionValue == 0 &&
secondHalfMotionValue == 0 &&
"bg-slashes",
)} )}
onClick={segmentClick} onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)} onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
> >
{isFirstSegmentWithoutRecording && (
<div className="absolute bottom-[0px] left-0 right-0 h-[1px] bg-primary-variant/40" />
)}
{isLastSegmentWithoutRecording && (
<div className="absolute -top-[1px] left-0 right-0 h-[1px] bg-primary-variant/50" />
)}
{!motionOnly && ( {!motionOnly && (
<> <>
{showMinimap && ( {showMinimap && (
@ -218,6 +235,10 @@ export function MotionSegment({
</> </>
)} )}
{(hasRecording ||
firstHalfSegmentWidth > 0 ||
secondHalfSegmentWidth > 0 ||
alwaysShowMotionLine) && (
<div className="absolute left-1/2 z-10 h-[8px] w-[20px] -translate-x-1/2 transform cursor-pointer md:w-[40px]"> <div className="absolute left-1/2 z-10 h-[8px] w-[20px] -translate-x-1/2 transform cursor-pointer md:w-[40px]">
<div className="mb-[1px] flex w-[20px] flex-row justify-center pt-[1px] md:w-[40px]"> <div className="mb-[1px] flex w-[20px] flex-row justify-center pt-[1px] md:w-[40px]">
<div className="mb-[1px] flex justify-center"> <div className="mb-[1px] flex justify-center">
@ -257,6 +278,7 @@ export function MotionSegment({
</div> </div>
</div> </div>
</div> </div>
)}
</div> </div>
)} )}
</> </>

View File

@ -36,6 +36,7 @@ export type ReviewTimelineProps = {
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
isZooming: boolean; isZooming: boolean;
zoomDirection: TimelineZoomDirection; zoomDirection: TimelineZoomDirection;
getRecordingAvailability?: (time: number) => boolean | undefined;
children: ReactNode; children: ReactNode;
}; };
@ -61,6 +62,7 @@ export function ReviewTimeline({
scrollToSegment, scrollToSegment,
isZooming, isZooming,
zoomDirection, zoomDirection,
getRecordingAvailability,
children, children,
}: ReviewTimelineProps) { }: ReviewTimelineProps) {
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false); const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
@ -326,6 +328,25 @@ export function ReviewTimeline({
} }
}, [isDraggingHandlebar, onHandlebarDraggingChange]); }, [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 ( return (
<div <div
ref={timelineRef} ref={timelineRef}
@ -380,6 +401,12 @@ export function ReviewTimeline({
></div> ></div>
</div> </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> </div>
)} )}
{showExportHandles && ( {showExportHandles && (

View File

@ -25,6 +25,7 @@ type VirtualizedMotionSegmentsProps = {
motionOnly: boolean; motionOnly: boolean;
getMotionSegmentValue: (timestamp: number) => number; getMotionSegmentValue: (timestamp: number) => number;
getRecordingAvailability: (timestamp: number) => boolean | undefined; getRecordingAvailability: (timestamp: number) => boolean | undefined;
alwaysShowMotionLine: boolean;
}; };
export interface VirtualizedMotionSegmentsRef { export interface VirtualizedMotionSegmentsRef {
@ -57,6 +58,7 @@ export const VirtualizedMotionSegments = forwardRef<
motionOnly, motionOnly,
getMotionSegmentValue, getMotionSegmentValue,
getRecordingAvailability, getRecordingAvailability,
alwaysShowMotionLine,
}, },
ref, ref,
) => { ) => {
@ -158,6 +160,20 @@ export const VirtualizedMotionSegments = forwardRef<
const hasRecording = getRecordingAvailability(segmentTime); 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) { if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
return null; // Skip rendering this segment in motion only mode return null; // Skip rendering this segment in motion only mode
} }
@ -177,6 +193,8 @@ export const VirtualizedMotionSegments = forwardRef<
firstHalfMotionValue={firstHalfMotionValue} firstHalfMotionValue={firstHalfMotionValue}
secondHalfMotionValue={secondHalfMotionValue} secondHalfMotionValue={secondHalfMotionValue}
hasRecording={hasRecording} hasRecording={hasRecording}
prevIsNoRecording={prevIsNoRecording}
nextIsNoRecording={nextIsNoRecording}
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
segmentTime={segmentTime} segmentTime={segmentTime}
timestampSpread={timestampSpread} timestampSpread={timestampSpread}
@ -187,6 +205,7 @@ export const VirtualizedMotionSegments = forwardRef<
setHandlebarTime={setHandlebarTime} setHandlebarTime={setHandlebarTime}
scrollToSegment={scrollToSegment} scrollToSegment={scrollToSegment}
dense={dense} dense={dense}
alwaysShowMotionLine={alwaysShowMotionLine}
/> />
</div> </div>
); );
@ -205,6 +224,7 @@ export const VirtualizedMotionSegments = forwardRef<
dense, dense,
timestampSpread, timestampSpread,
visibleRange.start, visibleRange.start,
alwaysShowMotionLine,
], ],
); );

View File

@ -43,9 +43,14 @@ export const useMotionSegmentUtils = (
const segmentStart = getSegmentStart(time); const segmentStart = getSegmentStart(time);
const segmentEnd = getSegmentEnd(time); const segmentEnd = getSegmentEnd(time);
const matchingEvents = motion_events.filter((event) => { const matchingEvents = motion_events.filter((event) => {
return ( // Use integer ms math to avoid floating point rounding issues
event.start_time >= segmentStart && event.start_time < segmentEnd // 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( const totalMotion = matchingEvents.reduce(
@ -55,7 +60,7 @@ export const useMotionSegmentUtils = (
return totalMotion; return totalMotion;
}, },
[motion_events, getSegmentStart, getSegmentEnd], [motion_events, getSegmentStart, getSegmentEnd, halfSegmentDuration],
); );
const getAudioSegmentValue = useCallback( const getAudioSegmentValue = useCallback(

View File

@ -895,11 +895,20 @@ function MotionReview({
// motion data // motion data
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
{
segmentDuration,
},
);
const alignedAfter = alignStartDateToTimeline(timeRange.after);
const alignedBefore = alignEndDateToTimeline(timeRange.before);
const { data: motionData } = useSWR<MotionData[]>([ const { data: motionData } = useSWR<MotionData[]>([
"review/activity/motion", "review/activity/motion",
{ {
before: timeRange.before, before: alignedBefore,
after: timeRange.after, after: alignedAfter,
scale: segmentDuration / 2, scale: segmentDuration / 2,
cameras: filter?.cameras?.join(",") ?? null, cameras: filter?.cameras?.join(",") ?? null,
}, },
@ -1006,10 +1015,6 @@ function MotionReview({
} }
}, [playing, playbackRate, nextTimestamp, setPlaying, timeRange]); }, [playing, playbackRate, nextTimestamp, setPlaying, timeRange]);
const { alignStartDateToTimeline } = useTimelineUtils({
segmentDuration,
});
const getDetectionType = useCallback( const getDetectionType = useCallback(
(cameraName: string) => { (cameraName: string) => {
if (motionOnly) { if (motionOnly) {
@ -1159,6 +1164,7 @@ function MotionReview({
dense={isMobileOnly} dense={isMobileOnly}
isZooming={false} isZooming={false}
zoomDirection={null} zoomDirection={null}
alwaysShowMotionLine={true}
/> />
) : ( ) : (
<Skeleton className="size-full" /> <Skeleton className="size-full" />

View File

@ -56,6 +56,7 @@ import { useFullscreen } from "@/hooks/use-fullscreen";
import { useTimezone } from "@/hooks/use-date-utils"; import { useTimezone } from "@/hooks/use-date-utils";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -908,12 +909,20 @@ function Timeline({
}); });
// motion data // motion data
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
{
segmentDuration: zoomSettings.segmentDuration,
},
);
const alignedAfter = alignStartDateToTimeline(timeRange.after);
const alignedBefore = alignEndDateToTimeline(timeRange.before);
const { data: motionData, isLoading } = useSWR<MotionData[]>([ const { data: motionData, isLoading } = useSWR<MotionData[]>([
"review/activity/motion", "review/activity/motion",
{ {
before: timeRange.before, before: alignedBefore,
after: timeRange.after, after: alignedAfter,
scale: Math.round(zoomSettings.segmentDuration / 2), scale: Math.round(zoomSettings.segmentDuration / 2),
cameras: mainCamera, cameras: mainCamera,
}, },
@ -922,9 +931,9 @@ function Timeline({
const { data: noRecordings } = useSWR<RecordingSegment[]>([ const { data: noRecordings } = useSWR<RecordingSegment[]>([
"recordings/unavailable", "recordings/unavailable",
{ {
before: timeRange.before, before: alignedBefore,
after: timeRange.after, after: alignedAfter,
scale: Math.round(zoomSettings.segmentDuration / 2), scale: Math.round(zoomSettings.segmentDuration),
cameras: mainCamera, cameras: mainCamera,
}, },
]); ]);

View File

@ -44,7 +44,7 @@ module.exports = {
}, },
backgroundImage: { backgroundImage: {
slashes: 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: { colors: {
border: "hsl(var(--border))", border: "hsl(var(--border))",