mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Redesign Recordings View (#10690)
* Use full width top bar * Make each item in review filter group optional * Remove export creation from export page * Consolidate packages and fix opening recording from event * Use common type for time range * Move timeline to separate component * Add events list view to recordings view * Fix loading of images * Fix incorrect labels * use overlay state for selected timeline type * Fix up for mobile view for now * replace overlay state * fix comparison * remove unused
This commit is contained in:
parent
1cd374d3ad
commit
1377d33e25
@ -265,7 +265,14 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
self.frame_manager.close(frame_id)
|
self.frame_manager.close(frame_id)
|
||||||
elif len(motion) >= 20:
|
elif len(motion) >= 20:
|
||||||
self.active_review_segments[camera] = PendingReviewSegment(
|
self.active_review_segments[camera] = PendingReviewSegment(
|
||||||
camera, frame_time, SeverityEnum.signification_motion, motion=motion
|
camera,
|
||||||
|
frame_time,
|
||||||
|
SeverityEnum.signification_motion,
|
||||||
|
detections=set(),
|
||||||
|
objects=set(),
|
||||||
|
sub_labels=set(),
|
||||||
|
motion=motion,
|
||||||
|
zones=set(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
@ -8,11 +8,12 @@ import { ReviewSegment } from "@/types/review";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
import { RecordingStartingPoint } from "@/types/record";
|
import { RecordingStartingPoint } from "@/types/record";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
type AnimatedEventThumbnailProps = {
|
type AnimatedEventCardProps = {
|
||||||
event: ReviewSegment;
|
event: ReviewSegment;
|
||||||
};
|
};
|
||||||
export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
|
export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
// interaction
|
// interaction
|
||||||
@ -21,11 +22,15 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
|
|||||||
const onOpenReview = useCallback(() => {
|
const onOpenReview = useCallback(() => {
|
||||||
navigate("events", {
|
navigate("events", {
|
||||||
state: {
|
state: {
|
||||||
camera: event.camera,
|
|
||||||
startTime: event.start_time,
|
|
||||||
severity: event.severity,
|
severity: event.severity,
|
||||||
} as RecordingStartingPoint,
|
recording: {
|
||||||
|
camera: event.camera,
|
||||||
|
startTime: event.start_time,
|
||||||
|
severity: event.severity,
|
||||||
|
} as RecordingStartingPoint,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
axios.post(`reviews/viewed`, { ids: [event.id] });
|
||||||
}, [navigate, event]);
|
}, [navigate, event]);
|
||||||
|
|
||||||
// image behavior
|
// image behavior
|
73
web/src/components/card/ReviewCard.tsx
Normal file
73
web/src/components/card/ReviewCard.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { ReviewSegment } from "@/types/review";
|
||||||
|
import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil";
|
||||||
|
import { isSafari } from "react-device-detect";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||||
|
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||||
|
|
||||||
|
type ReviewCardProps = {
|
||||||
|
event: ReviewSegment;
|
||||||
|
currentTime: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
export default function ReviewCard({
|
||||||
|
event,
|
||||||
|
currentTime,
|
||||||
|
onClick,
|
||||||
|
}: ReviewCardProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||||
|
const formattedDate = useFormattedTimestamp(
|
||||||
|
event.start_time,
|
||||||
|
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
|
||||||
|
);
|
||||||
|
const isSelected = useMemo(
|
||||||
|
() => event.start_time <= currentTime && event.end_time >= currentTime,
|
||||||
|
[event, currentTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex flex-col gap-1.5 cursor-pointer"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<ImageLoadingIndicator
|
||||||
|
className="size-full aspect-video"
|
||||||
|
imgLoaded={imgLoaded}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
className={`size-full rounded-lg ${isSelected ? "outline outline-3 outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`}
|
||||||
|
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
|
||||||
|
loading={isSafari ? "eager" : "lazy"}
|
||||||
|
onLoad={() => {
|
||||||
|
onImgLoad();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex justify-evenly items-center gap-1">
|
||||||
|
{event.data.objects.map((object) => {
|
||||||
|
return getIconForLabel(object, "size-3 text-white");
|
||||||
|
})}
|
||||||
|
{event.data.audio.map((audio) => {
|
||||||
|
return getIconForLabel(audio, "size-3 text-white");
|
||||||
|
})}
|
||||||
|
{event.data.sub_labels?.map((sub) => {
|
||||||
|
return getIconForSubLabel(sub, "size-3 text-white");
|
||||||
|
})}
|
||||||
|
<div className="font-extra-light text-xs">{formattedDate}</div>
|
||||||
|
</div>
|
||||||
|
<TimeAgo
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
time={event.start_time * 1000}
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -22,7 +22,7 @@ export default function NewReviewData({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return reviewItems.length != itemsToReview;
|
return reviewItems.length < itemsToReview;
|
||||||
}, [reviewItems, itemsToReview]);
|
}, [reviewItems, itemsToReview]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
interface IProp {
|
interface IProp {
|
||||||
|
/** OPTIONAL: classname */
|
||||||
|
className?: string;
|
||||||
/** The time to calculate time-ago from */
|
/** The time to calculate time-ago from */
|
||||||
time: number;
|
time: number;
|
||||||
/** OPTIONAL: overwrite current time */
|
/** OPTIONAL: overwrite current time */
|
||||||
@ -73,6 +75,7 @@ const timeAgo = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TimeAgo: FunctionComponent<IProp> = ({
|
const TimeAgo: FunctionComponent<IProp> = ({
|
||||||
|
className,
|
||||||
time,
|
time,
|
||||||
manualRefreshInterval,
|
manualRefreshInterval,
|
||||||
...rest
|
...rest
|
||||||
@ -105,6 +108,6 @@ const TimeAgo: FunctionComponent<IProp> = ({
|
|||||||
[currentTime, rest, time],
|
[currentTime, rest, time],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <span>{timeAgoValue}</span>;
|
return <span className={className}>{timeAgoValue}</span>;
|
||||||
};
|
};
|
||||||
export default TimeAgo;
|
export default TimeAgo;
|
||||||
|
@ -10,7 +10,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
|
import { ReviewFilter, ReviewSummary } from "@/types/review";
|
||||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
import { FaCalendarAlt, FaFilter, FaRunning, FaVideo } from "react-icons/fa";
|
import { FaCalendarAlt, FaFilter, FaRunning, FaVideo } from "react-icons/fa";
|
||||||
@ -22,21 +22,29 @@ import FilterCheckBox from "./FilterCheckBox";
|
|||||||
import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
|
import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
|
||||||
|
|
||||||
const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
|
const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
|
||||||
|
const REVIEW_FILTERS = ["cameras", "date", "general", "motionOnly"] as const;
|
||||||
|
type ReviewFilters = (typeof REVIEW_FILTERS)[number];
|
||||||
|
const DEFAULT_REVIEW_FILTERS: ReviewFilters[] = [
|
||||||
|
"cameras",
|
||||||
|
"date",
|
||||||
|
"general",
|
||||||
|
"motionOnly",
|
||||||
|
];
|
||||||
|
|
||||||
type ReviewFilterGroupProps = {
|
type ReviewFilterGroupProps = {
|
||||||
|
filters?: ReviewFilters[];
|
||||||
reviewSummary?: ReviewSummary;
|
reviewSummary?: ReviewSummary;
|
||||||
filter?: ReviewFilter;
|
filter?: ReviewFilter;
|
||||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||||
severity: ReviewSeverity;
|
|
||||||
motionOnly: boolean;
|
motionOnly: boolean;
|
||||||
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReviewFilterGroup({
|
export default function ReviewFilterGroup({
|
||||||
|
filters = DEFAULT_REVIEW_FILTERS,
|
||||||
reviewSummary,
|
reviewSummary,
|
||||||
filter,
|
filter,
|
||||||
onUpdateFilter,
|
onUpdateFilter,
|
||||||
severity,
|
|
||||||
motionOnly,
|
motionOnly,
|
||||||
setMotionOnly,
|
setMotionOnly,
|
||||||
}: ReviewFilterGroupProps) {
|
}: ReviewFilterGroupProps) {
|
||||||
@ -101,27 +109,34 @@ export default function ReviewFilterGroup({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<CamerasFilterButton
|
{filters.includes("cameras") && (
|
||||||
allCameras={filterValues.cameras}
|
<CamerasFilterButton
|
||||||
groups={groups}
|
allCameras={filterValues.cameras}
|
||||||
selectedCameras={filter?.cameras}
|
groups={groups}
|
||||||
updateCameraFilter={(newCameras) => {
|
selectedCameras={filter?.cameras}
|
||||||
onUpdateFilter({ ...filter, cameras: newCameras });
|
updateCameraFilter={(newCameras) => {
|
||||||
}}
|
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||||
/>
|
}}
|
||||||
<CalendarFilterButton
|
/>
|
||||||
reviewSummary={reviewSummary}
|
)}
|
||||||
day={
|
{filters.includes("date") && (
|
||||||
filter?.after == undefined ? undefined : new Date(filter.after * 1000)
|
<CalendarFilterButton
|
||||||
}
|
reviewSummary={reviewSummary}
|
||||||
updateSelectedDay={onUpdateSelectedDay}
|
day={
|
||||||
/>
|
filter?.after == undefined
|
||||||
{severity == "significant_motion" ? (
|
? undefined
|
||||||
|
: new Date(filter.after * 1000)
|
||||||
|
}
|
||||||
|
updateSelectedDay={onUpdateSelectedDay}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filters.includes("motionOnly") && (
|
||||||
<ShowMotionOnlyButton
|
<ShowMotionOnlyButton
|
||||||
motionOnly={motionOnly}
|
motionOnly={motionOnly}
|
||||||
setMotionOnly={setMotionOnly}
|
setMotionOnly={setMotionOnly}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
{filters.includes("general") && (
|
||||||
<GeneralFilterButton
|
<GeneralFilterButton
|
||||||
allLabels={filterValues.labels}
|
allLabels={filterValues.labels}
|
||||||
selectedLabels={filter?.labels}
|
selectedLabels={filter?.labels}
|
||||||
@ -293,7 +308,7 @@ type CalendarFilterButtonProps = {
|
|||||||
day?: Date;
|
day?: Date;
|
||||||
updateSelectedDay: (day?: Date) => void;
|
updateSelectedDay: (day?: Date) => void;
|
||||||
};
|
};
|
||||||
export function CalendarFilterButton({
|
function CalendarFilterButton({
|
||||||
reviewSummary,
|
reviewSummary,
|
||||||
day,
|
day,
|
||||||
updateSelectedDay,
|
updateSelectedDay,
|
||||||
|
20
web/src/components/indicators/ImageLoadingIndicator.tsx
Normal file
20
web/src/components/indicators/ImageLoadingIndicator.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { isSafari } from "react-device-detect";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
|
export default function ImageLoadingIndicator({
|
||||||
|
className,
|
||||||
|
imgLoaded,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
imgLoaded: boolean;
|
||||||
|
}) {
|
||||||
|
if (imgLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSafari ? (
|
||||||
|
<div className={`bg-gray-300 pointer-events-none ${className ?? ""}`} />
|
||||||
|
) : (
|
||||||
|
<Skeleton className={`pointer-events-none ${className ?? ""}`} />
|
||||||
|
);
|
||||||
|
}
|
@ -14,11 +14,12 @@ import { isCurrentHour } from "@/utils/dateUtil";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { isAndroid, isChrome, isMobile, isSafari } from "react-device-detect";
|
import { isAndroid, isChrome, isMobile, isSafari } from "react-device-detect";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
import { TimeRange } from "@/types/timeline";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
camera: string;
|
camera: string;
|
||||||
timeRange: { start: number; end: number };
|
timeRange: TimeRange;
|
||||||
cameraPreviews: Preview[];
|
cameraPreviews: Preview[];
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
isScrubbing: boolean;
|
isScrubbing: boolean;
|
||||||
@ -37,7 +38,7 @@ export default function PreviewPlayer({
|
|||||||
}: PreviewPlayerProps) {
|
}: PreviewPlayerProps) {
|
||||||
const [currentHourFrame, setCurrentHourFrame] = useState<string>();
|
const [currentHourFrame, setCurrentHourFrame] = useState<string>();
|
||||||
|
|
||||||
if (isCurrentHour(timeRange.end)) {
|
if (isCurrentHour(timeRange.before)) {
|
||||||
return (
|
return (
|
||||||
<PreviewFramesPlayer
|
<PreviewFramesPlayer
|
||||||
className={className}
|
className={className}
|
||||||
@ -84,7 +85,7 @@ export abstract class PreviewController {
|
|||||||
type PreviewVideoPlayerProps = {
|
type PreviewVideoPlayerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
camera: string;
|
camera: string;
|
||||||
timeRange: { start: number; end: number };
|
timeRange: TimeRange;
|
||||||
cameraPreviews: Preview[];
|
cameraPreviews: Preview[];
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
isScrubbing: boolean;
|
isScrubbing: boolean;
|
||||||
@ -148,8 +149,8 @@ function PreviewVideoPlayer({
|
|||||||
return cameraPreviews.find(
|
return cameraPreviews.find(
|
||||||
(preview) =>
|
(preview) =>
|
||||||
preview.camera == camera &&
|
preview.camera == camera &&
|
||||||
Math.round(preview.start) >= timeRange.start &&
|
Math.round(preview.start) >= timeRange.after &&
|
||||||
Math.floor(preview.end) <= timeRange.end,
|
Math.floor(preview.end) <= timeRange.before,
|
||||||
);
|
);
|
||||||
|
|
||||||
// we only want to calculate this once
|
// we only want to calculate this once
|
||||||
@ -179,8 +180,8 @@ function PreviewVideoPlayer({
|
|||||||
const preview = cameraPreviews.find(
|
const preview = cameraPreviews.find(
|
||||||
(preview) =>
|
(preview) =>
|
||||||
preview.camera == camera &&
|
preview.camera == camera &&
|
||||||
Math.round(preview.start) >= timeRange.start &&
|
Math.round(preview.start) >= timeRange.after &&
|
||||||
Math.floor(preview.end) <= timeRange.end,
|
Math.floor(preview.end) <= timeRange.before,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (preview != currentPreview) {
|
if (preview != currentPreview) {
|
||||||
@ -292,7 +293,7 @@ function PreviewVideoPlayer({
|
|||||||
class PreviewVideoController extends PreviewController {
|
class PreviewVideoController extends PreviewController {
|
||||||
// main state
|
// main state
|
||||||
private previewRef: MutableRefObject<HTMLVideoElement | null>;
|
private previewRef: MutableRefObject<HTMLVideoElement | null>;
|
||||||
private timeRange: { start: number; end: number } | undefined = undefined;
|
private timeRange: TimeRange | undefined = undefined;
|
||||||
|
|
||||||
// preview
|
// preview
|
||||||
private preview: Preview | undefined = undefined;
|
private preview: Preview | undefined = undefined;
|
||||||
@ -377,7 +378,7 @@ class PreviewVideoController extends PreviewController {
|
|||||||
type PreviewFramesPlayerProps = {
|
type PreviewFramesPlayerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
camera: string;
|
camera: string;
|
||||||
timeRange: { start: number; end: number };
|
timeRange: TimeRange;
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
onControllerReady: (controller: PreviewController) => void;
|
onControllerReady: (controller: PreviewController) => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@ -395,8 +396,8 @@ function PreviewFramesPlayer({
|
|||||||
// frames data
|
// frames data
|
||||||
|
|
||||||
const { data: previewFrames } = useSWR<string[]>(
|
const { data: previewFrames } = useSWR<string[]>(
|
||||||
`preview/${camera}/start/${Math.floor(timeRange.start)}/end/${Math.ceil(
|
`preview/${camera}/start/${Math.floor(timeRange.after)}/end/${Math.ceil(
|
||||||
timeRange.end,
|
timeRange.before,
|
||||||
)}/frames`,
|
)}/frames`,
|
||||||
{ revalidateOnFocus: false },
|
{ revalidateOnFocus: false },
|
||||||
);
|
);
|
||||||
@ -457,7 +458,7 @@ function PreviewFramesPlayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!startTime) {
|
if (!startTime) {
|
||||||
controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.start);
|
controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.after);
|
||||||
} else {
|
} else {
|
||||||
controller.scrubToTimestamp(startTime);
|
controller.scrubToTimestamp(startTime);
|
||||||
}
|
}
|
||||||
|
@ -17,9 +17,9 @@ import { isFirefox, isMobile, isSafari } from "react-device-detect";
|
|||||||
import Chip from "@/components/indicators/Chip";
|
import Chip from "@/components/indicators/Chip";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
|
||||||
import { useSwipeable } from "react-swipeable";
|
import { useSwipeable } from "react-swipeable";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
@ -187,11 +187,14 @@ export default function PreviewThumbnailPlayer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PreviewPlaceholder imgLoaded={imgLoaded} />
|
<ImageLoadingIndicator
|
||||||
|
className="absolute inset-0"
|
||||||
|
imgLoaded={imgLoaded}
|
||||||
|
/>
|
||||||
<div className={`${imgLoaded ? "visible" : "invisible"}`}>
|
<div className={`${imgLoaded ? "visible" : "invisible"}`}>
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
className={`w-full h-full transition-opacity ${
|
className={`size-full transition-opacity ${
|
||||||
playingBack ? "opacity-0" : "opacity-100"
|
playingBack ? "opacity-0" : "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
||||||
@ -700,15 +703,3 @@ function InProgressPreview({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewPlaceholder({ imgLoaded }: { imgLoaded: boolean }) {
|
|
||||||
if (imgLoaded) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isSafari ? (
|
|
||||||
<div className={`absolute inset-0 bg-gray-300 pointer-events-none`} />
|
|
||||||
) : (
|
|
||||||
<Skeleton className={`absolute inset-0 pointer-events-none`} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -8,7 +8,7 @@ import { Preview } from "@/types/preview";
|
|||||||
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
||||||
import { DynamicVideoController } from "./DynamicVideoController";
|
import { DynamicVideoController } from "./DynamicVideoController";
|
||||||
import HlsVideoPlayer from "../HlsVideoPlayer";
|
import HlsVideoPlayer from "../HlsVideoPlayer";
|
||||||
import { Timeline } from "@/types/timeline";
|
import { TimeRange, Timeline } from "@/types/timeline";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamically switches between video playback and scrubbing preview player.
|
* Dynamically switches between video playback and scrubbing preview player.
|
||||||
@ -16,7 +16,7 @@ import { Timeline } from "@/types/timeline";
|
|||||||
type DynamicVideoPlayerProps = {
|
type DynamicVideoPlayerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
camera: string;
|
camera: string;
|
||||||
timeRange: { start: number; end: number };
|
timeRange: TimeRange;
|
||||||
cameraPreviews: Preview[];
|
cameraPreviews: Preview[];
|
||||||
startTimestamp?: number;
|
startTimestamp?: number;
|
||||||
isScrubbing: boolean;
|
isScrubbing: boolean;
|
||||||
@ -100,7 +100,7 @@ export default function DynamicVideoPlayer({
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [source, setSource] = useState(
|
const [source, setSource] = useState(
|
||||||
`${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
|
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// start at correct time
|
// start at correct time
|
||||||
@ -134,8 +134,8 @@ export default function DynamicVideoPlayer({
|
|||||||
|
|
||||||
const recordingParams = useMemo(() => {
|
const recordingParams = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
before: timeRange.end,
|
before: timeRange.before,
|
||||||
after: timeRange.start,
|
after: timeRange.after,
|
||||||
};
|
};
|
||||||
}, [timeRange]);
|
}, [timeRange]);
|
||||||
const { data: recordings } = useSWR<Recording[]>(
|
const { data: recordings } = useSWR<Recording[]>(
|
||||||
@ -153,7 +153,7 @@ export default function DynamicVideoPlayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSource(
|
setSource(
|
||||||
`${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
|
`${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`,
|
||||||
);
|
);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
@ -10,27 +10,9 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
|
||||||
import {
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { format } from "date-fns";
|
import { useCallback, useState } from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { DateRange } from "react-day-picker";
|
|
||||||
import { isDesktop } from "react-device-detect";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
type ExportItem = {
|
type ExportItem = {
|
||||||
@ -38,96 +20,13 @@ type ExportItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Export() {
|
function Export() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
|
||||||
const { data: exports, mutate } = useSWR<ExportItem[]>(
|
const { data: exports, mutate } = useSWR<ExportItem[]>(
|
||||||
"exports/",
|
"exports/",
|
||||||
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
|
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
|
||||||
);
|
);
|
||||||
const location = useLocation();
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
// Export States
|
|
||||||
const [camera, setCamera] = useState<string | undefined>();
|
|
||||||
const [playback, setPlayback] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const currentDate = new Date();
|
|
||||||
currentDate.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const [date, setDate] = useState<DateRange | undefined>({
|
|
||||||
from: currentDate,
|
|
||||||
});
|
|
||||||
const [startTime, setStartTime] = useState("00:00:00");
|
|
||||||
const [endTime, setEndTime] = useState("23:59:59");
|
|
||||||
|
|
||||||
const [deleteClip, setDeleteClip] = useState<string | undefined>();
|
const [deleteClip, setDeleteClip] = useState<string | undefined>();
|
||||||
|
|
||||||
const onHandleExport = () => {
|
|
||||||
if (!camera) {
|
|
||||||
toast.error("A camera needs to be selected.", { position: "top-center" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!playback) {
|
|
||||||
toast.error("A playback factor needs to be selected.", {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!date?.from || !startTime || !endTime) {
|
|
||||||
toast.error("A start and end time needs to be selected", {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = new Date(date.from.getTime());
|
|
||||||
const [startHour, startMin, startSec] = startTime.split(":");
|
|
||||||
startDate.setHours(
|
|
||||||
parseInt(startHour),
|
|
||||||
parseInt(startMin),
|
|
||||||
parseInt(startSec),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const start = startDate.getTime() / 1000;
|
|
||||||
const endDate = new Date((date.to || date.from).getTime());
|
|
||||||
const [endHour, endMin, endSec] = endTime.split(":");
|
|
||||||
endDate.setHours(parseInt(endHour), parseInt(endMin), parseInt(endSec), 0);
|
|
||||||
const end = endDate.getTime() / 1000;
|
|
||||||
|
|
||||||
if (end <= start) {
|
|
||||||
toast.error("The end time must be after the start time.", {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
axios
|
|
||||||
.post(`export/${camera}/start/${start}/end/${end}`, { playback })
|
|
||||||
.then((response) => {
|
|
||||||
if (response.status == 200) {
|
|
||||||
toast.success(
|
|
||||||
"Successfully started export. View the file in the /exports folder.",
|
|
||||||
{ position: "top-center" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
mutate();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (error.response?.data?.message) {
|
|
||||||
toast.error(
|
|
||||||
`Failed to start export: ${error.response.data.message}`,
|
|
||||||
{ position: "top-center" },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.error(`Failed to start export: ${error.message}`, {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onHandleDelete = useCallback(() => {
|
const onHandleDelete = useCallback(() => {
|
||||||
if (!deleteClip) {
|
if (!deleteClip) {
|
||||||
return;
|
return;
|
||||||
@ -141,27 +40,6 @@ function Export() {
|
|||||||
});
|
});
|
||||||
}, [deleteClip, mutate]);
|
}, [deleteClip, mutate]);
|
||||||
|
|
||||||
const Create = isDesktop ? Dialog : Drawer;
|
|
||||||
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
|
|
||||||
const Content = isDesktop ? DialogContent : DrawerContent;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (location.state && location.state.start && location.state.end) {
|
|
||||||
const startTimeString = format(
|
|
||||||
new Date(location.state.start * 1000),
|
|
||||||
"HH:mm:ss",
|
|
||||||
);
|
|
||||||
const endTimeString = format(
|
|
||||||
new Date(location.state.end * 1000),
|
|
||||||
"HH:mm:ss",
|
|
||||||
);
|
|
||||||
setStartTime(startTimeString);
|
|
||||||
setEndTime(endTimeString);
|
|
||||||
|
|
||||||
setDialogOpen(true);
|
|
||||||
}
|
|
||||||
}, [location.state]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full p-2 overflow-hidden flex flex-col">
|
<div className="size-full p-2 overflow-hidden flex flex-col">
|
||||||
<Toaster />
|
<Toaster />
|
||||||
@ -186,102 +64,6 @@ function Export() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<div className="w-full h-14">
|
|
||||||
<Create open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<Trigger>
|
|
||||||
<Button variant="select">New Export</Button>
|
|
||||||
</Trigger>
|
|
||||||
<Content className="flex flex-col justify-center items-center">
|
|
||||||
<div className="w-full flex justify-evenly items-center mt-4 md:mt-0">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button className="capitalize" variant="secondary">
|
|
||||||
{camera?.replaceAll("_", " ") || "Select Camera"}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuLabel className="flex justify-center items-center">
|
|
||||||
Select Camera
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={camera}
|
|
||||||
onValueChange={setCamera}
|
|
||||||
>
|
|
||||||
{Object.keys(config?.cameras || {}).map((item) => (
|
|
||||||
<DropdownMenuRadioItem
|
|
||||||
className="capitalize"
|
|
||||||
key={item}
|
|
||||||
value={item}
|
|
||||||
>
|
|
||||||
{item.replaceAll("_", " ")}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button className="capitalize" variant="secondary">
|
|
||||||
{playback?.split("_")[0] || "Select Playback"}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuLabel className="flex justify-center items-center">
|
|
||||||
Select Playback
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={playback}
|
|
||||||
onValueChange={setPlayback}
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem value="realtime">
|
|
||||||
Realtime
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="timelapse_25x">
|
|
||||||
Timelapse
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<Calendar mode="range" selected={date} onSelect={setDate} />
|
|
||||||
<div className="w-full flex justify-evenly">
|
|
||||||
<input
|
|
||||||
className="w-36 p-1 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
|
||||||
id="startTime"
|
|
||||||
type="time"
|
|
||||||
value={startTime}
|
|
||||||
step="1"
|
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="w-36 p-1 mx-2 border border-input bg-background text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
|
||||||
id="endTime"
|
|
||||||
type="time"
|
|
||||||
value={endTime}
|
|
||||||
step="1"
|
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex items-center justify-between px-4">
|
|
||||||
{`${
|
|
||||||
date?.from ? format(date?.from, "LLL dd, y") : ""
|
|
||||||
} ${startTime} -> ${
|
|
||||||
date?.to ? format(date?.to, "LLL dd, y") : ""
|
|
||||||
} ${endTime}`}
|
|
||||||
<Button
|
|
||||||
className="my-4"
|
|
||||||
variant="select"
|
|
||||||
onClick={() => onHandleExport()}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Content>
|
|
||||||
</Create>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
{exports && (
|
{exports && (
|
||||||
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">
|
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Preview } from "./preview";
|
import { Preview } from "./preview";
|
||||||
import { Recording } from "./record";
|
import { Recording } from "./record";
|
||||||
|
import { TimeRange } from "./timeline";
|
||||||
|
|
||||||
export type DynamicPlayback = {
|
export type DynamicPlayback = {
|
||||||
recordings: Recording[];
|
recordings: Recording[];
|
||||||
@ -7,5 +8,5 @@ export type DynamicPlayback = {
|
|||||||
|
|
||||||
export type PreviewPlayback = {
|
export type PreviewPlayback = {
|
||||||
preview: Preview | undefined;
|
preview: Preview | undefined;
|
||||||
timeRange: { end: number; start: number };
|
timeRange: TimeRange;
|
||||||
};
|
};
|
||||||
|
@ -21,7 +21,7 @@ import {
|
|||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import { FaBicycle } from "react-icons/fa";
|
import { FaBicycle } from "react-icons/fa";
|
||||||
import { endOfHourOrCurrentTime } from "./dateUtil";
|
import { endOfHourOrCurrentTime } from "./dateUtil";
|
||||||
import { Timeline } from "@/types/timeline";
|
import { TimeRange, Timeline } from "@/types/timeline";
|
||||||
|
|
||||||
export function getTimelineIcon(timelineItem: Timeline) {
|
export function getTimelineIcon(timelineItem: Timeline) {
|
||||||
switch (timelineItem.class_type) {
|
switch (timelineItem.class_type) {
|
||||||
@ -124,7 +124,7 @@ export function getTimelineItemDescription(timelineItem: Timeline) {
|
|||||||
export function getChunkedTimeDay(timestamp: number) {
|
export function getChunkedTimeDay(timestamp: number) {
|
||||||
const endOfThisHour = new Date();
|
const endOfThisHour = new Date();
|
||||||
endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
|
endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
|
||||||
const data: { start: number; end: number }[] = [];
|
const data: TimeRange[] = [];
|
||||||
const startDay = new Date(timestamp * 1000);
|
const startDay = new Date(timestamp * 1000);
|
||||||
startDay.setHours(0, 0, 0, 0);
|
startDay.setHours(0, 0, 0, 0);
|
||||||
const startTimestamp = startDay.getTime() / 1000;
|
const startTimestamp = startDay.getTime() / 1000;
|
||||||
@ -140,8 +140,8 @@ export function getChunkedTimeDay(timestamp: number) {
|
|||||||
|
|
||||||
end = endOfHourOrCurrentTime(startDay.getTime() / 1000);
|
end = endOfHourOrCurrentTime(startDay.getTime() / 1000);
|
||||||
data.push({
|
data.push({
|
||||||
start,
|
after: start,
|
||||||
end,
|
before: end,
|
||||||
});
|
});
|
||||||
start = startDay.getTime() / 1000;
|
start = startDay.getTime() / 1000;
|
||||||
}
|
}
|
||||||
@ -155,7 +155,7 @@ export function getChunkedTimeRange(
|
|||||||
) {
|
) {
|
||||||
const endOfThisHour = new Date();
|
const endOfThisHour = new Date();
|
||||||
endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
|
endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
|
||||||
const data: { start: number; end: number }[] = [];
|
const data: TimeRange[] = [];
|
||||||
const startDay = new Date(startTimestamp * 1000);
|
const startDay = new Date(startTimestamp * 1000);
|
||||||
startDay.setMinutes(0, 0, 0);
|
startDay.setMinutes(0, 0, 0);
|
||||||
let start = startDay.getTime() / 1000;
|
let start = startDay.getTime() / 1000;
|
||||||
@ -170,8 +170,8 @@ export function getChunkedTimeRange(
|
|||||||
|
|
||||||
end = endOfHourOrCurrentTime(startDay.getTime() / 1000);
|
end = endOfHourOrCurrentTime(startDay.getTime() / 1000);
|
||||||
data.push({
|
data.push({
|
||||||
start,
|
after: start,
|
||||||
end,
|
before: end,
|
||||||
});
|
});
|
||||||
start = startDay.getTime() / 1000;
|
start = startDay.getTime() / 1000;
|
||||||
}
|
}
|
||||||
|
@ -254,10 +254,14 @@ export default function EventView({
|
|||||||
|
|
||||||
{selectedReviews.length <= 0 ? (
|
{selectedReviews.length <= 0 ? (
|
||||||
<ReviewFilterGroup
|
<ReviewFilterGroup
|
||||||
|
filters={
|
||||||
|
severity == "significant_motion"
|
||||||
|
? ["cameras", "date", "motionOnly"]
|
||||||
|
: ["cameras", "date", "general"]
|
||||||
|
}
|
||||||
reviewSummary={reviewSummary}
|
reviewSummary={reviewSummary}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onUpdateFilter={updateFilter}
|
onUpdateFilter={updateFilter}
|
||||||
severity={severity}
|
|
||||||
motionOnly={motionOnly}
|
motionOnly={motionOnly}
|
||||||
setMotionOnly={setMotionOnly}
|
setMotionOnly={setMotionOnly}
|
||||||
/>
|
/>
|
||||||
@ -667,7 +671,7 @@ function MotionReview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return timeRangeSegments.ranges.findIndex(
|
return timeRangeSegments.ranges.findIndex(
|
||||||
(seg) => seg.start <= startTime && seg.end >= startTime,
|
(seg) => seg.after <= startTime && seg.before >= startTime,
|
||||||
);
|
);
|
||||||
// only render once
|
// only render once
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -675,7 +679,7 @@ function MotionReview({
|
|||||||
|
|
||||||
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
|
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
|
||||||
const [currentTime, setCurrentTime] = useState<number>(
|
const [currentTime, setCurrentTime] = useState<number>(
|
||||||
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.end,
|
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.before,
|
||||||
);
|
);
|
||||||
const currentTimeRange = useMemo(
|
const currentTimeRange = useMemo(
|
||||||
() => timeRangeSegments.ranges[selectedRangeIdx],
|
() => timeRangeSegments.ranges[selectedRangeIdx],
|
||||||
@ -689,11 +693,11 @@ function MotionReview({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
currentTime > currentTimeRange.end + 60 ||
|
currentTime > currentTimeRange.before + 60 ||
|
||||||
currentTime < currentTimeRange.start - 60
|
currentTime < currentTimeRange.after - 60
|
||||||
) {
|
) {
|
||||||
const index = timeRangeSegments.ranges.findIndex(
|
const index = timeRangeSegments.ranges.findIndex(
|
||||||
(seg) => seg.start <= currentTime && seg.end >= currentTime,
|
(seg) => seg.after <= currentTime && seg.before >= currentTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
import ReviewCard from "@/components/card/ReviewCard";
|
||||||
import FilterCheckBox from "@/components/filter/FilterCheckBox";
|
import FilterCheckBox from "@/components/filter/FilterCheckBox";
|
||||||
import { CalendarFilterButton } from "@/components/filter/ReviewFilterGroup";
|
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
||||||
import PreviewPlayer, {
|
import PreviewPlayer, {
|
||||||
PreviewController,
|
PreviewController,
|
||||||
} from "@/components/player/PreviewPlayer";
|
} from "@/components/player/PreviewPlayer";
|
||||||
@ -8,6 +9,8 @@ import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
|
|||||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import {
|
import {
|
||||||
@ -16,9 +19,15 @@ import {
|
|||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
ReviewSummary,
|
ReviewSummary,
|
||||||
} from "@/types/review";
|
} from "@/types/review";
|
||||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
|
||||||
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
MutableRefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { FaCircle, FaVideo } from "react-icons/fa";
|
import { FaCircle, FaVideo } from "react-icons/fa";
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
@ -26,6 +35,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
const SEGMENT_DURATION = 30;
|
const SEGMENT_DURATION = 30;
|
||||||
|
type TimelineType = "timeline" | "events";
|
||||||
|
|
||||||
type RecordingViewProps = {
|
type RecordingViewProps = {
|
||||||
startCamera: string;
|
startCamera: string;
|
||||||
@ -64,12 +74,17 @@ export function RecordingView({
|
|||||||
[reviewItems, mainCamera],
|
[reviewItems, mainCamera],
|
||||||
);
|
);
|
||||||
|
|
||||||
// timeline time
|
// timeline
|
||||||
|
|
||||||
|
const [timelineType, setTimelineType] = useOverlayState<TimelineType>(
|
||||||
|
"timelineType",
|
||||||
|
"timeline",
|
||||||
|
);
|
||||||
|
|
||||||
const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]);
|
const timeRange = useMemo(() => getChunkedTimeDay(startTime), [startTime]);
|
||||||
const [selectedRangeIdx, setSelectedRangeIdx] = useState(
|
const [selectedRangeIdx, setSelectedRangeIdx] = useState(
|
||||||
timeRange.ranges.findIndex((chunk) => {
|
timeRange.ranges.findIndex((chunk) => {
|
||||||
return chunk.start <= startTime && chunk.end >= startTime;
|
return chunk.after <= startTime && chunk.before >= startTime;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const currentTimeRange = useMemo(
|
const currentTimeRange = useMemo(
|
||||||
@ -98,7 +113,7 @@ export function RecordingView({
|
|||||||
const updateSelectedSegment = useCallback(
|
const updateSelectedSegment = useCallback(
|
||||||
(currentTime: number, updateStartTime: boolean) => {
|
(currentTime: number, updateStartTime: boolean) => {
|
||||||
const index = timeRange.ranges.findIndex(
|
const index = timeRange.ranges.findIndex(
|
||||||
(seg) => seg.start <= currentTime && seg.end >= currentTime,
|
(seg) => seg.after <= currentTime && seg.before >= currentTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
@ -115,8 +130,8 @@ export function RecordingView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrubbing) {
|
if (scrubbing) {
|
||||||
if (
|
if (
|
||||||
currentTime > currentTimeRange.end + 60 ||
|
currentTime > currentTimeRange.before + 60 ||
|
||||||
currentTime < currentTimeRange.start - 60
|
currentTime < currentTimeRange.after - 60
|
||||||
) {
|
) {
|
||||||
updateSelectedSegment(currentTime, false);
|
updateSelectedSegment(currentTime, false);
|
||||||
return;
|
return;
|
||||||
@ -140,8 +155,8 @@ export function RecordingView({
|
|||||||
if (!scrubbing) {
|
if (!scrubbing) {
|
||||||
if (Math.abs(currentTime - playerTime) > 10) {
|
if (Math.abs(currentTime - playerTime) > 10) {
|
||||||
if (
|
if (
|
||||||
currentTimeRange.start <= currentTime &&
|
currentTimeRange.after <= currentTime &&
|
||||||
currentTimeRange.end >= currentTime
|
currentTimeRange.before >= currentTime
|
||||||
) {
|
) {
|
||||||
mainControllerRef.current?.seekToTimestamp(currentTime, true);
|
mainControllerRef.current?.seekToTimestamp(currentTime, true);
|
||||||
} else {
|
} else {
|
||||||
@ -165,16 +180,6 @@ export function RecordingView({
|
|||||||
|
|
||||||
// motion timeline data
|
// motion timeline data
|
||||||
|
|
||||||
const { data: motionData } = useSWR<MotionData[]>([
|
|
||||||
"review/activity/motion",
|
|
||||||
{
|
|
||||||
before: timeRange.end,
|
|
||||||
after: timeRange.start,
|
|
||||||
scale: SEGMENT_DURATION / 2,
|
|
||||||
cameras: mainCamera,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mainCameraAspect = useMemo(() => {
|
const mainCameraAspect = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return "normal";
|
return "normal";
|
||||||
@ -204,31 +209,13 @@ export function RecordingView({
|
|||||||
}, [mainCameraAspect]);
|
}, [mainCameraAspect]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="relative size-full">
|
<div ref={contentRef} className="size-full flex flex-col">
|
||||||
<div
|
<div className={`w-full h-10 flex items-center justify-between pr-1`}>
|
||||||
className={`absolute left-0 top-0 mr-2 flex items-center justify-between ${isMobile ? "right-0" : "right-24"}`}
|
|
||||||
>
|
|
||||||
<Button className="rounded-lg" onClick={() => navigate(-1)}>
|
<Button className="rounded-lg" onClick={() => navigate(-1)}>
|
||||||
<IoMdArrowRoundBack className="size-5 mr-[10px]" />
|
<IoMdArrowRoundBack className="size-5 mr-[10px]" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<CalendarFilterButton
|
|
||||||
day={
|
|
||||||
filter?.after == undefined
|
|
||||||
? undefined
|
|
||||||
: new Date(filter.after * 1000)
|
|
||||||
}
|
|
||||||
reviewSummary={reviewSummary}
|
|
||||||
updateSelectedDay={(day) => {
|
|
||||||
updateFilter({
|
|
||||||
...filter,
|
|
||||||
after: day == undefined ? undefined : day.getTime() / 1000,
|
|
||||||
before:
|
|
||||||
day == undefined ? undefined : getEndOfDayTimestamp(day),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Drawer>
|
<Drawer>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
@ -258,11 +245,45 @@ export function RecordingView({
|
|||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
|
<ReviewFilterGroup
|
||||||
|
filters={["date", "general"]}
|
||||||
|
reviewSummary={reviewSummary}
|
||||||
|
filter={filter}
|
||||||
|
onUpdateFilter={updateFilter}
|
||||||
|
motionOnly={false}
|
||||||
|
setMotionOnly={() => {}}
|
||||||
|
/>
|
||||||
|
{isDesktop && (
|
||||||
|
<ToggleGroup
|
||||||
|
className="*:px-3 *:py-4 *:rounded-md"
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
value={timelineType}
|
||||||
|
onValueChange={(value: TimelineType) =>
|
||||||
|
value ? setTimelineType(value, true) : null
|
||||||
|
} // don't allow the severity to be unselected
|
||||||
|
>
|
||||||
|
<ToggleGroupItem
|
||||||
|
className={`${timelineType == "timeline" ? "" : "text-gray-500"}`}
|
||||||
|
value="timeline"
|
||||||
|
aria-label="Select timeline"
|
||||||
|
>
|
||||||
|
<div className="">Timeline</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
className={`${timelineType == "events" ? "" : "text-gray-500"}`}
|
||||||
|
value="events"
|
||||||
|
aria-label="Select events"
|
||||||
|
>
|
||||||
|
<div className="">Events</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex h-full justify-center overflow-hidden ${isDesktop ? "" : "flex-col pt-12"}`}
|
className={`flex h-full mb-2 justify-center overflow-hidden ${isDesktop ? "" : "flex-col"}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-wrap">
|
<div className="flex flex-1 flex-wrap">
|
||||||
<div
|
<div
|
||||||
@ -328,31 +349,123 @@ export function RecordingView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobile && (
|
||||||
<div
|
<ToggleGroup
|
||||||
className={
|
className="py-2 *:px-3 *:py-4 *:rounded-md"
|
||||||
isDesktop
|
type="single"
|
||||||
? "w-[100px] mt-2 overflow-y-auto no-scrollbar"
|
size="sm"
|
||||||
: "flex-grow overflow-hidden"
|
value={timelineType}
|
||||||
}
|
onValueChange={(value: TimelineType) =>
|
||||||
>
|
value ? setTimelineType(value) : null
|
||||||
<MotionReviewTimeline
|
} // don't allow the severity to be unselected
|
||||||
segmentDuration={30}
|
>
|
||||||
timestampSpread={15}
|
<ToggleGroupItem
|
||||||
timelineStart={timeRange.end}
|
className={`${timelineType == "timeline" ? "" : "text-gray-500"}`}
|
||||||
timelineEnd={timeRange.start}
|
value="timeline"
|
||||||
showHandlebar
|
aria-label="Select timeline"
|
||||||
handlebarTime={currentTime}
|
>
|
||||||
setHandlebarTime={setCurrentTime}
|
<div className="">Timeline</div>
|
||||||
onlyInitialHandlebarScroll={true}
|
</ToggleGroupItem>
|
||||||
events={mainCameraReviewItems}
|
<ToggleGroupItem
|
||||||
motion_events={motionData ?? []}
|
className={`${timelineType == "events" ? "" : "text-gray-500"}`}
|
||||||
severityType="significant_motion"
|
value="events"
|
||||||
contentRef={contentRef}
|
aria-label="Select events"
|
||||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
>
|
||||||
/>
|
<div className="">Events</div>
|
||||||
</div>
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
<Timeline
|
||||||
|
contentRef={contentRef}
|
||||||
|
mainCamera={mainCamera}
|
||||||
|
timelineType={timelineType ?? "timeline"}
|
||||||
|
timeRange={timeRange}
|
||||||
|
mainCameraReviewItems={mainCameraReviewItems}
|
||||||
|
currentTime={currentTime}
|
||||||
|
setCurrentTime={setCurrentTime}
|
||||||
|
setScrubbing={setScrubbing}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimelineProps = {
|
||||||
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
mainCamera: string;
|
||||||
|
timelineType: TimelineType;
|
||||||
|
timeRange: { start: number; end: number };
|
||||||
|
mainCameraReviewItems: ReviewSegment[];
|
||||||
|
currentTime: number;
|
||||||
|
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
function Timeline({
|
||||||
|
contentRef,
|
||||||
|
mainCamera,
|
||||||
|
timelineType,
|
||||||
|
timeRange,
|
||||||
|
mainCameraReviewItems,
|
||||||
|
currentTime,
|
||||||
|
setCurrentTime,
|
||||||
|
setScrubbing,
|
||||||
|
}: TimelineProps) {
|
||||||
|
const { data: motionData } = useSWR<MotionData[]>([
|
||||||
|
"review/activity/motion",
|
||||||
|
{
|
||||||
|
before: timeRange.end,
|
||||||
|
after: timeRange.start,
|
||||||
|
scale: SEGMENT_DURATION / 2,
|
||||||
|
cameras: mainCamera,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (timelineType == "timeline") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isDesktop
|
||||||
|
? "w-[100px] mt-2 overflow-y-auto no-scrollbar"
|
||||||
|
: "flex-grow overflow-hidden"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MotionReviewTimeline
|
||||||
|
segmentDuration={30}
|
||||||
|
timestampSpread={15}
|
||||||
|
timelineStart={timeRange.end}
|
||||||
|
timelineEnd={timeRange.start}
|
||||||
|
showHandlebar
|
||||||
|
handlebarTime={currentTime}
|
||||||
|
setHandlebarTime={setCurrentTime}
|
||||||
|
onlyInitialHandlebarScroll={true}
|
||||||
|
events={mainCameraReviewItems}
|
||||||
|
motion_events={motionData ?? []}
|
||||||
|
severityType="significant_motion"
|
||||||
|
contentRef={contentRef}
|
||||||
|
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${isDesktop ? "w-60" : "w-full"} h-full p-4 flex flex-col gap-4 bg-secondary overflow-auto`}
|
||||||
|
>
|
||||||
|
{mainCameraReviewItems.map((review) => {
|
||||||
|
if (review.severity == "significant_motion") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReviewCard
|
||||||
|
key={review.id}
|
||||||
|
event={review}
|
||||||
|
currentTime={currentTime}
|
||||||
|
onClick={() => setCurrentTime(review.start_time)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -2,7 +2,7 @@ import { useFrigateReviews } from "@/api/ws";
|
|||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector";
|
import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector";
|
||||||
import { LiveGridIcon, LiveListIcon } from "@/components/icons/LiveIcons";
|
import { LiveGridIcon, LiveListIcon } from "@/components/icons/LiveIcons";
|
||||||
import { AnimatedEventThumbnail } from "@/components/image/AnimatedEventThumbnail";
|
import { AnimatedEventCard } from "@/components/card/AnimatedEventCard";
|
||||||
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
||||||
import LivePlayer from "@/components/player/LivePlayer";
|
import LivePlayer from "@/components/player/LivePlayer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -166,7 +166,7 @@ export default function LiveDashboardView({
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{events.map((event) => {
|
{events.map((event) => {
|
||||||
return <AnimatedEventThumbnail key={event.id} event={event} />;
|
return <AnimatedEventCard key={event.id} event={event} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
Loading…
Reference in New Issue
Block a user