diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index 9f25f69a3..08f3c9126 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -93,6 +93,7 @@ export default function CameraImage({ "rounded-lg md:rounded-2xl", )} onLoad={handleImageLoad} + loading="lazy" /> ) : (
diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 1ad98be0d..045740a97 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -3,7 +3,7 @@ import { LuTrash } from "react-icons/lu"; import { Button } from "../ui/button"; import { useCallback, useState } from "react"; import { isDesktop } from "react-device-detect"; -import { FaDownload, FaPlay } from "react-icons/fa"; +import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa"; import Chip from "../indicators/Chip"; import { Skeleton } from "../ui/skeleton"; import { @@ -19,6 +19,7 @@ import { DeleteClipType, Export } from "@/types/export"; import { MdEditSquare } from "react-icons/md"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; +import { shareOrCopy } from "@/utils/browserUtil"; type ExportProps = { className: string; @@ -147,6 +148,19 @@ export default function ExportCard({
+ {!exportedRecording.in_progress && ( + + shareOrCopy( + `${baseUrl}exports?id=${exportedRecording.id}`, + exportedRecording.name.replaceAll("_", " "), + ) + } + > + + + )} {!exportedRecording.in_progress && ( {allLabels.map((item) => ( { @@ -516,6 +517,7 @@ export function GeneralFilterContent({
{allZones.map((item) => ( { diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index 3cb7945ce..ca8851b92 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -13,7 +13,7 @@ import { import { Button } from "@/components/ui/button"; import { ObjectLifecycleSequence } from "@/types/timeline"; import Heading from "@/components/ui/heading"; -import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; +import { ReviewDetailPaneType } from "@/types/review"; import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { getIconForLabel } from "@/utils/iconUtil"; @@ -47,14 +47,16 @@ import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; import { TooltipPortal } from "@radix-ui/react-tooltip"; type ObjectLifecycleProps = { - review: ReviewSegment; + className?: string; event: Event; + fullscreen?: boolean; setPane: React.Dispatch>; }; export default function ObjectLifecycle({ - review, + className, event, + fullscreen = false, setPane, }: ObjectLifecycleProps) { const { data: eventSequence } = useSWR([ @@ -78,13 +80,13 @@ export default function ObjectLifecycle({ const getZoneColor = useCallback( (zoneName: string) => { const zoneColor = - config?.cameras?.[review.camera]?.zones?.[zoneName]?.color; + config?.cameras?.[event.camera]?.zones?.[zoneName]?.color; if (zoneColor) { const reversed = [...zoneColor].reverse(); return reversed; } }, - [config, review], + [config, event], ); const getZonePolygon = useCallback( @@ -93,7 +95,7 @@ export default function ObjectLifecycle({ return; } const zonePoints = - config?.cameras[review.camera].zones[zoneName].coordinates; + config?.cameras[event.camera].zones[zoneName].coordinates; const imgElement = imgRef.current; const imgRect = imgElement.getBoundingClientRect(); @@ -110,7 +112,7 @@ export default function ObjectLifecycle({ }, [] as number[]) .join(","); }, - [config, imgRef, review], + [config, imgRef, event], ); const [boxStyle, setBoxStyle] = useState(null); @@ -224,17 +226,19 @@ export default function ObjectLifecycle({ } return ( - <> -
- -
+
+ {!fullscreen && ( +
+ +
+ )}
- + {eventSequence.map((item, index) => ( @@ -455,7 +462,7 @@ export default function ObjectLifecycle({
-
+
( handleThumbnailClick(index)} >
@@ -513,7 +523,7 @@ export default function ObjectLifecycle({
- +
); } diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 25dd79711..0bfae33bc 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -25,7 +25,7 @@ import { cn } from "@/lib/utils"; import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog"; import ObjectLifecycle from "./ObjectLifecycle"; import Chip from "@/components/indicators/Chip"; -import { FaDownload, FaImages } from "react-icons/fa"; +import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa"; import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; import { FaArrowsRotate } from "react-icons/fa6"; import { @@ -34,6 +34,9 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { baseUrl } from "@/api/baseUrl"; +import { shareOrCopy } from "@/utils/browserUtil"; type ReviewDetailDialogProps = { review?: ReviewSegment; @@ -136,11 +139,21 @@ export default function ReviewDetailDialog({
Timestamp
{formattedDate}
+
-
-
+
+
Objects
-
+
{events?.map((event) => { return (
{review.data.zones.length > 0 && ( -
+
Zones
{review.data.zones.map((zone) => { @@ -199,11 +212,7 @@ export default function ReviewDetailDialog({ {pane == "details" && selectedEvent && (
- +
)} diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index a67ec3b4a..02c099b29 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -34,10 +34,16 @@ import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; -import { FaRegListAlt, FaVideo } from "react-icons/fa"; -import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; +import { FaImage, FaRegListAlt, FaVideo } from "react-icons/fa"; +import { FaRotate } from "react-icons/fa6"; +import ObjectLifecycle from "./ObjectLifecycle"; -const SEARCH_TABS = ["details", "frigate+", "video"] as const; +const SEARCH_TABS = [ + "details", + "snapshot", + "video", + "object lifecycle", +] as const; type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { @@ -66,8 +72,8 @@ export default function SearchDetailDialog({ const views = [...SEARCH_TABS]; - if (!config.plus.enabled || !search.has_snapshot) { - const index = views.indexOf("frigate+"); + if (!search.has_snapshot) { + const index = views.indexOf("snapshot"); views.splice(index, 1); } @@ -80,6 +86,16 @@ export default function SearchDetailDialog({ return views; }, [config, search]); + useEffect(() => { + if (searchTabs.length == 0) { + return; + } + + if (!searchTabs.includes(pageToggle)) { + setPage("details"); + } + }, [pageToggle, searchTabs]); + if (!search) { return; } @@ -104,7 +120,7 @@ export default function SearchDetailDialog({ @@ -136,8 +152,11 @@ export default function SearchDetailDialog({ aria-label={`Select ${item}`} > {item == "details" && } - {item == "frigate+" && } + {item == "snapshot" && } {item == "video" && } + {item == "object lifecycle" && ( + + )}
{item}
))} @@ -153,9 +172,14 @@ export default function SearchDetailDialog({ setSimilarity={setSimilarity} /> )} - {page == "frigate+" && ( + {page == "snapshot" && ( {}} onEventUploaded={() => { @@ -164,6 +188,14 @@ export default function SearchDetailDialog({ /> )} {page == "video" && } + {page == "object lifecycle" && ( + {}} + /> + )}
); diff --git a/web/src/components/overlay/dialog/FrigatePlusDialog.tsx b/web/src/components/overlay/dialog/FrigatePlusDialog.tsx index e96b53d63..0c4801f8e 100644 --- a/web/src/components/overlay/dialog/FrigatePlusDialog.tsx +++ b/web/src/components/overlay/dialog/FrigatePlusDialog.tsx @@ -79,7 +79,7 @@ export function FrigatePlusDialog({ const content = ( - + Submit To Frigate+ Objects in locations you want to avoid are not false positives. diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 6ff5e1590..b233c6ad4 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -539,6 +539,7 @@ function PreviewFramesPlayer({ {previewFrames?.length === 0 && ( diff --git a/web/src/hooks/use-api-filter.ts b/web/src/hooks/use-api-filter.ts index 79269baee..185d0df9a 100644 --- a/web/src/hooks/use-api-filter.ts +++ b/web/src/hooks/use-api-filter.ts @@ -65,11 +65,11 @@ export function useApiFilterArgs< const filter: { [key: string]: unknown } = {}; rawParams.forEach((value, key) => { - if (isNaN(parseFloat(value))) { + if (value != "true" && value != "false" && isNaN(parseFloat(value))) { filter[key] = value.includes(",") ? value.split(",") : [value]; } else { if (value != undefined) { - filter[key] = `${value}`; + filter[key] = JSON.parse(value); } } }); diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index 0db339c9b..e68476682 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { usePersistence } from "./use-persistence"; export function useOverlayState( @@ -103,33 +103,29 @@ export function useHashState(): [ export function useSearchEffect( key: string, - callback: (value: string) => void, + callback: (value: string) => boolean, ) { - const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const param = useMemo(() => { - if (!location || !location.search || location.search.length == 0) { + const param = searchParams.get(key); + + if (!param) { return undefined; } - const params = location.search.substring(1).split("&"); - - const foundParam = params - .find((p) => p.includes("=") && p.split("=")[0] == key) - ?.split("="); - - if (foundParam && foundParam.length === 2) { - return [foundParam[0], decodeURIComponent(foundParam[1])]; - } - - return undefined; - }, [location, key]); + return [key, decodeURIComponent(param)]; + }, [searchParams, key]); useEffect(() => { if (!param) { return; } - callback(param[1]); - }, [param, callback]); + const remove = callback(param[1]); + + if (remove) { + setSearchParams(); + } + }, [param, callback, setSearchParams]); } diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 68e5c9adc..cbdd246f3 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; -import useApiFilter from "@/hooks/use-api-filter"; +import { useApiFilterArgs } from "@/hooks/use-api-filter"; import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useTimezone } from "@/hooks/use-date-utils"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; @@ -54,6 +54,8 @@ export default function Events() { } }) .catch(() => {}); + + return true; }); const [startTime, setStartTime] = useState(); @@ -69,7 +71,7 @@ export default function Events() { // review filter const [reviewFilter, setReviewFilter, reviewSearchParams] = - useApiFilter(); + useApiFilterArgs(); useSearchEffect("group", (reviewGroup) => { if (config && reviewGroup && reviewGroup != "default") { @@ -83,7 +85,11 @@ export default function Events() { cameras: group.cameras, }); } + + return true; } + + return false; }); const onUpdateFilter = useCallback( diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 750c75fde..f68bb5068 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -43,6 +43,7 @@ export default function Explore() { setSearch(`similarity:${similarityId}`); // @ts-expect-error we want to clear this setSearchFilter({ ...searchFilter, similarity_search_id: undefined }); + return false; }); useEffect(() => { diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 451d52052..a4659551b 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Toaster } from "@/components/ui/sonner"; +import { useSearchEffect } from "@/hooks/use-overlay-state"; import { cn } from "@/lib/utils"; import { DeleteClipType, Export } from "@/types/export"; import axios from "axios"; @@ -46,6 +47,20 @@ function Exports() { ); }, [exports, search]); + // Viewing + + const [selected, setSelected] = useState(); + const [selectedAspect, setSelectedAspect] = useState(0.0); + + useSearchEffect("id", (id) => { + if (!exports) { + return false; + } + + setSelected(exports.find((exp) => exp.id == id)); + return true; + }); + // Deleting const [deleteClip, setDeleteClip] = useState(); @@ -91,11 +106,6 @@ function Exports() { [mutate], ); - // Viewing - - const [selected, setSelected] = useState(); - const [selectedAspect, setSelectedAspect] = useState(0.0); - return (
diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index c088a5b04..9852852a7 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -29,7 +29,11 @@ function Live() { if (group) { setCameraGroup(cameraGroup); } + + return true; } + + return false; }); // fullscreen diff --git a/web/src/utils/browserUtil.ts b/web/src/utils/browserUtil.ts new file mode 100644 index 000000000..78f740649 --- /dev/null +++ b/web/src/utils/browserUtil.ts @@ -0,0 +1,16 @@ +import copy from "copy-to-clipboard"; +import { toast } from "sonner"; + +export function shareOrCopy(url: string, title?: string) { + if (window.isSecureContext && "share" in navigator) { + navigator.share({ + url: url, + title: title, + }); + } else { + copy(url); + toast.success("Copied to clipboard.", { + position: "top-center", + }); + } +}