forked from Github/frigate
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:
@@ -8,11 +8,12 @@ import { ReviewSegment } from "@/types/review";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import axios from "axios";
|
||||
|
||||
type AnimatedEventThumbnailProps = {
|
||||
type AnimatedEventCardProps = {
|
||||
event: ReviewSegment;
|
||||
};
|
||||
export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
|
||||
export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// interaction
|
||||
@@ -21,11 +22,15 @@ export function AnimatedEventThumbnail({ event }: AnimatedEventThumbnailProps) {
|
||||
const onOpenReview = useCallback(() => {
|
||||
navigate("events", {
|
||||
state: {
|
||||
camera: event.camera,
|
||||
startTime: event.start_time,
|
||||
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]);
|
||||
|
||||
// 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 reviewItems.length != itemsToReview;
|
||||
return reviewItems.length < itemsToReview;
|
||||
}, [reviewItems, itemsToReview]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface IProp {
|
||||
/** OPTIONAL: classname */
|
||||
className?: string;
|
||||
/** The time to calculate time-ago from */
|
||||
time: number;
|
||||
/** OPTIONAL: overwrite current time */
|
||||
@@ -73,6 +75,7 @@ const timeAgo = ({
|
||||
};
|
||||
|
||||
const TimeAgo: FunctionComponent<IProp> = ({
|
||||
className,
|
||||
time,
|
||||
manualRefreshInterval,
|
||||
...rest
|
||||
@@ -105,6 +108,6 @@ const TimeAgo: FunctionComponent<IProp> = ({
|
||||
[currentTime, rest, time],
|
||||
);
|
||||
|
||||
return <span>{timeAgoValue}</span>;
|
||||
return <span className={className}>{timeAgoValue}</span>;
|
||||
};
|
||||
export default TimeAgo;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
|
||||
import { ReviewFilter, ReviewSummary } from "@/types/review";
|
||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { FaCalendarAlt, FaFilter, FaRunning, FaVideo } from "react-icons/fa";
|
||||
@@ -22,21 +22,29 @@ import FilterCheckBox from "./FilterCheckBox";
|
||||
import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
|
||||
|
||||
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 = {
|
||||
filters?: ReviewFilters[];
|
||||
reviewSummary?: ReviewSummary;
|
||||
filter?: ReviewFilter;
|
||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||
severity: ReviewSeverity;
|
||||
motionOnly: boolean;
|
||||
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function ReviewFilterGroup({
|
||||
filters = DEFAULT_REVIEW_FILTERS,
|
||||
reviewSummary,
|
||||
filter,
|
||||
onUpdateFilter,
|
||||
severity,
|
||||
motionOnly,
|
||||
setMotionOnly,
|
||||
}: ReviewFilterGroupProps) {
|
||||
@@ -101,27 +109,34 @@ export default function ReviewFilterGroup({
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<CamerasFilterButton
|
||||
allCameras={filterValues.cameras}
|
||||
groups={groups}
|
||||
selectedCameras={filter?.cameras}
|
||||
updateCameraFilter={(newCameras) => {
|
||||
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||
}}
|
||||
/>
|
||||
<CalendarFilterButton
|
||||
reviewSummary={reviewSummary}
|
||||
day={
|
||||
filter?.after == undefined ? undefined : new Date(filter.after * 1000)
|
||||
}
|
||||
updateSelectedDay={onUpdateSelectedDay}
|
||||
/>
|
||||
{severity == "significant_motion" ? (
|
||||
{filters.includes("cameras") && (
|
||||
<CamerasFilterButton
|
||||
allCameras={filterValues.cameras}
|
||||
groups={groups}
|
||||
selectedCameras={filter?.cameras}
|
||||
updateCameraFilter={(newCameras) => {
|
||||
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filters.includes("date") && (
|
||||
<CalendarFilterButton
|
||||
reviewSummary={reviewSummary}
|
||||
day={
|
||||
filter?.after == undefined
|
||||
? undefined
|
||||
: new Date(filter.after * 1000)
|
||||
}
|
||||
updateSelectedDay={onUpdateSelectedDay}
|
||||
/>
|
||||
)}
|
||||
{filters.includes("motionOnly") && (
|
||||
<ShowMotionOnlyButton
|
||||
motionOnly={motionOnly}
|
||||
setMotionOnly={setMotionOnly}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
{filters.includes("general") && (
|
||||
<GeneralFilterButton
|
||||
allLabels={filterValues.labels}
|
||||
selectedLabels={filter?.labels}
|
||||
@@ -293,7 +308,7 @@ type CalendarFilterButtonProps = {
|
||||
day?: Date;
|
||||
updateSelectedDay: (day?: Date) => void;
|
||||
};
|
||||
export function CalendarFilterButton({
|
||||
function CalendarFilterButton({
|
||||
reviewSummary,
|
||||
day,
|
||||
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 { isAndroid, isChrome, isMobile, isSafari } from "react-device-detect";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
timeRange: TimeRange;
|
||||
cameraPreviews: Preview[];
|
||||
startTime?: number;
|
||||
isScrubbing: boolean;
|
||||
@@ -37,7 +38,7 @@ export default function PreviewPlayer({
|
||||
}: PreviewPlayerProps) {
|
||||
const [currentHourFrame, setCurrentHourFrame] = useState<string>();
|
||||
|
||||
if (isCurrentHour(timeRange.end)) {
|
||||
if (isCurrentHour(timeRange.before)) {
|
||||
return (
|
||||
<PreviewFramesPlayer
|
||||
className={className}
|
||||
@@ -84,7 +85,7 @@ export abstract class PreviewController {
|
||||
type PreviewVideoPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
timeRange: TimeRange;
|
||||
cameraPreviews: Preview[];
|
||||
startTime?: number;
|
||||
isScrubbing: boolean;
|
||||
@@ -148,8 +149,8 @@ function PreviewVideoPlayer({
|
||||
return cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.start &&
|
||||
Math.floor(preview.end) <= timeRange.end,
|
||||
Math.round(preview.start) >= timeRange.after &&
|
||||
Math.floor(preview.end) <= timeRange.before,
|
||||
);
|
||||
|
||||
// we only want to calculate this once
|
||||
@@ -179,8 +180,8 @@ function PreviewVideoPlayer({
|
||||
const preview = cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.start &&
|
||||
Math.floor(preview.end) <= timeRange.end,
|
||||
Math.round(preview.start) >= timeRange.after &&
|
||||
Math.floor(preview.end) <= timeRange.before,
|
||||
);
|
||||
|
||||
if (preview != currentPreview) {
|
||||
@@ -292,7 +293,7 @@ function PreviewVideoPlayer({
|
||||
class PreviewVideoController extends PreviewController {
|
||||
// main state
|
||||
private previewRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
private timeRange: { start: number; end: number } | undefined = undefined;
|
||||
private timeRange: TimeRange | undefined = undefined;
|
||||
|
||||
// preview
|
||||
private preview: Preview | undefined = undefined;
|
||||
@@ -377,7 +378,7 @@ class PreviewVideoController extends PreviewController {
|
||||
type PreviewFramesPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
timeRange: TimeRange;
|
||||
startTime?: number;
|
||||
onControllerReady: (controller: PreviewController) => void;
|
||||
onClick?: () => void;
|
||||
@@ -395,8 +396,8 @@ function PreviewFramesPlayer({
|
||||
// frames data
|
||||
|
||||
const { data: previewFrames } = useSWR<string[]>(
|
||||
`preview/${camera}/start/${Math.floor(timeRange.start)}/end/${Math.ceil(
|
||||
timeRange.end,
|
||||
`preview/${camera}/start/${Math.floor(timeRange.after)}/end/${Math.ceil(
|
||||
timeRange.before,
|
||||
)}/frames`,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
@@ -457,7 +458,7 @@ function PreviewFramesPlayer({
|
||||
}
|
||||
|
||||
if (!startTime) {
|
||||
controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.start);
|
||||
controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.after);
|
||||
} else {
|
||||
controller.scrubToTimestamp(startTime);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ import { isFirefox, isMobile, isSafari } from "react-device-detect";
|
||||
import Chip from "@/components/indicators/Chip";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { useSwipeable } from "react-swipeable";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
review: ReviewSegment;
|
||||
@@ -187,11 +187,14 @@ export default function PreviewThumbnailPlayer({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PreviewPlaceholder imgLoaded={imgLoaded} />
|
||||
<ImageLoadingIndicator
|
||||
className="absolute inset-0"
|
||||
imgLoaded={imgLoaded}
|
||||
/>
|
||||
<div className={`${imgLoaded ? "visible" : "invisible"}`}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
className={`w-full h-full transition-opacity ${
|
||||
className={`size-full transition-opacity ${
|
||||
playingBack ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
||||
@@ -700,15 +703,3 @@ function InProgressPreview({
|
||||
</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 { DynamicVideoController } from "./DynamicVideoController";
|
||||
import HlsVideoPlayer from "../HlsVideoPlayer";
|
||||
import { Timeline } from "@/types/timeline";
|
||||
import { TimeRange, Timeline } from "@/types/timeline";
|
||||
|
||||
/**
|
||||
* Dynamically switches between video playback and scrubbing preview player.
|
||||
@@ -16,7 +16,7 @@ import { Timeline } from "@/types/timeline";
|
||||
type DynamicVideoPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
timeRange: TimeRange;
|
||||
cameraPreviews: Preview[];
|
||||
startTimestamp?: number;
|
||||
isScrubbing: boolean;
|
||||
@@ -100,7 +100,7 @@ export default function DynamicVideoPlayer({
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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
|
||||
@@ -134,8 +134,8 @@ export default function DynamicVideoPlayer({
|
||||
|
||||
const recordingParams = useMemo(() => {
|
||||
return {
|
||||
before: timeRange.end,
|
||||
after: timeRange.start,
|
||||
before: timeRange.before,
|
||||
after: timeRange.after,
|
||||
};
|
||||
}, [timeRange]);
|
||||
const { data: recordings } = useSWR<Recording[]>(
|
||||
@@ -153,7 +153,7 @@ export default function DynamicVideoPlayer({
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user