mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 05:24:11 +03:00
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:
parent
9917fc3169
commit
61549a0151
@ -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)
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||
@ -203,6 +205,7 @@ export function MotionReviewTimeline({
|
||||
scrollToSegment={scrollToSegment}
|
||||
isZooming={isZooming}
|
||||
zoomDirection={zoomDirection}
|
||||
getRecordingAvailability={getRecordingAvailability}
|
||||
>
|
||||
<VirtualizedMotionSegments
|
||||
ref={virtualizedSegmentsRef}
|
||||
@ -221,6 +224,7 @@ export function MotionReviewTimeline({
|
||||
motionOnly={motionOnly}
|
||||
getMotionSegmentValue={getMotionSegmentValue}
|
||||
getRecordingAvailability={getRecordingAvailability}
|
||||
alwaysShowMotionLine={alwaysShowMotionLine}
|
||||
/>
|
||||
</ReviewTimeline>
|
||||
);
|
||||
|
||||
@ -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<React.SetStateAction<number>>;
|
||||
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<HTMLDivElement>(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 && (
|
||||
<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 && (
|
||||
<>
|
||||
{showMinimap && (
|
||||
@ -218,45 +235,50 @@ export function MotionSegment({
|
||||
</>
|
||||
)}
|
||||
|
||||
<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 justify-center">
|
||||
<div
|
||||
key={`${segmentKey}_motion_data_1`}
|
||||
data-motion-value={secondHalfSegmentWidth}
|
||||
className={cn(
|
||||
"h-[2px]",
|
||||
"rounded-full",
|
||||
secondHalfSegmentWidth
|
||||
? "bg-motion_review"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
style={{
|
||||
width: secondHalfSegmentWidth || 1,
|
||||
}}
|
||||
></div>
|
||||
{(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="mb-[1px] flex w-[20px] flex-row justify-center pt-[1px] md:w-[40px]">
|
||||
<div className="mb-[1px] flex justify-center">
|
||||
<div
|
||||
key={`${segmentKey}_motion_data_1`}
|
||||
data-motion-value={secondHalfSegmentWidth}
|
||||
className={cn(
|
||||
"h-[2px]",
|
||||
"rounded-full",
|
||||
secondHalfSegmentWidth
|
||||
? "bg-motion_review"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
style={{
|
||||
width: secondHalfSegmentWidth || 1,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[20px] flex-row justify-center pb-[1px] md:w-[40px]">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
key={`${segmentKey}_motion_data_2`}
|
||||
data-motion-value={firstHalfSegmentWidth}
|
||||
className={cn(
|
||||
"h-[2px]",
|
||||
"rounded-full",
|
||||
firstHalfSegmentWidth
|
||||
? "bg-motion_review"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
style={{
|
||||
width: firstHalfSegmentWidth || 1,
|
||||
}}
|
||||
></div>
|
||||
<div className="flex w-[20px] flex-row justify-center pb-[1px] md:w-[40px]">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
key={`${segmentKey}_motion_data_2`}
|
||||
data-motion-value={firstHalfSegmentWidth}
|
||||
className={cn(
|
||||
"h-[2px]",
|
||||
"rounded-full",
|
||||
firstHalfSegmentWidth
|
||||
? "bg-motion_review"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
style={{
|
||||
width: firstHalfSegmentWidth || 1,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
ref={timelineRef}
|
||||
@ -380,6 +401,12 @@ export function ReviewTimeline({
|
||||
></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 && (
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -205,6 +224,7 @@ export const VirtualizedMotionSegments = forwardRef<
|
||||
dense,
|
||||
timestampSpread,
|
||||
visibleRange.start,
|
||||
alwaysShowMotionLine,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<MotionData[]>([
|
||||
"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}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
|
||||
@ -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<MotionData[]>([
|
||||
"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<RecordingSegment[]>([
|
||||
"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,
|
||||
},
|
||||
]);
|
||||
|
||||
@ -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))",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user