Add ability to interact with review items in events list (#11562)

* Add ability to interact with review items

* Ignore on iOS

* Don't load metadata

* Bug fixes
This commit is contained in:
Nicolas Mowen 2024-05-27 16:12:57 -06:00 committed by GitHub
parent bfeb7b8a96
commit 5900a2a4ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 161 additions and 8 deletions

View File

@ -3,12 +3,24 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review"; import { ReviewSegment } from "@/types/review";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { isSafari } from "react-device-detect"; import { isDesktop, isIOS, isSafari } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import { useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import { FaCompactDisc } from "react-icons/fa";
import { FaCircleCheck } from "react-icons/fa6";
import { HiTrash } from "react-icons/hi";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import { Drawer, DrawerContent } from "../ui/drawer";
import axios from "axios";
import { toast } from "sonner";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -33,10 +45,61 @@ export default function ReviewCard({
[event, currentTime], [event, currentTime],
); );
return ( const [optionsOpen, setOptionsOpen] = useState(false);
const onMarkAsReviewed = useCallback(async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
event.has_been_reviewed = true;
setOptionsOpen(false);
}, [event]);
const onExport = useCallback(async () => {
axios
.post(
`export/${event.camera}/start/${event.start_time}/end/${event.end_time}`,
{ playback: "realtime" },
)
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
}
})
.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",
});
}
});
setOptionsOpen(false);
}, [event]);
const onDelete = useCallback(async () => {
await axios.post(`reviews/delete`, { ids: [event.id] });
event.id = "";
setOptionsOpen(false);
}, [event]);
const content = (
<div <div
className="relative flex w-full cursor-pointer flex-col gap-1.5" className="relative flex w-full cursor-pointer flex-col gap-1.5"
onClick={onClick} onClick={onClick}
onContextMenu={
isDesktop
? undefined
: (e) => {
e.preventDefault();
setOptionsOpen(true);
}
}
> >
<ImageLoadingIndicator <ImageLoadingIndicator
className="absolute inset-0" className="absolute inset-0"
@ -47,6 +110,15 @@ export default function ReviewCard({
className={`size-full rounded-lg ${isSelected ? "outline outline-[3px] outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`} className={`size-full rounded-lg ${isSelected ? "outline outline-[3px] outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`}
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`} src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"} loading={isSafari ? "eager" : "lazy"}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
onLoad={() => { onLoad={() => {
onImgLoad(); onImgLoad();
}} }}
@ -69,4 +141,78 @@ export default function ReviewCard({
</div> </div>
</div> </div>
); );
if (event.id == "") {
return;
}
if (isDesktop) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
</ContextMenuItem>
{!event.has_been_reviewed && (
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
</div>
</ContextMenuItem>
)}
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
return (
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
{content}
<DrawerContent>
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
{!event.has_been_reviewed && (
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
</div>
)}
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div>
</div>
</DrawerContent>
</Drawer>
);
} }

View File

@ -272,8 +272,8 @@ export default function HlsVideoPlayer({
? onTimeUpdate(videoRef.current.currentTime) ? onTimeUpdate(videoRef.current.currentTime)
: undefined : undefined
} }
onLoadedData={onPlayerLoaded} onLoadedData={() => {
onLoadedMetadata={() => { onPlayerLoaded?.();
handleLoadedMetadata(); handleLoadedMetadata();
if (videoRef.current) { if (videoRef.current) {

View File

@ -135,7 +135,7 @@ export default function LivePlayer({
); );
} }
} else if (liveMode == "jsmpeg") { } else if (liveMode == "jsmpeg") {
if (cameraActive) { if (cameraActive || !showStillWithoutActivity) {
player = ( player = (
<JSMpegPlayer <JSMpegPlayer
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl" className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"

View File

@ -302,7 +302,10 @@ function PreviewVideoPlayer({
}} }}
> >
{currentPreview != undefined && ( {currentPreview != undefined && (
<source src={currentPreview.src} type={currentPreview.type} /> <source
src={`${baseUrl}${currentPreview.src.substring(1)}`}
type={currentPreview.type}
/>
)} )}
</video> </video>
{cameraPreviews && !currentPreview && ( {cameraPreviews && !currentPreview && (

View File

@ -25,6 +25,7 @@ import { TimeRange } from "@/types/timeline";
import { NoThumbSlider } from "../ui/slider"; import { NoThumbSlider } from "../ui/slider";
import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview"; import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { baseUrl } from "@/api/baseUrl";
type PreviewPlayerProps = { type PreviewPlayerProps = {
review: ReviewSegment; review: ReviewSegment;
@ -575,7 +576,10 @@ export function VideoPreview({
muted muted
onTimeUpdate={onProgress} onTimeUpdate={onProgress}
> >
<source src={relevantPreview.src} type={relevantPreview.type} /> <source
src={`${baseUrl}${relevantPreview.src.substring(1)}`}
type={relevantPreview.type}
/>
</video> </video>
{showProgress && ( {showProgress && (
<NoThumbSlider <NoThumbSlider