option to show motion only on motion timeline (#10626)

This commit is contained in:
Josh Hawkins 2024-03-23 08:33:50 -05:00 committed by GitHub
parent 8e1d18d06b
commit 4159334520
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 287 additions and 127 deletions

View File

@ -10,10 +10,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ReviewFilter, ReviewSummary } from "@/types/review";
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FaCalendarAlt, FaFilter, FaVideo } from "react-icons/fa";
import { FaCalendarAlt, FaFilter, FaRunning, FaVideo } from "react-icons/fa";
import { isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Switch } from "../ui/switch";
@ -27,12 +27,18 @@ type ReviewFilterGroupProps = {
reviewSummary?: ReviewSummary;
filter?: ReviewFilter;
onUpdateFilter: (filter: ReviewFilter) => void;
severity: ReviewSeverity;
motionOnly: boolean;
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function ReviewFilterGroup({
reviewSummary,
filter,
onUpdateFilter,
severity,
motionOnly,
setMotionOnly,
}: ReviewFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config");
@ -94,7 +100,7 @@ export default function ReviewFilterGroup({
);
return (
<div>
<div className="flex justify-center">
<CamerasFilterButton
allCameras={filterValues.cameras}
groups={groups}
@ -110,17 +116,24 @@ export default function ReviewFilterGroup({
}
updateSelectedDay={onUpdateSelectedDay}
/>
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
showReviewed={filter?.showReviewed || 0}
setShowReviewed={(reviewed) =>
onUpdateFilter({ ...filter, showReviewed: reviewed })
}
/>
{severity == "significant_motion" ? (
<ShowMotionOnlyButton
motionOnly={motionOnly}
setMotionOnly={setMotionOnly}
/>
) : (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
showReviewed={filter?.showReviewed || 0}
setShowReviewed={(reviewed) =>
onUpdateFilter({ ...filter, showReviewed: reviewed })
}
/>
)}
</div>
);
}
@ -485,3 +498,46 @@ function GeneralFilterButton({
</Popover>
);
}
type ShowMotionOnlyButtonProps = {
motionOnly: boolean;
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
};
function ShowMotionOnlyButton({
motionOnly,
setMotionOnly,
}: ShowMotionOnlyButtonProps) {
return (
<>
<div className="hidden md:inline-flex items-center justify-center whitespace-nowrap text-sm bg-secondary text-secondary-foreground h-9 rounded-md md:px-3 md:mx-1">
<Switch
className="ml-1"
id="collapse-motion"
checked={motionOnly}
onCheckedChange={() => {
setMotionOnly(!motionOnly);
}}
/>
<Label
className="mx-2 text-secondary-foreground"
htmlFor="collapse-motion"
>
Motion only
</Label>
</div>
<div className="block md:hidden">
<Button
size="sm"
className="ml-1"
variant="secondary"
onClick={() => setMotionOnly(!motionOnly)}
>
<FaRunning
className={`${motionOnly ? "text-selected" : "text-muted-foreground"}`}
/>
</Button>
</div>
</>
);
}

View File

@ -236,10 +236,12 @@ export function EventReviewTimeline({
const element = selectedTimelineRef.current?.querySelector(
`[data-segment-id="${Math.max(...alignedVisibleTimestamps)}"]`,
);
scrollIntoView(element as HTMLDivElement, {
scrollMode: "if-needed",
behavior: "smooth",
});
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
behavior: "smooth",
});
}
}
}, [
selectedTimelineRef,

View File

@ -201,7 +201,7 @@ export function EventSegment({
<div
key={segmentKey}
data-segment-id={segmentKey}
className={segmentClasses}
className={`segment ${segmentClasses}`}
onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
>

View File

@ -21,6 +21,7 @@ export type MotionReviewTimelineProps = {
showHandlebar?: boolean;
handlebarTime?: number;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
motionOnly?: boolean;
showMinimap?: boolean;
minimapStartTime?: number;
minimapEndTime?: number;
@ -45,6 +46,7 @@ export function MotionReviewTimeline({
showHandlebar = false,
handlebarTime,
setHandlebarTime,
motionOnly = false,
showMinimap = false,
minimapStartTime,
minimapEndTime,
@ -113,6 +115,7 @@ export function MotionReviewTimeline({
draggableElementTime: handlebarTime,
setDraggableElementTime: setHandlebarTime,
timelineDuration,
timelineCollapsed: motionOnly,
timelineStartAligned,
isDragging,
setIsDragging,
@ -176,6 +179,7 @@ export function MotionReviewTimeline({
segmentDuration={segmentDuration}
segmentTime={segmentTime}
timestampSpread={timestampSpread}
motionOnly={motionOnly}
showMinimap={showMinimap}
minimapStartTime={minimapStartTime}
minimapEndTime={minimapEndTime}
@ -195,6 +199,7 @@ export function MotionReviewTimeline({
minimapEndTime,
events,
motion_events,
motionOnly,
]);
const segments = useMemo(
@ -211,6 +216,7 @@ export function MotionReviewTimeline({
minimapEndTime,
events,
motion_events,
motionOnly,
],
);

View File

@ -14,6 +14,7 @@ type MotionSegmentProps = {
segmentTime: number;
segmentDuration: number;
timestampSpread: number;
motionOnly: boolean;
showMinimap: boolean;
minimapStartTime?: number;
minimapEndTime?: number;
@ -26,6 +27,7 @@ export function MotionSegment({
segmentTime,
segmentDuration,
timestampSpread,
motionOnly,
showMinimap,
minimapStartTime,
minimapEndTime,
@ -180,79 +182,96 @@ export function MotionSegment({
}, [segmentTime, setHandlebarTime]);
return (
<div
key={segmentKey}
data-segment-id={segmentKey}
className={`segment ${firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`}
onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
>
<MinimapBounds
isFirstSegmentInMinimap={isFirstSegmentInMinimap}
isLastSegmentInMinimap={isLastSegmentInMinimap}
alignedMinimapStartTime={alignedMinimapStartTime}
alignedMinimapEndTime={alignedMinimapEndTime}
firstMinimapSegmentRef={firstMinimapSegmentRef}
/>
<>
{(((firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1) &&
motionOnly &&
severity[0] < 2) ||
!motionOnly) && (
<div
key={segmentKey}
data-segment-id={segmentKey}
className={`segment ${firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`}
onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
>
{!motionOnly && (
<>
<MinimapBounds
isFirstSegmentInMinimap={isFirstSegmentInMinimap}
isLastSegmentInMinimap={isLastSegmentInMinimap}
alignedMinimapStartTime={alignedMinimapStartTime}
alignedMinimapEndTime={alignedMinimapEndTime}
firstMinimapSegmentRef={firstMinimapSegmentRef}
/>
<Tick timestamp={timestamp} timestampSpread={timestampSpread} />
<Tick
key={`${segmentKey}_tick`}
timestamp={timestamp}
timestampSpread={timestampSpread}
/>
<Timestamp
isFirstSegmentInMinimap={isFirstSegmentInMinimap}
isLastSegmentInMinimap={isLastSegmentInMinimap}
timestamp={timestamp}
timestampSpread={timestampSpread}
segmentKey={segmentKey}
/>
<Timestamp
key={`${segmentKey}_timestamp`}
isFirstSegmentInMinimap={isFirstSegmentInMinimap}
isLastSegmentInMinimap={isLastSegmentInMinimap}
timestamp={timestamp}
timestampSpread={timestampSpread}
segmentKey={segmentKey}
/>
</>
)}
<div className="absolute left-1/2 transform -translate-x-1/2 w-[20px] md:w-[40px] h-2 z-10 cursor-pointer">
<div className="flex flex-row justify-center w-[20px] md:w-[40px] mb-[1px]">
<div className="flex justify-center">
<div
key={`${segmentKey}_motion_data_1`}
className={`${isDesktop && animationClassesSecondHalf} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
style={{
width: secondHalfSegmentWidth,
}}
></div>
</div>
</div>
<div className="flex flex-row justify-center w-[20px] md:w-[40px]">
<div className="flex justify-center">
<div
key={`${segmentKey}_motion_data_2`}
className={`${isDesktop && animationClassesFirstHalf} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
style={{
width: firstHalfSegmentWidth,
}}
></div>
</div>
</div>
</div>
{severity.map((severityValue: number, index: number) => {
if (severityValue > 1) {
return (
<React.Fragment key={index}>
<div className="absolute right-0 h-2 z-10">
<div className="absolute left-1/2 transform -translate-x-1/2 w-[20px] md:w-[40px] h-2 z-10 cursor-pointer">
<div className="flex flex-row justify-center w-[20px] md:w-[40px] mb-[1px]">
<div className="flex justify-center">
<div
key={`${segmentKey}_${index}_secondary_data`}
className={`
w-1 h-2 bg-gradient-to-r
${roundBottomSecondary ? "rounded-bl-full rounded-br-full" : ""}
${roundTopSecondary ? "rounded-tl-full rounded-tr-full" : ""}
${severityColors[severityValue]}
`}
key={`${segmentKey}_motion_data_1`}
className={`${isDesktop && animationClassesSecondHalf} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
style={{
width: secondHalfSegmentWidth,
}}
></div>
</div>
</React.Fragment>
);
} else {
return null;
}
})}
</div>
</div>
<div className="flex flex-row justify-center w-[20px] md:w-[40px]">
<div className="flex justify-center">
<div
key={`${segmentKey}_motion_data_2`}
className={`${isDesktop && animationClassesFirstHalf} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
style={{
width: firstHalfSegmentWidth,
}}
></div>
</div>
</div>
</div>
{!motionOnly &&
severity.map((severityValue: number, index: number) => {
if (severityValue > 1) {
return (
<React.Fragment key={index}>
<div className="absolute right-0 h-2 z-10">
<div
key={`${segmentKey}_${index}_secondary_data`}
className={`
w-1 h-2 bg-gradient-to-r
${roundBottomSecondary ? "rounded-bl-full rounded-br-full" : ""}
${roundTopSecondary ? "rounded-tl-full rounded-tr-full" : ""}
${severityColors[severityValue]}
`}
></div>
</div>
</React.Fragment>
);
} else {
return null;
}
})}
</div>
)}
</>
);
}

View File

@ -1,7 +1,7 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@ -10,18 +10,18 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-selected data-[state=unchecked]:bg-input",
className
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
"pointer-events-none block h-5 w-5 rounded-full bg-muted-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch }
export { Switch };

View File

@ -15,6 +15,7 @@ type DraggableElementProps = {
setDraggableElementTime?: React.Dispatch<React.SetStateAction<number>>;
draggableElementTimeRef: React.MutableRefObject<HTMLDivElement | null>;
timelineDuration: number;
timelineCollapsed?: boolean;
timelineStartAligned: number;
isDragging: boolean;
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
@ -33,6 +34,7 @@ function useDraggableElement({
setDraggableElementTime,
draggableElementTimeRef,
timelineDuration,
timelineCollapsed,
timelineStartAligned,
isDragging,
setIsDragging,
@ -40,6 +42,7 @@ function useDraggableElement({
}: DraggableElementProps) {
const [clientYPosition, setClientYPosition] = useState<number | null>(null);
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
const [segments, setSegments] = useState<HTMLDivElement[]>([]);
const { alignStartDateToTimeline, getCumulativeScrollTop } = useTimelineUtils(
{
segmentDuration: segmentDuration,
@ -101,7 +104,7 @@ function useDraggableElement({
} else if (e.nativeEvent instanceof MouseEvent) {
clientY = e.nativeEvent.clientY;
}
if (clientY && draggableElementRef.current && isDesktop) {
if (clientY && draggableElementRef.current) {
const draggableElementRect =
draggableElementRef.current.getBoundingClientRect();
if (!isDragging) {
@ -203,6 +206,12 @@ function useDraggableElement({
[contentRef, draggableElementRef, timelineRef, getClientYPosition],
);
useEffect(() => {
if (timelineRef.current) {
setSegments(Array.from(timelineRef.current.querySelectorAll(".segment")));
}
}, [timelineRef, segmentDuration, timelineDuration, timelineCollapsed]);
useEffect(() => {
let animationFrameId: number | null = null;
@ -211,13 +220,11 @@ function useDraggableElement({
timelineRef.current &&
showDraggableElement &&
isDragging &&
clientYPosition
clientYPosition &&
segments
) {
const {
scrollHeight: timelineHeight,
scrollTop: scrolled,
offsetTop: timelineTop,
} = timelineRef.current;
const { scrollHeight: timelineHeight, scrollTop: scrolled } =
timelineRef.current;
const segmentHeight =
timelineHeight / (timelineDuration / segmentDuration);
@ -235,22 +242,53 @@ function useDraggableElement({
? timestampToPixels(draggableElementLatestTime)
: segmentHeight * 2 + scrolled;
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineTopAbsolute = timelineRect.top;
const newElementPosition = Math.min(
elementEarliest,
Math.max(
elementLatest,
// current Y position
clientYPosition -
timelineTop +
timelineTopAbsolute +
parentScrollTop -
initialClickAdjustment,
),
);
const segmentIndex = Math.floor(newElementPosition / segmentHeight);
const segmentStartTime = alignStartDateToTimeline(
timelineStartAligned - segmentIndex * segmentDuration,
);
if (
newElementPosition >= elementEarliest ||
newElementPosition <= elementLatest
) {
return;
}
let targetSegmentId = 0;
let offset = 0;
segments.forEach((segmentElement: HTMLDivElement) => {
const rect = segmentElement.getBoundingClientRect();
const segmentTop =
rect.top + scrolled - timelineTopAbsolute - segmentHeight;
const segmentBottom =
rect.bottom + scrolled - timelineTopAbsolute - segmentHeight;
// Check if handlebar position falls within the segment bounds
if (
newElementPosition >= segmentTop &&
newElementPosition <= segmentBottom
) {
targetSegmentId = parseFloat(
segmentElement.getAttribute("data-segment-id") || "0",
);
offset = Math.min(
segmentBottom - newElementPosition,
segmentHeight,
);
return;
}
});
if (draggingAtTopEdge || draggingAtBottomEdge) {
let newPosition = clientYPosition;
@ -267,17 +305,15 @@ function useDraggableElement({
}
updateDraggableElementPosition(
newElementPosition - segmentHeight,
segmentStartTime,
newElementPosition,
targetSegmentId,
false,
false,
);
if (setDraggableElementTime) {
setDraggableElementTime(
timelineStartAligned -
((newElementPosition - segmentHeight / 2 - 2) / segmentHeight) *
segmentDuration,
targetSegmentId + segmentDuration * (offset / segmentHeight),
);
}
@ -321,7 +357,8 @@ function useDraggableElement({
draggableElementRef.current &&
showDraggableElement &&
draggableElementTime &&
!isDragging
!isDragging &&
segments.length > 0
) {
const { scrollHeight: timelineHeight, scrollTop: scrolled } =
timelineRef.current;
@ -329,29 +366,60 @@ function useDraggableElement({
const segmentHeight =
timelineHeight / (timelineDuration / segmentDuration);
const parentScrollTop = getCumulativeScrollTop(timelineRef.current);
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
const newElementPosition =
((timelineStartAligned - draggableElementTime) / segmentDuration) *
segmentHeight +
parentScrollTop -
scrolled -
2; // height of draggableElement horizontal line
updateDraggableElementPosition(
newElementPosition,
draggableElementTime,
true,
true,
let segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${alignedSegmentTime}"]`,
);
if (!segmentElement) {
// segment not found, maybe we collapsed over a collapsible segment
let searchTime = alignedSegmentTime;
while (searchTime >= timelineStartAligned - timelineDuration) {
// Decrement currentTime by segmentDuration
searchTime -= segmentDuration;
segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${searchTime}"]`,
);
if (segmentElement) {
// segmentElement found
break;
}
}
}
if (segmentElement) {
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineTopAbsolute = timelineRect.top;
const rect = segmentElement.getBoundingClientRect();
const segmentTop =
rect.top + scrolled - timelineTopAbsolute - segmentHeight / 2;
const offset =
((draggableElementTime - alignedSegmentTime) / segmentDuration) *
segmentHeight;
const newElementPosition = segmentTop - offset;
updateDraggableElementPosition(
newElementPosition,
draggableElementTime,
true,
true,
);
}
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
draggableElementTime,
timelineDuration,
segmentDuration,
showDraggableElement,
draggableElementRef,
timelineStartAligned,
timelineRef,
timelineCollapsed,
segments,
]);
return { handleMouseDown, handleMouseUp, handleMouseMove };

View File

@ -196,6 +196,8 @@ export default function EventView({
[reviewItems],
);
const [motionOnly, setMotionOnly] = useState(false);
if (!config) {
return <ActivityIndicator />;
}
@ -253,6 +255,9 @@ export default function EventView({
reviewSummary={reviewSummary}
filter={filter}
onUpdateFilter={updateFilter}
severity={severity}
motionOnly={motionOnly}
setMotionOnly={setMotionOnly}
/>
) : (
<ReviewActionGroup
@ -290,6 +295,7 @@ export default function EventView({
timeRange={timeRange}
startTime={startTime}
filter={filter}
motionOnly={motionOnly}
onOpenRecording={onOpenRecording}
/>
)}
@ -603,6 +609,7 @@ type MotionReviewProps = {
timeRange: { before: number; after: number };
startTime?: number;
filter?: ReviewFilter;
motionOnly?: boolean;
onOpenRecording: (data: RecordingStartingPoint) => void;
};
function MotionReview({
@ -612,6 +619,7 @@ function MotionReview({
timeRange,
startTime,
filter,
motionOnly = false,
onOpenRecording,
}: MotionReviewProps) {
const segmentDuration = 30;
@ -784,6 +792,7 @@ function MotionReview({
timestampSpread={15}
timelineStart={timeRangeSegments.end}
timelineEnd={timeRangeSegments.start}
motionOnly={motionOnly}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}

View File

@ -55,7 +55,7 @@
--border: 214.3 31.8% 91.4%;
--input: hsl(214.3 31.8% 91.4%);
--input: 214.3 31.8% 91.4%;
--input: 0 0 85%;
--ring: hsl(222.2 84% 4.9%);
--ring: 222.2 84% 4.9%;
@ -140,7 +140,7 @@
--border: 0 0% 32%;
--input: hsl(217.2 32.6% 17.5%);
--input: 217.2 32.6% 17.5%;
--input: 0 0 25%;
--ring: hsl(212.7 26.8% 83.9%);
--ring: 212.7 26.8% 83.9%;