diff --git a/web/src/App.tsx b/web/src/App.tsx
index 93e980f2c..53491c6aa 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -13,6 +13,7 @@ import { isPWA } from "./utils/isPWA";
const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events"));
+const Search = lazy(() => import("@/pages/Search"));
const Exports = lazy(() => import("@/pages/Exports"));
const SubmitPlus = lazy(() => import("@/pages/SubmitPlus"));
const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
@@ -44,6 +45,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx
index 85d6eb6d4..7a2eba4b1 100644
--- a/web/src/components/card/AnimatedEventCard.tsx
+++ b/web/src/components/card/AnimatedEventCard.tsx
@@ -7,10 +7,10 @@ import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { useNavigate } from "react-router-dom";
import { RecordingStartingPoint } from "@/types/record";
import axios from "axios";
-import { VideoPreview } from "../player/PreviewThumbnailPlayer";
import { isCurrentHour } from "@/utils/dateUtil";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { baseUrl } from "@/api/baseUrl";
+import { VideoPreview } from "../preview/ScrubbablePreview";
import { useApiHost } from "@/api";
import { isDesktop, isSafari } from "react-device-detect";
import { usePersistence } from "@/hooks/use-persistence";
diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx
new file mode 100644
index 000000000..9b630b893
--- /dev/null
+++ b/web/src/components/filter/CalendarFilterButton.tsx
@@ -0,0 +1,152 @@
+import {
+ useFormattedRange,
+ useFormattedTimestamp,
+} from "@/hooks/use-date-utils";
+import { ReviewSummary } from "@/types/review";
+import { Button } from "../ui/button";
+import { FaCalendarAlt } from "react-icons/fa";
+import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
+import { DropdownMenuSeparator } from "../ui/dropdown-menu";
+import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
+import { isMobile } from "react-device-detect";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import { DateRangePicker } from "../ui/calendar-range";
+import { DateRange } from "react-day-picker";
+
+type CalendarFilterButtonProps = {
+ reviewSummary?: ReviewSummary;
+ day?: Date;
+ updateSelectedDay: (day?: Date) => void;
+};
+export default function CalendarFilterButton({
+ reviewSummary,
+ day,
+ updateSelectedDay,
+}: CalendarFilterButtonProps) {
+ const selectedDate = useFormattedTimestamp(
+ day == undefined ? 0 : day?.getTime() / 1000 + 1,
+ "%b %-d",
+ );
+
+ const trigger = (
+
+ );
+ const content = (
+ <>
+
+
+
+
+
+ >
+ );
+
+ if (isMobile) {
+ return (
+
+ {trigger}
+ {content}
+
+ );
+ }
+
+ return (
+
+ {trigger}
+ {content}
+
+ );
+}
+
+type CalendarRangeFilterButtonProps = {
+ range?: DateRange;
+ defaultText: string;
+ updateSelectedRange: (range?: DateRange) => void;
+};
+export function CalendarRangeFilterButton({
+ range,
+ defaultText,
+ updateSelectedRange,
+}: CalendarRangeFilterButtonProps) {
+ const selectedDate = useFormattedRange(
+ range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1,
+ range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1,
+ "%b %-d",
+ );
+
+ const trigger = (
+
+ );
+ const content = (
+ <>
+ updateSelectedRange(range.range)}
+ />
+
+
+
+
+ >
+ );
+
+ if (isMobile) {
+ return (
+
+ {trigger}
+ {content}
+
+ );
+ }
+
+ return (
+
+ {trigger}
+ {content}
+
+ );
+}
diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx
new file mode 100644
index 000000000..b1878bf12
--- /dev/null
+++ b/web/src/components/filter/CamerasFilterButton.tsx
@@ -0,0 +1,177 @@
+import { Button } from "../ui/button";
+import { CameraGroupConfig } from "@/types/frigateConfig";
+import { useState } from "react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../ui/dropdown-menu";
+import { isMobile } from "react-device-detect";
+import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
+import FilterSwitch from "./FilterSwitch";
+import { FaVideo } from "react-icons/fa";
+
+type CameraFilterButtonProps = {
+ allCameras: string[];
+ groups: [string, CameraGroupConfig][];
+ selectedCameras: string[] | undefined;
+ updateCameraFilter: (cameras: string[] | undefined) => void;
+};
+export function CamerasFilterButton({
+ allCameras,
+ groups,
+ selectedCameras,
+ updateCameraFilter,
+}: CameraFilterButtonProps) {
+ const [open, setOpen] = useState(false);
+ const [currentCameras, setCurrentCameras] = useState(
+ selectedCameras,
+ );
+
+ const trigger = (
+
+ );
+ const content = (
+ <>
+ {isMobile && (
+ <>
+
+ Cameras
+
+
+ >
+ )}
+
+
{
+ if (isChecked) {
+ setCurrentCameras(undefined);
+ }
+ }}
+ />
+ {groups.length > 0 && (
+ <>
+
+ {groups.map(([name, conf]) => {
+ return (
+ setCurrentCameras([...conf.cameras])}
+ >
+ {name}
+
+ );
+ })}
+ >
+ )}
+
+
+ {allCameras.map((item) => (
+ {
+ if (isChecked) {
+ const updatedCameras = currentCameras
+ ? [...currentCameras]
+ : [];
+
+ updatedCameras.push(item);
+ setCurrentCameras(updatedCameras);
+ } else {
+ const updatedCameras = currentCameras
+ ? [...currentCameras]
+ : [];
+
+ // can not deselect the last item
+ if (updatedCameras.length > 1) {
+ updatedCameras.splice(updatedCameras.indexOf(item), 1);
+ setCurrentCameras(updatedCameras);
+ }
+ }
+ }}
+ />
+ ))}
+
+
+
+
+
+
+
+ >
+ );
+
+ if (isMobile) {
+ return (
+ {
+ if (!open) {
+ setCurrentCameras(selectedCameras);
+ }
+
+ setOpen(open);
+ }}
+ >
+ {trigger}
+
+ {content}
+
+
+ );
+ }
+
+ return (
+ {
+ if (!open) {
+ setCurrentCameras(selectedCameras);
+ }
+
+ setOpen(open);
+ }}
+ >
+ {trigger}
+ {content}
+
+ );
+}
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx
index d01ea384f..d73e77464 100644
--- a/web/src/components/filter/ReviewFilterGroup.tsx
+++ b/web/src/components/filter/ReviewFilterGroup.tsx
@@ -1,36 +1,24 @@
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import useSWR from "swr";
-import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
+import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useMemo, useState } from "react";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "../ui/dropdown-menu";
+import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
-import { useFormattedTimestamp } from "@/hooks/use-date-utils";
-import {
- FaCalendarAlt,
- FaCheckCircle,
- FaFilter,
- FaRunning,
- FaVideo,
-} from "react-icons/fa";
+import { FaCheckCircle, FaFilter, FaRunning } from "react-icons/fa";
import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
-import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
import MobileReviewSettingsDrawer, {
DrawerFeatures,
} from "../overlay/MobileReviewSettingsDrawer";
import useOptimisticState from "@/hooks/use-optimistic-state";
import FilterSwitch from "./FilterSwitch";
import { FilterList } from "@/types/filter";
+import CalendarFilterButton from "./CalendarFilterButton";
+import { CamerasFilterButton } from "./CamerasFilterButton";
const REVIEW_FILTERS = [
"cameras",
@@ -210,6 +198,7 @@ export default function ReviewFilterGroup({
? undefined
: new Date(filter.after * 1000)
}
+ defaultText="Last 24 Hours"
updateSelectedDay={onUpdateSelectedDay}
/>
)}
@@ -260,169 +249,6 @@ export default function ReviewFilterGroup({
);
}
-type CameraFilterButtonProps = {
- allCameras: string[];
- groups: [string, CameraGroupConfig][];
- selectedCameras: string[] | undefined;
- updateCameraFilter: (cameras: string[] | undefined) => void;
-};
-export function CamerasFilterButton({
- allCameras,
- groups,
- selectedCameras,
- updateCameraFilter,
-}: CameraFilterButtonProps) {
- const [open, setOpen] = useState(false);
- const [currentCameras, setCurrentCameras] = useState(
- selectedCameras,
- );
-
- const trigger = (
-
- );
- const content = (
- <>
- {isMobile && (
- <>
-
- Cameras
-
-
- >
- )}
-
-
{
- if (isChecked) {
- setCurrentCameras(undefined);
- }
- }}
- />
- {groups.length > 0 && (
- <>
-
- {groups.map(([name, conf]) => {
- return (
- setCurrentCameras([...conf.cameras])}
- >
- {name}
-
- );
- })}
- >
- )}
-
-
- {allCameras.map((item) => (
- {
- if (isChecked) {
- const updatedCameras = currentCameras
- ? [...currentCameras]
- : [];
-
- updatedCameras.push(item);
- setCurrentCameras(updatedCameras);
- } else {
- const updatedCameras = currentCameras
- ? [...currentCameras]
- : [];
-
- // can not deselect the last item
- if (updatedCameras.length > 1) {
- updatedCameras.splice(updatedCameras.indexOf(item), 1);
- setCurrentCameras(updatedCameras);
- }
- }
- }}
- />
- ))}
-
-
-
-
-
-
-
- >
- );
-
- if (isMobile) {
- return (
- {
- if (!open) {
- setCurrentCameras(selectedCameras);
- }
-
- setOpen(open);
- }}
- >
- {trigger}
-
- {content}
-
-
- );
- }
-
- return (
- {
- if (!open) {
- setCurrentCameras(selectedCameras);
- }
-
- setOpen(open);
- }}
- >
- {trigger}
- {content}
-
- );
-}
-
type ShowReviewedFilterProps = {
showReviewed: boolean;
setShowReviewed: (reviewed: boolean) => void;
@@ -466,74 +292,6 @@ function ShowReviewFilter({
);
}
-type CalendarFilterButtonProps = {
- reviewSummary?: ReviewSummary;
- day?: Date;
- updateSelectedDay: (day?: Date) => void;
-};
-function CalendarFilterButton({
- reviewSummary,
- day,
- updateSelectedDay,
-}: CalendarFilterButtonProps) {
- const selectedDate = useFormattedTimestamp(
- day == undefined ? 0 : day?.getTime() / 1000 + 1,
- "%b %-d",
- );
-
- const trigger = (
-
- );
- const content = (
- <>
-
-
-
-
-
- >
- );
-
- if (isMobile) {
- return (
-
- {trigger}
- {content}
-
- );
- }
-
- return (
-
- {trigger}
- {content}
-
- );
-}
-
type GeneralFilterButtonProps = {
allLabels: string[];
selectedLabels: string[] | undefined;
diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx
new file mode 100644
index 000000000..e27a65a47
--- /dev/null
+++ b/web/src/components/filter/SearchFilterGroup.tsx
@@ -0,0 +1,458 @@
+import { Button } from "../ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import useSWR from "swr";
+import { FrigateConfig } from "@/types/frigateConfig";
+import { useCallback, useMemo, useState } from "react";
+import { DropdownMenuSeparator } from "../ui/dropdown-menu";
+import { getEndOfDayTimestamp } from "@/utils/dateUtil";
+import { FaFilter } from "react-icons/fa";
+import { isDesktop, isMobile } from "react-device-detect";
+import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
+import { Switch } from "../ui/switch";
+import { Label } from "../ui/label";
+import MobileReviewSettingsDrawer, {
+ DrawerFeatures,
+} from "../overlay/MobileReviewSettingsDrawer";
+import FilterSwitch from "./FilterSwitch";
+import { FilterList } from "@/types/filter";
+import { CalendarRangeFilterButton } from "./CalendarFilterButton";
+import { CamerasFilterButton } from "./CamerasFilterButton";
+import { SearchFilter } from "@/types/search";
+import { DateRange } from "react-day-picker";
+
+const SEARCH_FILTERS = ["cameras", "date", "general"] as const;
+type SearchFilters = (typeof SEARCH_FILTERS)[number];
+const DEFAULT_REVIEW_FILTERS: SearchFilters[] = ["cameras", "date", "general"];
+
+type SearchFilterGroupProps = {
+ filters?: SearchFilters[];
+ filter?: SearchFilter;
+ filterList?: FilterList;
+ onUpdateFilter: (filter: SearchFilter) => void;
+};
+
+export default function SearchFilterGroup({
+ filters = DEFAULT_REVIEW_FILTERS,
+ filter,
+ filterList,
+ onUpdateFilter,
+}: SearchFilterGroupProps) {
+ const { data: config } = useSWR("config");
+
+ const allLabels = useMemo(() => {
+ if (filterList?.labels) {
+ return filterList.labels;
+ }
+
+ if (!config) {
+ return [];
+ }
+
+ const labels = new Set();
+ const cameras = filter?.cameras || Object.keys(config.cameras);
+
+ cameras.forEach((camera) => {
+ if (camera == "birdseye") {
+ return;
+ }
+ const cameraConfig = config.cameras[camera];
+ cameraConfig.objects.track.forEach((label) => {
+ labels.add(label);
+ });
+
+ if (cameraConfig.audio.enabled_in_config) {
+ cameraConfig.audio.listen.forEach((label) => {
+ labels.add(label);
+ });
+ }
+ });
+
+ return [...labels].sort();
+ }, [config, filterList, filter]);
+
+ const allZones = useMemo(() => {
+ if (filterList?.zones) {
+ return filterList.zones;
+ }
+
+ if (!config) {
+ return [];
+ }
+
+ const zones = new Set();
+ const cameras = filter?.cameras || Object.keys(config.cameras);
+
+ cameras.forEach((camera) => {
+ if (camera == "birdseye") {
+ return;
+ }
+ const cameraConfig = config.cameras[camera];
+ cameraConfig.review.alerts.required_zones.forEach((zone) => {
+ zones.add(zone);
+ });
+ cameraConfig.review.detections.required_zones.forEach((zone) => {
+ zones.add(zone);
+ });
+ });
+
+ return [...zones].sort();
+ }, [config, filterList, filter]);
+
+ const filterValues = useMemo(
+ () => ({
+ cameras: Object.keys(config?.cameras || {}),
+ labels: Object.values(allLabels || {}),
+ zones: Object.values(allZones || {}),
+ }),
+ [config, allLabels, allZones],
+ );
+
+ const groups = useMemo(() => {
+ if (!config) {
+ return [];
+ }
+
+ return Object.entries(config.camera_groups).sort(
+ (a, b) => a[1].order - b[1].order,
+ );
+ }, [config]);
+
+ const mobileSettingsFeatures = useMemo(() => {
+ const features: DrawerFeatures[] = [];
+
+ if (filters.includes("date")) {
+ features.push("calendar");
+ }
+
+ if (filters.includes("general")) {
+ features.push("filter");
+ }
+
+ return features;
+ }, [filters]);
+
+ // handle updating filters
+
+ const onUpdateSelectedRange = useCallback(
+ (range?: DateRange) => {
+ onUpdateFilter({
+ ...filter,
+ after:
+ range?.from == undefined ? undefined : range.from.getTime() / 1000,
+ before:
+ range?.to == undefined ? undefined : getEndOfDayTimestamp(range.to),
+ });
+ },
+ [filter, onUpdateFilter],
+ );
+
+ return (
+
+ {filters.includes("cameras") && (
+ {
+ onUpdateFilter({ ...filter, cameras: newCameras });
+ }}
+ />
+ )}
+ {isDesktop && filters.includes("date") && (
+
+ )}
+ {isDesktop && filters.includes("general") && (
+ {
+ onUpdateFilter({ ...filter, labels: newLabels });
+ }}
+ updateZoneFilter={(newZones) =>
+ onUpdateFilter({ ...filter, zones: newZones })
+ }
+ />
+ )}
+ {isMobile && mobileSettingsFeatures.length > 0 && (
+ {}}
+ setRange={() => {}}
+ />
+ )}
+
+ );
+}
+
+type GeneralFilterButtonProps = {
+ allLabels: string[];
+ selectedLabels: string[] | undefined;
+ allZones: string[];
+ selectedZones?: string[];
+ updateLabelFilter: (labels: string[] | undefined) => void;
+ updateZoneFilter: (zones: string[] | undefined) => void;
+};
+function GeneralFilterButton({
+ allLabels,
+ selectedLabels,
+ allZones,
+ selectedZones,
+ updateLabelFilter,
+ updateZoneFilter,
+}: GeneralFilterButtonProps) {
+ const [open, setOpen] = useState(false);
+ const [currentLabels, setCurrentLabels] = useState(
+ selectedLabels,
+ );
+ const [currentZones, setCurrentZones] = useState(
+ selectedZones,
+ );
+
+ const trigger = (
+
+ );
+ const content = (
+ setOpen(false)}
+ />
+ );
+
+ if (isMobile) {
+ return (
+ {
+ if (!open) {
+ setCurrentLabels(selectedLabels);
+ }
+
+ setOpen(open);
+ }}
+ >
+ {trigger}
+
+ {content}
+
+
+ );
+ }
+
+ return (
+ {
+ if (!open) {
+ setCurrentLabels(selectedLabels);
+ }
+
+ setOpen(open);
+ }}
+ >
+ {trigger}
+ {content}
+
+ );
+}
+
+type GeneralFilterContentProps = {
+ allLabels: string[];
+ selectedLabels: string[] | undefined;
+ currentLabels: string[] | undefined;
+ allZones?: string[];
+ selectedZones?: string[];
+ currentZones?: string[];
+ updateLabelFilter: (labels: string[] | undefined) => void;
+ setCurrentLabels: (labels: string[] | undefined) => void;
+ updateZoneFilter?: (zones: string[] | undefined) => void;
+ setCurrentZones?: (zones: string[] | undefined) => void;
+ onClose: () => void;
+};
+export function GeneralFilterContent({
+ allLabels,
+ selectedLabels,
+ currentLabels,
+ allZones,
+ selectedZones,
+ currentZones,
+ updateLabelFilter,
+ setCurrentLabels,
+ updateZoneFilter,
+ setCurrentZones,
+ onClose,
+}: GeneralFilterContentProps) {
+ return (
+ <>
+
+
+
+ {
+ if (isChecked) {
+ setCurrentLabels(undefined);
+ }
+ }}
+ />
+
+
+ {allLabels.map((item) => (
+ {
+ if (isChecked) {
+ const updatedLabels = currentLabels ? [...currentLabels] : [];
+
+ updatedLabels.push(item);
+ setCurrentLabels(updatedLabels);
+ } else {
+ const updatedLabels = currentLabels ? [...currentLabels] : [];
+
+ // can not deselect the last item
+ if (updatedLabels.length > 1) {
+ updatedLabels.splice(updatedLabels.indexOf(item), 1);
+ setCurrentLabels(updatedLabels);
+ }
+ }
+ }}
+ />
+ ))}
+
+
+ {allZones && setCurrentZones && (
+ <>
+
+
+
+ {
+ if (isChecked) {
+ setCurrentZones(undefined);
+ }
+ }}
+ />
+
+
+ {allZones.map((item) => (
+ {
+ if (isChecked) {
+ const updatedZones = currentZones
+ ? [...currentZones]
+ : [];
+
+ updatedZones.push(item);
+ setCurrentZones(updatedZones);
+ } else {
+ const updatedZones = currentZones
+ ? [...currentZones]
+ : [];
+
+ // can not deselect the last item
+ if (updatedZones.length > 1) {
+ updatedZones.splice(updatedZones.indexOf(item), 1);
+ setCurrentZones(updatedZones);
+ }
+ }
+ }}
+ />
+ ))}
+
+ >
+ )}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/indicators/Chip.tsx b/web/src/components/indicators/Chip.tsx
index e484f2f5a..8c8d373c8 100644
--- a/web/src/components/indicators/Chip.tsx
+++ b/web/src/components/indicators/Chip.tsx
@@ -39,7 +39,13 @@ export default function Chip({
className,
!isIOS && "z-10",
)}
- onClick={onClick}
+ onClick={(e) => {
+ e.stopPropagation();
+
+ if (onClick) {
+ onClick();
+ }
+ }}
>
{children}
diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx
index 34637d57e..c1c46d87b 100644
--- a/web/src/components/menu/GeneralSettings.tsx
+++ b/web/src/components/menu/GeneralSettings.tsx
@@ -3,6 +3,7 @@ import {
LuGithub,
LuLifeBuoy,
LuList,
+ LuLogOut,
LuMoon,
LuPenSquare,
LuRotateCw,
@@ -56,7 +57,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import ActivityIndicator from "../indicators/activity-indicator";
-import { isDesktop } from "react-device-detect";
+import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import {
Dialog,
@@ -68,11 +69,18 @@ import {
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import { baseUrl } from "@/api/baseUrl";
+import useSWR from "swr";
type GeneralSettingsProps = {
className?: string;
};
export default function GeneralSettings({ className }: GeneralSettingsProps) {
+ const { data: profile } = useSWR("profile");
+ const { data: config } = useSWR("config");
+ const logoutUrl = config?.proxy?.logout_url || "/api/logout";
+
+ // settings
+
const { theme, colorScheme, setTheme, setColorScheme } = useTheme();
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
@@ -154,6 +162,28 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
}
>
+ {isMobile && (
+ <>
+
+ Current User: {profile?.username || "anonymous"}
+
+
+
+ >
+ )}
System
diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx
index b6432bd6c..d82f69c60 100644
--- a/web/src/components/navigation/Bottombar.tsx
+++ b/web/src/components/navigation/Bottombar.tsx
@@ -7,7 +7,6 @@ import { useFrigateStats } from "@/api/ws";
import { useContext, useEffect, useMemo } from "react";
import useStats from "@/hooks/use-stats";
import GeneralSettings from "../menu/GeneralSettings";
-import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
import {
StatusBarMessagesContext,
@@ -35,7 +34,6 @@ function Bottombar() {
))}
-
);
diff --git a/web/src/components/overlay/SearchDetailDialog.tsx b/web/src/components/overlay/SearchDetailDialog.tsx
new file mode 100644
index 000000000..23580bdbf
--- /dev/null
+++ b/web/src/components/overlay/SearchDetailDialog.tsx
@@ -0,0 +1,167 @@
+import { isDesktop, isIOS } from "react-device-detect";
+import { Sheet, SheetContent } from "../ui/sheet";
+import { Drawer, DrawerContent } from "../ui/drawer";
+import { SearchResult } from "@/types/search";
+import useSWR from "swr";
+import { FrigateConfig } from "@/types/frigateConfig";
+import { useFormattedTimestamp } from "@/hooks/use-date-utils";
+import { getIconForLabel } from "@/utils/iconUtil";
+import { useApiHost } from "@/api";
+import { Button } from "../ui/button";
+import { useCallback, useEffect, useState } from "react";
+import axios from "axios";
+import { toast } from "sonner";
+import { Textarea } from "../ui/textarea";
+
+type SearchDetailDialogProps = {
+ search?: SearchResult;
+ setSearch: (search: SearchResult | undefined) => void;
+ setSimilarity?: () => void;
+};
+export default function SearchDetailDialog({
+ search,
+ setSearch,
+ setSimilarity,
+}: SearchDetailDialogProps) {
+ const { data: config } = useSWR("config", {
+ revalidateOnFocus: false,
+ });
+
+ const apiHost = useApiHost();
+
+ // data
+
+ const [desc, setDesc] = useState(search?.description);
+
+ // we have to make sure the current selected search item stays in sync
+ useEffect(() => setDesc(search?.description), [search]);
+
+ const formattedDate = useFormattedTimestamp(
+ search?.start_time ?? 0,
+ config?.ui.time_format == "24hour"
+ ? "%b %-d %Y, %H:%M"
+ : "%b %-d %Y, %I:%M %p",
+ );
+
+ // api
+
+ const updateDescription = useCallback(() => {
+ if (!search) {
+ return;
+ }
+
+ axios
+ .post(`events/${search.id}/description`, { description: desc })
+ .then((resp) => {
+ if (resp.status == 200) {
+ toast.success("Successfully saved description", {
+ position: "top-center",
+ });
+ }
+ })
+ .catch(() => {
+ toast.error("Failed to update the description", {
+ position: "top-center",
+ });
+ setDesc(search.description);
+ });
+ }, [desc, search]);
+
+ // content
+
+ const Overlay = isDesktop ? Sheet : Drawer;
+ const Content = isDesktop ? SheetContent : DrawerContent;
+
+ return (
+ {
+ if (!open) {
+ setSearch(undefined);
+ }
+ }}
+ >
+
+ {search && (
+
+
+
+
+
Label
+
+ {getIconForLabel(search.label, "size-4 text-white")}
+ {search.label}
+
+
+
+
Score
+
+ {Math.round(search.score * 100)}%
+
+
+
+
Camera
+
+ {search.camera.replaceAll("_", " ")}
+
+
+
+
Timestamp
+
{formattedDate}
+
+
+
+

+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx
index d2192e85d..99f2e9413 100644
--- a/web/src/components/player/PreviewThumbnailPlayer.tsx
+++ b/web/src/components/player/PreviewThumbnailPlayer.tsx
@@ -1,10 +1,4 @@
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useApiHost } from "@/api";
import { isCurrentHour } from "@/utils/dateUtil";
import { ReviewSegment } from "@/types/review";
@@ -12,7 +6,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
-import { isFirefox, isIOS, isMobile, isSafari } from "react-device-detect";
+import { isIOS, 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";
@@ -21,12 +15,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import useContextMenu from "@/hooks/use-contextmenu";
import ActivityIndicator from "../indicators/activity-indicator";
-import { TimelineScrubMode, TimeRange } from "@/types/timeline";
-import { NoThumbSlider } from "../ui/slider";
-import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview";
+import { TimeRange } from "@/types/timeline";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
-import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
+import { InProgressPreview, VideoPreview } from "../preview/ScrubbablePreview";
+import { Preview } from "@/types/preview";
type PreviewPlayerProps = {
review: ReviewSegment;
@@ -38,14 +31,6 @@ type PreviewPlayerProps = {
onClick: (review: ReviewSegment, ctrl: boolean) => void;
};
-type Preview = {
- camera: string;
- src: string;
- type: string;
- start: number;
- end: number;
-};
-
export default function PreviewThumbnailPlayer({
review,
allPreviews,
@@ -354,474 +339,3 @@ function PreviewContent({
);
}
}
-
-type VideoPreviewProps = {
- relevantPreview: Preview;
- startTime: number;
- endTime?: number;
- showProgress?: boolean;
- loop?: boolean;
- setReviewed: () => void;
- setIgnoreClick: (ignore: boolean) => void;
- isPlayingBack: (ended: boolean) => void;
- onTimeUpdate?: (time: number | undefined) => void;
- windowVisible: boolean;
-};
-export function VideoPreview({
- relevantPreview,
- startTime,
- endTime,
- showProgress = true,
- loop = false,
- setReviewed,
- setIgnoreClick,
- isPlayingBack,
- onTimeUpdate,
- windowVisible,
-}: VideoPreviewProps) {
- const playerRef = useRef(null);
- const sliderRef = useRef(null);
-
- // keep track of playback state
-
- const [progress, setProgress] = useState(0);
- const [hoverTimeout, setHoverTimeout] = useState();
- const playerStartTime = useMemo(() => {
- if (!relevantPreview) {
- return 0;
- }
-
- // start with a bit of padding
- return Math.max(0, startTime - relevantPreview.start - PREVIEW_PADDING);
-
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
- const playerDuration = useMemo(
- () => (endTime ?? relevantPreview.end) - startTime + PREVIEW_PADDING,
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [],
- );
- const [lastPercent, setLastPercent] = useState(0.0);
-
- // initialize player correctly
-
- useEffect(() => {
- if (!playerRef.current) {
- return;
- }
-
- if (isSafari || (isFirefox && isMobile)) {
- playerRef.current.pause();
- setPlaybackMode("compat");
- } else {
- playerRef.current.currentTime = playerStartTime;
- playerRef.current.playbackRate = PREVIEW_FPS;
- }
-
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [playerRef]);
-
- // time progress update
-
- const onProgress = useCallback(() => {
- if (!windowVisible) {
- return;
- }
-
- if (onTimeUpdate) {
- onTimeUpdate(
- relevantPreview.start + (playerRef.current?.currentTime || 0),
- );
- }
-
- const playerProgress =
- (playerRef.current?.currentTime || 0) - playerStartTime;
-
- // end with a bit of padding
- const playerPercent = (playerProgress / playerDuration) * 100;
-
- if (setReviewed && lastPercent < 50 && playerPercent > 50) {
- setReviewed();
- }
-
- setLastPercent(playerPercent);
-
- if (playerPercent > 100) {
- setReviewed();
-
- if (loop && playerRef.current) {
- if (playbackMode != "auto") {
- setPlaybackMode("auto");
- setTimeout(() => setPlaybackMode("compat"), 100);
- }
-
- playerRef.current.currentTime = playerStartTime;
- return;
- }
-
- if (isMobile) {
- isPlayingBack(false);
-
- if (onTimeUpdate) {
- onTimeUpdate(undefined);
- }
- } else {
- playerRef.current?.pause();
- }
-
- setPlaybackMode("auto");
- setProgress(100.0);
- } else {
- setProgress(playerPercent);
- }
-
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [setProgress, lastPercent, windowVisible]);
-
- // manual playback
- // safari is incapable of playing at a speed > 2x
- // so manual seeking is required on iOS
-
- const [playbackMode, setPlaybackMode] = useState("auto");
-
- useEffect(() => {
- if (playbackMode != "compat" || !playerRef.current) {
- return;
- }
-
- let counter = 0;
- const intervalId: NodeJS.Timeout = setInterval(() => {
- if (playerRef.current) {
- playerRef.current.currentTime = playerStartTime + counter;
- counter += 1;
- }
- }, 1000 / PREVIEW_FPS);
- return () => clearInterval(intervalId);
-
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [playbackMode, playerRef]);
-
- // user interaction
-
- useEffect(() => {
- setIgnoreClick(playbackMode != "auto" && playbackMode != "compat");
- }, [playbackMode, setIgnoreClick]);
-
- const onManualSeek = useCallback(
- (values: number[]) => {
- const value = values[0];
-
- if (!playerRef.current) {
- return;
- }
-
- if (playerRef.current.paused == false) {
- playerRef.current.pause();
- }
-
- if (setReviewed) {
- setReviewed();
- }
-
- setProgress(value);
- playerRef.current.currentTime =
- playerStartTime + (value / 100.0) * playerDuration;
- },
-
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [playerDuration, playerRef, playerStartTime, setIgnoreClick],
- );
-
- const onStopManualSeek = useCallback(() => {
- setTimeout(() => {
- setHoverTimeout(undefined);
-
- if (isSafari || (isFirefox && isMobile)) {
- setPlaybackMode("compat");
- } else {
- setPlaybackMode("auto");
- playerRef.current?.play();
- }
- }, 500);
- }, [playerRef]);
-
- const onProgressHover = useCallback(
- (event: React.MouseEvent) => {
- if (!sliderRef.current) {
- return;
- }
-
- const rect = sliderRef.current.getBoundingClientRect();
- const positionX = event.clientX - rect.left;
- const width = sliderRef.current.clientWidth;
- onManualSeek([Math.round((positionX / width) * 100)]);
-
- if (hoverTimeout) {
- clearTimeout(hoverTimeout);
- }
- },
- [sliderRef, hoverTimeout, onManualSeek],
- );
-
- return (
-
-
- {showProgress && (
- {
- setPlaybackMode("drag");
- onManualSeek(event);
- }}
- onValueCommit={onStopManualSeek}
- min={0}
- step={1}
- max={100}
- onMouseMove={
- isMobile
- ? undefined
- : (event) => {
- if (playbackMode != "drag") {
- setPlaybackMode("hover");
- onProgressHover(event);
- }
- }
- }
- onMouseLeave={
- isMobile
- ? undefined
- : () => {
- if (!sliderRef.current) {
- return;
- }
-
- setHoverTimeout(setTimeout(() => onStopManualSeek(), 500));
- }
- }
- />
- )}
-
- );
-}
-
-const MIN_LOAD_TIMEOUT_MS = 200;
-type InProgressPreviewProps = {
- review: ReviewSegment;
- timeRange: TimeRange;
- showProgress?: boolean;
- loop?: boolean;
- setReviewed: (reviewId: string) => void;
- setIgnoreClick: (ignore: boolean) => void;
- isPlayingBack: (ended: boolean) => void;
- onTimeUpdate?: (time: number | undefined) => void;
- windowVisible: boolean;
-};
-export function InProgressPreview({
- review,
- timeRange,
- showProgress = true,
- loop = false,
- setReviewed,
- setIgnoreClick,
- isPlayingBack,
- onTimeUpdate,
- windowVisible,
-}: InProgressPreviewProps) {
- const apiHost = useApiHost();
- const sliderRef = useRef(null);
- const { data: previewFrames } = useSWR(
- `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${
- Math.ceil(review.end_time ?? timeRange.before) + PREVIEW_PADDING
- }/frames`,
- { revalidateOnFocus: false },
- );
-
- const [playbackMode, setPlaybackMode] = useState("auto");
- const [hoverTimeout, setHoverTimeout] = useState();
- const [key, setKey] = useState(0);
-
- const handleLoad = useCallback(() => {
- if (!previewFrames || !windowVisible) {
- return;
- }
-
- if (onTimeUpdate) {
- onTimeUpdate(review.start_time - PREVIEW_PADDING + key);
- }
-
- if (playbackMode != "auto") {
- return;
- }
-
- if (key == previewFrames.length - 1) {
- if (!review.has_been_reviewed) {
- setReviewed(review.id);
- }
-
- if (loop) {
- setKey(0);
- return;
- }
-
- if (isMobile) {
- isPlayingBack(false);
-
- if (onTimeUpdate) {
- onTimeUpdate(undefined);
- }
- }
-
- return;
- }
-
- setTimeout(() => {
- if (setReviewed && key == Math.floor(previewFrames.length / 2)) {
- setReviewed(review.id);
- }
-
- if (previewFrames[key + 1]) {
- setKey(key + 1);
- }
- }, MIN_LOAD_TIMEOUT_MS);
-
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [key, playbackMode, previewFrames]);
-
- // user interaction
-
- useEffect(() => {
- setIgnoreClick(playbackMode != "auto");
- }, [playbackMode, setIgnoreClick]);
-
- const onManualSeek = useCallback(
- (values: number[]) => {
- const value = values[0];
-
- if (!review.has_been_reviewed) {
- setReviewed(review.id);
- }
-
- setKey(value);
- },
-
- // we know that these deps are correct
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [setIgnoreClick, setKey],
- );
-
- const onStopManualSeek = useCallback(
- (values: number[]) => {
- const value = values[0];
- setTimeout(() => {
- setPlaybackMode("auto");
- setKey(value - 1);
- }, 500);
- },
- [setPlaybackMode],
- );
-
- const onProgressHover = useCallback(
- (event: React.MouseEvent) => {
- if (!sliderRef.current || !previewFrames) {
- return;
- }
-
- const rect = sliderRef.current.getBoundingClientRect();
- const positionX = event.clientX - rect.left;
- const width = sliderRef.current.clientWidth;
- const progress = [Math.round((positionX / width) * previewFrames.length)];
- onManualSeek(progress);
-
- if (hoverTimeout) {
- clearTimeout(hoverTimeout);
- }
- },
- [sliderRef, hoverTimeout, previewFrames, onManualSeek],
- );
-
- if (!previewFrames || previewFrames.length == 0) {
- return (
-
- );
- }
-
- return (
-
-

- {showProgress && (
-
{
- setPlaybackMode("drag");
- onManualSeek(event);
- }}
- onValueCommit={onStopManualSeek}
- min={0}
- step={1}
- max={previewFrames.length - 1}
- onMouseMove={
- isMobile
- ? undefined
- : (event) => {
- if (playbackMode != "drag") {
- setPlaybackMode("hover");
- onProgressHover(event);
- }
- }
- }
- onMouseLeave={
- isMobile
- ? undefined
- : (event) => {
- if (!sliderRef.current || !previewFrames) {
- return;
- }
-
- const rect = sliderRef.current.getBoundingClientRect();
- const positionX = event.clientX - rect.left;
- const width = sliderRef.current.clientWidth;
- const progress = [
- Math.round((positionX / width) * previewFrames.length),
- ];
-
- setHoverTimeout(
- setTimeout(() => onStopManualSeek(progress), 500),
- );
- }
- }
- />
- )}
-
- );
-}
diff --git a/web/src/components/player/SearchThumbnailPlayer.tsx b/web/src/components/player/SearchThumbnailPlayer.tsx
new file mode 100644
index 000000000..db1f129b1
--- /dev/null
+++ b/web/src/components/player/SearchThumbnailPlayer.tsx
@@ -0,0 +1,331 @@
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { useApiHost } from "@/api";
+import { isCurrentHour } from "@/utils/dateUtil";
+import { getIconForLabel } from "@/utils/iconUtil";
+import TimeAgo from "../dynamic/TimeAgo";
+import useSWR from "swr";
+import { FrigateConfig } from "@/types/frigateConfig";
+import { isIOS, 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 { useSwipeable } from "react-swipeable";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
+import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
+import ActivityIndicator from "../indicators/activity-indicator";
+import { capitalizeFirstLetter } from "@/utils/stringUtil";
+import { VideoPreview } from "../preview/ScrubbablePreview";
+import { Preview } from "@/types/preview";
+import { SearchResult } from "@/types/search";
+import { LuInfo } from "react-icons/lu";
+import useContextMenu from "@/hooks/use-contextmenu";
+
+type SearchPlayerProps = {
+ searchResult: SearchResult;
+ allPreviews?: Preview[];
+ scrollLock?: boolean;
+ onClick: (searchResult: SearchResult, detail: boolean) => void;
+};
+
+export default function SearchThumbnailPlayer({
+ searchResult,
+ allPreviews,
+ scrollLock = false,
+ onClick,
+}: SearchPlayerProps) {
+ const apiHost = useApiHost();
+ const { data: config } = useSWR("config");
+ const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
+
+ // interaction
+
+ const [ignoreClick, setIgnoreClick] = useState(false);
+ const handleOnClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (!ignoreClick) {
+ onClick(searchResult, e.metaKey);
+ }
+ },
+ [ignoreClick, searchResult, onClick],
+ );
+
+ const swipeHandlers = useSwipeable({
+ onSwipedLeft: () => setPlayback(false),
+ onSwipedRight: () => setPlayback(true),
+ preventScrollOnSwipe: true,
+ });
+
+ useContextMenu(imgRef, () => {
+ onClick(searchResult, true);
+ });
+
+ // playback
+
+ const relevantPreview = useMemo(() => {
+ if (!allPreviews) {
+ return undefined;
+ }
+
+ let multiHour = false;
+ const firstIndex = Object.values(allPreviews).findIndex((preview) => {
+ if (
+ preview.camera != searchResult.camera ||
+ preview.end < searchResult.start_time
+ ) {
+ return false;
+ }
+
+ if ((searchResult.end_time ?? Date.now() / 1000) > preview.end) {
+ multiHour = true;
+ }
+
+ return true;
+ });
+
+ if (firstIndex == -1) {
+ return undefined;
+ }
+
+ if (!multiHour) {
+ return allPreviews[firstIndex];
+ }
+
+ const firstPrev = allPreviews[firstIndex];
+ const firstDuration = firstPrev.end - searchResult.start_time;
+ const secondDuration =
+ (searchResult.end_time ?? Date.now() / 1000) - firstPrev.end;
+
+ if (firstDuration > secondDuration) {
+ // the first preview is longer than the second, return the first
+ return firstPrev;
+ } else {
+ // the second preview is longer, return the second if it exists
+ if (firstIndex < allPreviews.length - 1) {
+ return allPreviews.find(
+ (preview, idx) =>
+ idx > firstIndex && preview.camera == searchResult.camera,
+ );
+ }
+
+ return undefined;
+ }
+ }, [allPreviews, searchResult]);
+
+ // Hover Playback
+
+ const [hoverTimeout, setHoverTimeout] = useState();
+ const [playback, setPlayback] = useState(false);
+ const [tooltipHovering, setTooltipHovering] = useState(false);
+ const playingBack = useMemo(
+ () => playback && !tooltipHovering,
+ [playback, tooltipHovering],
+ );
+ const [isHovered, setIsHovered] = useState(false);
+
+ useEffect(() => {
+ if (isHovered && scrollLock) {
+ return;
+ }
+
+ if (isHovered && !tooltipHovering) {
+ setHoverTimeout(
+ setTimeout(() => {
+ setPlayback(true);
+ setHoverTimeout(null);
+ }, 500),
+ );
+ } else {
+ if (hoverTimeout) {
+ clearTimeout(hoverTimeout);
+ }
+
+ setPlayback(false);
+ }
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isHovered, scrollLock, tooltipHovering]);
+
+ // date
+
+ const formattedDate = useFormattedTimestamp(
+ searchResult.start_time,
+ config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
+ );
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={isMobile ? undefined : () => setIsHovered(false)}
+ onClick={handleOnClick}
+ {...swipeHandlers}
+ >
+ {playingBack && (
+
+ )}
+
+
+

{
+ onImgLoad();
+ }}
+ />
+
+
+
+ setTooltipHovering(true)}
+ onMouseLeave={() => setTooltipHovering(false)}
+ >
+
+
+ {
+ <>
+
+ {getIconForLabel(
+ searchResult.label,
+ "size-3 text-white",
+ )}
+
+ >
+ }
+
+
+
+
+ {[...new Set([searchResult.label])]
+ .filter(
+ (item) => item !== undefined && !item.includes("-verified"),
+ )
+ .map((text) => capitalizeFirstLetter(text))
+ .sort()
+ .join(", ")
+ .replaceAll("-verified", "")}
+
+
+
+
+
+ setTooltipHovering(true)}
+ onMouseLeave={() => setTooltipHovering(false)}
+ >
+
+
+ {
+ <>
+ onClick(searchResult, true)}
+ >
+
+
+ >
+ }
+
+
+
+
+ View Detection Details
+
+
+
+ {!playingBack && (
+ <>
+
+
+
+ {searchResult.end_time ? (
+
+ ) : (
+
+ )}
+ {formattedDate}
+
+
+ >
+ )}
+
+
+ );
+}
+
+type PreviewContentProps = {
+ searchResult: SearchResult;
+ relevantPreview: Preview | undefined;
+ setIgnoreClick: (ignore: boolean) => void;
+ isPlayingBack: (ended: boolean) => void;
+ onTimeUpdate?: (time: number | undefined) => void;
+};
+function PreviewContent({
+ searchResult,
+ relevantPreview,
+ setIgnoreClick,
+ isPlayingBack,
+ onTimeUpdate,
+}: PreviewContentProps) {
+ // preview
+
+ if (relevantPreview) {
+ return (
+ {}}
+ />
+ );
+ } else if (isCurrentHour(searchResult.start_time)) {
+ return (
+ /* { }}
+ />*/
+
+ );
+ }
+}
diff --git a/web/src/components/preview/ScrubbablePreview.tsx b/web/src/components/preview/ScrubbablePreview.tsx
new file mode 100644
index 000000000..eb9a962a8
--- /dev/null
+++ b/web/src/components/preview/ScrubbablePreview.tsx
@@ -0,0 +1,486 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useApiHost } from "@/api";
+import { ReviewSegment } from "@/types/review";
+import useSWR from "swr";
+import { isFirefox, isMobile, isSafari } from "react-device-detect";
+import { TimelineScrubMode, TimeRange } from "@/types/timeline";
+import { NoThumbSlider } from "../ui/slider";
+import { PREVIEW_FPS, PREVIEW_PADDING, Preview } from "@/types/preview";
+import { baseUrl } from "@/api/baseUrl";
+
+type VideoPreviewProps = {
+ relevantPreview: Preview;
+ startTime: number;
+ endTime?: number;
+ showProgress?: boolean;
+ loop?: boolean;
+ setReviewed: () => void;
+ setIgnoreClick: (ignore: boolean) => void;
+ isPlayingBack: (ended: boolean) => void;
+ onTimeUpdate?: (time: number | undefined) => void;
+ windowVisible: boolean;
+};
+export function VideoPreview({
+ relevantPreview,
+ startTime,
+ endTime,
+ showProgress = true,
+ loop = false,
+ setReviewed,
+ setIgnoreClick,
+ isPlayingBack,
+ onTimeUpdate,
+ windowVisible,
+}: VideoPreviewProps) {
+ const playerRef = useRef(null);
+ const sliderRef = useRef(null);
+
+ // keep track of playback state
+
+ const [progress, setProgress] = useState(0);
+ const [hoverTimeout, setHoverTimeout] = useState();
+ const playerStartTime = useMemo(() => {
+ if (!relevantPreview) {
+ return 0;
+ }
+
+ // start with a bit of padding
+ return Math.max(0, startTime - relevantPreview.start - PREVIEW_PADDING);
+
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ const playerDuration = useMemo(
+ () => (endTime ?? relevantPreview.end) - startTime + PREVIEW_PADDING,
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+ const [lastPercent, setLastPercent] = useState(0.0);
+
+ // initialize player correctly
+
+ useEffect(() => {
+ if (!playerRef.current) {
+ return;
+ }
+
+ if (isSafari || (isFirefox && isMobile)) {
+ playerRef.current.pause();
+ setPlaybackMode("compat");
+ } else {
+ playerRef.current.currentTime = playerStartTime;
+ playerRef.current.playbackRate = PREVIEW_FPS;
+ }
+
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [playerRef]);
+
+ // time progress update
+
+ const onProgress = useCallback(() => {
+ if (!windowVisible) {
+ return;
+ }
+
+ if (onTimeUpdate) {
+ onTimeUpdate(
+ relevantPreview.start + (playerRef.current?.currentTime || 0),
+ );
+ }
+
+ const playerProgress =
+ (playerRef.current?.currentTime || 0) - playerStartTime;
+
+ // end with a bit of padding
+ const playerPercent = (playerProgress / playerDuration) * 100;
+
+ if (setReviewed && lastPercent < 50 && playerPercent > 50) {
+ setReviewed();
+ }
+
+ setLastPercent(playerPercent);
+
+ if (playerPercent > 100) {
+ setReviewed();
+
+ if (loop && playerRef.current) {
+ if (playbackMode != "auto") {
+ setPlaybackMode("auto");
+ setTimeout(() => setPlaybackMode("compat"), 100);
+ }
+
+ playerRef.current.currentTime = playerStartTime;
+ return;
+ }
+
+ if (isMobile) {
+ isPlayingBack(false);
+
+ if (onTimeUpdate) {
+ onTimeUpdate(undefined);
+ }
+ } else {
+ playerRef.current?.pause();
+ }
+
+ setPlaybackMode("auto");
+ setProgress(100.0);
+ } else {
+ setProgress(playerPercent);
+ }
+
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [setProgress, lastPercent, windowVisible]);
+
+ // manual playback
+ // safari is incapable of playing at a speed > 2x
+ // so manual seeking is required on iOS
+
+ const [playbackMode, setPlaybackMode] = useState("auto");
+
+ useEffect(() => {
+ if (playbackMode != "compat" || !playerRef.current) {
+ return;
+ }
+
+ let counter = 0;
+ const intervalId: NodeJS.Timeout = setInterval(() => {
+ if (playerRef.current) {
+ playerRef.current.currentTime = playerStartTime + counter;
+ counter += 1;
+ }
+ }, 1000 / PREVIEW_FPS);
+ return () => clearInterval(intervalId);
+
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [playbackMode, playerRef]);
+
+ // user interaction
+
+ useEffect(() => {
+ setIgnoreClick(playbackMode != "auto" && playbackMode != "compat");
+ }, [playbackMode, setIgnoreClick]);
+
+ const onManualSeek = useCallback(
+ (values: number[]) => {
+ const value = values[0];
+
+ if (!playerRef.current) {
+ return;
+ }
+
+ if (playerRef.current.paused == false) {
+ playerRef.current.pause();
+ }
+
+ if (setReviewed) {
+ setReviewed();
+ }
+
+ setProgress(value);
+ playerRef.current.currentTime =
+ playerStartTime + (value / 100.0) * playerDuration;
+ },
+
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [playerDuration, playerRef, playerStartTime, setIgnoreClick],
+ );
+
+ const onStopManualSeek = useCallback(() => {
+ setTimeout(() => {
+ setHoverTimeout(undefined);
+
+ if (isSafari || (isFirefox && isMobile)) {
+ setPlaybackMode("compat");
+ } else {
+ setPlaybackMode("auto");
+ playerRef.current?.play();
+ }
+ }, 500);
+ }, [playerRef]);
+
+ const onProgressHover = useCallback(
+ (event: React.MouseEvent) => {
+ if (!sliderRef.current) {
+ return;
+ }
+
+ const rect = sliderRef.current.getBoundingClientRect();
+ const positionX = event.clientX - rect.left;
+ const width = sliderRef.current.clientWidth;
+ onManualSeek([Math.round((positionX / width) * 100)]);
+
+ if (hoverTimeout) {
+ clearTimeout(hoverTimeout);
+ }
+ },
+ [sliderRef, hoverTimeout, onManualSeek],
+ );
+
+ return (
+
+
+ {showProgress && (
+ {
+ setPlaybackMode("drag");
+ onManualSeek(event);
+ }}
+ onValueCommit={onStopManualSeek}
+ min={0}
+ step={1}
+ max={100}
+ onMouseMove={
+ isMobile
+ ? undefined
+ : (event) => {
+ if (playbackMode != "drag") {
+ setPlaybackMode("hover");
+ onProgressHover(event);
+ }
+ }
+ }
+ onMouseLeave={
+ isMobile
+ ? undefined
+ : () => {
+ if (!sliderRef.current) {
+ return;
+ }
+
+ setHoverTimeout(setTimeout(() => onStopManualSeek(), 500));
+ }
+ }
+ />
+ )}
+
+ );
+}
+
+const MIN_LOAD_TIMEOUT_MS = 200;
+type InProgressPreviewProps = {
+ review: ReviewSegment;
+ timeRange: TimeRange;
+ showProgress?: boolean;
+ loop?: boolean;
+ setReviewed: (reviewId: string) => void;
+ setIgnoreClick: (ignore: boolean) => void;
+ isPlayingBack: (ended: boolean) => void;
+ onTimeUpdate?: (time: number | undefined) => void;
+ windowVisible: boolean;
+};
+export function InProgressPreview({
+ review,
+ timeRange,
+ showProgress = true,
+ loop = false,
+ setReviewed,
+ setIgnoreClick,
+ isPlayingBack,
+ onTimeUpdate,
+ windowVisible,
+}: InProgressPreviewProps) {
+ const apiHost = useApiHost();
+ const sliderRef = useRef(null);
+ const { data: previewFrames } = useSWR(
+ `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${
+ Math.ceil(review.end_time ?? timeRange.before) + PREVIEW_PADDING
+ }/frames`,
+ { revalidateOnFocus: false },
+ );
+
+ const [playbackMode, setPlaybackMode] = useState("auto");
+ const [hoverTimeout, setHoverTimeout] = useState();
+ const [key, setKey] = useState(0);
+
+ const handleLoad = useCallback(() => {
+ if (!previewFrames || !windowVisible) {
+ return;
+ }
+
+ if (onTimeUpdate) {
+ onTimeUpdate(review.start_time - PREVIEW_PADDING + key);
+ }
+
+ if (playbackMode != "auto") {
+ return;
+ }
+
+ if (key == previewFrames.length - 1) {
+ if (!review.has_been_reviewed) {
+ setReviewed(review.id);
+ }
+
+ if (loop) {
+ setKey(0);
+ return;
+ }
+
+ if (isMobile) {
+ isPlayingBack(false);
+
+ if (onTimeUpdate) {
+ onTimeUpdate(undefined);
+ }
+ }
+
+ return;
+ }
+
+ setTimeout(() => {
+ if (setReviewed && key == Math.floor(previewFrames.length / 2)) {
+ setReviewed(review.id);
+ }
+
+ if (previewFrames[key + 1]) {
+ setKey(key + 1);
+ }
+ }, MIN_LOAD_TIMEOUT_MS);
+
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, playbackMode, previewFrames]);
+
+ // user interaction
+
+ useEffect(() => {
+ setIgnoreClick(playbackMode != "auto");
+ }, [playbackMode, setIgnoreClick]);
+
+ const onManualSeek = useCallback(
+ (values: number[]) => {
+ const value = values[0];
+
+ if (!review.has_been_reviewed) {
+ setReviewed(review.id);
+ }
+
+ setKey(value);
+ },
+
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [setIgnoreClick, setKey],
+ );
+
+ const onStopManualSeek = useCallback(
+ (values: number[]) => {
+ const value = values[0];
+ setTimeout(() => {
+ setPlaybackMode("auto");
+ setKey(value - 1);
+ }, 500);
+ },
+ [setPlaybackMode],
+ );
+
+ const onProgressHover = useCallback(
+ (event: React.MouseEvent) => {
+ if (!sliderRef.current || !previewFrames) {
+ return;
+ }
+
+ const rect = sliderRef.current.getBoundingClientRect();
+ const positionX = event.clientX - rect.left;
+ const width = sliderRef.current.clientWidth;
+ const progress = [Math.round((positionX / width) * previewFrames.length)];
+ onManualSeek(progress);
+
+ if (hoverTimeout) {
+ clearTimeout(hoverTimeout);
+ }
+ },
+ [sliderRef, hoverTimeout, previewFrames, onManualSeek],
+ );
+
+ if (!previewFrames || previewFrames.length == 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+

+ {showProgress && (
+
{
+ setPlaybackMode("drag");
+ onManualSeek(event);
+ }}
+ onValueCommit={onStopManualSeek}
+ min={0}
+ step={1}
+ max={previewFrames.length - 1}
+ onMouseMove={
+ isMobile
+ ? undefined
+ : (event) => {
+ if (playbackMode != "drag") {
+ setPlaybackMode("hover");
+ onProgressHover(event);
+ }
+ }
+ }
+ onMouseLeave={
+ isMobile
+ ? undefined
+ : (event) => {
+ if (!sliderRef.current || !previewFrames) {
+ return;
+ }
+
+ const rect = sliderRef.current.getBoundingClientRect();
+ const positionX = event.clientX - rect.left;
+ const width = sliderRef.current.clientWidth;
+ const progress = [
+ Math.round((positionX / width) * previewFrames.length),
+ ];
+
+ setHoverTimeout(
+ setTimeout(() => onStopManualSeek(progress), 500),
+ );
+ }
+ }
+ />
+ )}
+
+ );
+}
diff --git a/web/src/components/ui/calendar-range.tsx b/web/src/components/ui/calendar-range.tsx
new file mode 100644
index 000000000..f940e772e
--- /dev/null
+++ b/web/src/components/ui/calendar-range.tsx
@@ -0,0 +1,444 @@
+/* eslint-disable max-lines */
+"use client";
+
+import { type FC, useState, useEffect, useRef } from "react";
+import { Button } from "./button";
+import { Calendar } from "./calendar";
+import { Label } from "./label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./select";
+import { Switch } from "./switch";
+import { cn } from "@/lib/utils";
+import { LuCheck } from "react-icons/lu";
+
+export interface DateRangePickerProps {
+ /** Click handler for applying the updates from DateRangePicker. */
+ onUpdate?: (values: { range: DateRange; rangeCompare?: DateRange }) => void;
+ /** Initial value for start date */
+ initialDateFrom?: Date | string;
+ /** Initial value for end date */
+ initialDateTo?: Date | string;
+ /** Initial value for start date for compare */
+ initialCompareFrom?: Date | string;
+ /** Initial value for end date for compare */
+ initialCompareTo?: Date | string;
+ /** Alignment of popover */
+ align?: "start" | "center" | "end";
+ /** Option for locale */
+ locale?: string;
+ /** Option for showing compare feature */
+ showCompare?: boolean;
+}
+
+const getDateAdjustedForTimezone = (dateInput: Date | string): Date => {
+ if (typeof dateInput === "string") {
+ // Split the date string to get year, month, and day parts
+ const parts = dateInput.split("-").map((part) => parseInt(part, 10));
+ // Create a new Date object using the local timezone
+ // Note: Month is 0-indexed, so subtract 1 from the month part
+ const date = new Date(parts[0], parts[1] - 1, parts[2]);
+ return date;
+ } else {
+ // If dateInput is already a Date object, return it directly
+ return dateInput;
+ }
+};
+
+interface DateRange {
+ from: Date;
+ to: Date | undefined;
+}
+
+interface Preset {
+ name: string;
+ label: string;
+}
+
+// Define presets
+const PRESETS: Preset[] = [
+ { name: "today", label: "Today" },
+ { name: "yesterday", label: "Yesterday" },
+ { name: "last7", label: "Last 7 days" },
+ { name: "last14", label: "Last 14 days" },
+ { name: "last30", label: "Last 30 days" },
+ { name: "thisWeek", label: "This Week" },
+ { name: "lastWeek", label: "Last Week" },
+ { name: "thisMonth", label: "This Month" },
+ { name: "lastMonth", label: "Last Month" },
+];
+
+/** The DateRangePicker component allows a user to select a range of dates */
+export const DateRangePicker: FC = ({
+ initialDateFrom = new Date(new Date().setHours(0, 0, 0, 0)),
+ initialDateTo,
+ initialCompareFrom,
+ initialCompareTo,
+ onUpdate,
+ showCompare = true,
+}): JSX.Element => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const [range, setRange] = useState({
+ from: getDateAdjustedForTimezone(initialDateFrom),
+ to: initialDateTo
+ ? getDateAdjustedForTimezone(initialDateTo)
+ : getDateAdjustedForTimezone(initialDateFrom),
+ });
+ const [rangeCompare, setRangeCompare] = useState(
+ initialCompareFrom
+ ? {
+ from: new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)),
+ to: initialCompareTo
+ ? new Date(new Date(initialCompareTo).setHours(0, 0, 0, 0))
+ : new Date(new Date(initialCompareFrom).setHours(0, 0, 0, 0)),
+ }
+ : undefined,
+ );
+
+ // Refs to store the values of range and rangeCompare when the date picker is opened
+ const openedRangeRef = useRef();
+ const openedRangeCompareRef = useRef();
+
+ const [selectedPreset, setSelectedPreset] = useState(
+ undefined,
+ );
+
+ const [isSmallScreen, setIsSmallScreen] = useState(
+ typeof window !== "undefined" ? window.innerWidth < 960 : false,
+ );
+
+ useEffect(() => {
+ const handleResize = (): void => {
+ setIsSmallScreen(window.innerWidth < 960);
+ };
+
+ window.addEventListener("resize", handleResize);
+
+ // Clean up event listener on unmount
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ };
+ }, []);
+
+ const getPresetRange = (presetName: string): DateRange => {
+ const preset = PRESETS.find(({ name }) => name === presetName);
+ if (!preset) throw new Error(`Unknown date range preset: ${presetName}`);
+ const from = new Date();
+ const to = new Date();
+ const first = from.getDate() - from.getDay();
+
+ switch (preset.name) {
+ case "today":
+ from.setHours(0, 0, 0, 0);
+ to.setHours(23, 59, 59, 999);
+ break;
+ case "yesterday":
+ from.setDate(from.getDate() - 1);
+ from.setHours(0, 0, 0, 0);
+ to.setDate(to.getDate() - 1);
+ to.setHours(23, 59, 59, 999);
+ break;
+ case "last7":
+ from.setDate(from.getDate() - 6);
+ from.setHours(0, 0, 0, 0);
+ to.setHours(23, 59, 59, 999);
+ break;
+ case "last14":
+ from.setDate(from.getDate() - 13);
+ from.setHours(0, 0, 0, 0);
+ to.setHours(23, 59, 59, 999);
+ break;
+ case "last30":
+ from.setDate(from.getDate() - 29);
+ from.setHours(0, 0, 0, 0);
+ to.setHours(23, 59, 59, 999);
+ break;
+ case "thisWeek":
+ from.setDate(first);
+ from.setHours(0, 0, 0, 0);
+ to.setHours(23, 59, 59, 999);
+ break;
+ case "lastWeek":
+ from.setDate(from.getDate() - 7 - from.getDay());
+ to.setDate(to.getDate() - to.getDay() - 1);
+ from.setHours(0, 0, 0, 0);
+ to.setHours(23, 59, 59, 999);
+ break;
+ case "thisMonth":
+ from.setDate(1);
+ from.setHours(0, 0, 0, 0);
+ to.setHours(23, 59, 59, 999);
+ break;
+ case "lastMonth":
+ from.setMonth(from.getMonth() - 1);
+ from.setDate(1);
+ from.setHours(0, 0, 0, 0);
+ to.setDate(0);
+ to.setHours(23, 59, 59, 999);
+ break;
+ }
+
+ return { from, to };
+ };
+
+ const setPreset = (preset: string): void => {
+ const range = getPresetRange(preset);
+ setRange(range);
+ if (rangeCompare) {
+ const rangeCompare = {
+ from: new Date(
+ range.from.getFullYear() - 1,
+ range.from.getMonth(),
+ range.from.getDate(),
+ ),
+ to: range.to
+ ? new Date(
+ range.to.getFullYear() - 1,
+ range.to.getMonth(),
+ range.to.getDate(),
+ )
+ : undefined,
+ };
+ setRangeCompare(rangeCompare);
+ }
+ };
+
+ const checkPreset = (): void => {
+ for (const preset of PRESETS) {
+ const presetRange = getPresetRange(preset.name);
+
+ const normalizedRangeFrom = new Date(range.from);
+ normalizedRangeFrom.setHours(0, 0, 0, 0);
+ const normalizedPresetFrom = new Date(
+ presetRange.from.setHours(0, 0, 0, 0),
+ );
+
+ const normalizedRangeTo = new Date(range.to ?? 0);
+ normalizedRangeTo.setHours(0, 0, 0, 0);
+ const normalizedPresetTo = new Date(
+ presetRange.to?.setHours(0, 0, 0, 0) ?? 0,
+ );
+
+ if (
+ normalizedRangeFrom.getTime() === normalizedPresetFrom.getTime() &&
+ normalizedRangeTo.getTime() === normalizedPresetTo.getTime()
+ ) {
+ setSelectedPreset(preset.name);
+ return;
+ }
+ }
+
+ setSelectedPreset(undefined);
+ };
+
+ const resetValues = (): void => {
+ setRange({
+ from:
+ typeof initialDateFrom === "string"
+ ? getDateAdjustedForTimezone(initialDateFrom)
+ : initialDateFrom,
+ to: initialDateTo
+ ? typeof initialDateTo === "string"
+ ? getDateAdjustedForTimezone(initialDateTo)
+ : initialDateTo
+ : typeof initialDateFrom === "string"
+ ? getDateAdjustedForTimezone(initialDateFrom)
+ : initialDateFrom,
+ });
+ setRangeCompare(
+ initialCompareFrom
+ ? {
+ from:
+ typeof initialCompareFrom === "string"
+ ? getDateAdjustedForTimezone(initialCompareFrom)
+ : initialCompareFrom,
+ to: initialCompareTo
+ ? typeof initialCompareTo === "string"
+ ? getDateAdjustedForTimezone(initialCompareTo)
+ : initialCompareTo
+ : typeof initialCompareFrom === "string"
+ ? getDateAdjustedForTimezone(initialCompareFrom)
+ : initialCompareFrom,
+ }
+ : undefined,
+ );
+ };
+
+ useEffect(() => {
+ checkPreset();
+ }, [range]);
+
+ const PresetButton = ({
+ preset,
+ label,
+ isSelected,
+ }: {
+ preset: string;
+ label: string;
+ isSelected: boolean;
+ }): JSX.Element => (
+
+ );
+
+ // Helper function to check if two date ranges are equal
+ const areRangesEqual = (a?: DateRange, b?: DateRange): boolean => {
+ if (!a || !b) return a === b; // If either is undefined, return true if both are undefined
+ return (
+ a.from.getTime() === b.from.getTime() &&
+ (!a.to || !b.to || a.to.getTime() === b.to.getTime())
+ );
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ openedRangeRef.current = range;
+ openedRangeCompareRef.current = rangeCompare;
+ }
+ }, [isOpen]);
+
+ return (
+
+
+
+
+
+ {showCompare && (
+
+ {
+ if (checked) {
+ if (!range.to) {
+ setRange({
+ from: range.from,
+ to: range.from,
+ });
+ }
+ setRangeCompare({
+ from: new Date(
+ range.from.getFullYear(),
+ range.from.getMonth(),
+ range.from.getDate() - 365,
+ ),
+ to: range.to
+ ? new Date(
+ range.to.getFullYear() - 1,
+ range.to.getMonth(),
+ range.to.getDate(),
+ )
+ : new Date(
+ range.from.getFullYear() - 1,
+ range.from.getMonth(),
+ range.from.getDate(),
+ ),
+ });
+ } else {
+ setRangeCompare(undefined);
+ }
+ }}
+ id="compare-mode"
+ />
+
+
+ )}
+
+ {isSmallScreen && (
+
+ )}
+
+ {
+ if (value?.from != null) {
+ setRange({ from: value.from, to: value?.to });
+ }
+ }}
+ selected={range}
+ numberOfMonths={isSmallScreen ? 1 : 2}
+ defaultMonth={
+ new Date(
+ new Date().setMonth(
+ new Date().getMonth() - (isSmallScreen ? 0 : 1),
+ ),
+ )
+ }
+ />
+
+
+
+ {!isSmallScreen && (
+
+
+ {PRESETS.map((preset) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/web/src/components/ui/textarea.tsx b/web/src/components/ui/textarea.tsx
new file mode 100644
index 000000000..9f9a6dc56
--- /dev/null
+++ b/web/src/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/web/src/hooks/use-date-utils.ts b/web/src/hooks/use-date-utils.ts
index 14890c5c0..c234cadd4 100644
--- a/web/src/hooks/use-date-utils.ts
+++ b/web/src/hooks/use-date-utils.ts
@@ -12,6 +12,21 @@ export function useFormattedTimestamp(timestamp: number, format: string) {
return formattedTimestamp;
}
+export function useFormattedRange(start: number, end: number, format: string) {
+ const formattedStart = useMemo(() => {
+ return formatUnixTimestampToDateTime(start, {
+ strftime_fmt: format,
+ });
+ }, [format, start]);
+ const formattedEnd = useMemo(() => {
+ return formatUnixTimestampToDateTime(end, {
+ strftime_fmt: format,
+ });
+ }, [format, end]);
+
+ return `${formattedStart} - ${formattedEnd}`;
+}
+
export function useTimezone(config: FrigateConfig | undefined) {
return useMemo(() => {
if (!config) {
diff --git a/web/src/hooks/use-navigation.ts b/web/src/hooks/use-navigation.ts
index 771dde088..fde200839 100644
--- a/web/src/hooks/use-navigation.ts
+++ b/web/src/hooks/use-navigation.ts
@@ -4,10 +4,18 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { NavData } from "@/types/navigation";
import { useMemo } from "react";
import { FaCompactDisc, FaVideo } from "react-icons/fa";
+import { IoSearch } from "react-icons/io5";
import { LuConstruction } from "react-icons/lu";
import { MdVideoLibrary } from "react-icons/md";
import useSWR from "swr";
+export const ID_LIVE = 1;
+export const ID_REVIEW = 2;
+export const ID_SEARCH = 3;
+export const ID_EXPORT = 4;
+export const ID_PLUS = 5;
+export const ID_PLAYGROUND = 6;
+
export default function useNavigation(
variant: "primary" | "secondary" = "primary",
) {
@@ -17,28 +25,36 @@ export default function useNavigation(
() =>
[
{
- id: 1,
+ id: ID_LIVE,
variant,
icon: FaVideo,
title: "Live",
url: "/",
},
{
- id: 2,
+ id: ID_REVIEW,
variant,
icon: MdVideoLibrary,
title: "Review",
url: "/review",
},
{
- id: 3,
+ id: ID_SEARCH,
+ variant,
+ icon: IoSearch,
+ title: "Search",
+ url: "/search",
+ enabled: config?.semantic_search?.enabled,
+ },
+ {
+ id: ID_EXPORT,
variant,
icon: FaCompactDisc,
title: "Export",
url: "/export",
},
{
- id: 5,
+ id: ID_PLUS,
variant,
icon: Logo,
title: "Frigate+",
@@ -46,7 +62,7 @@ export default function useNavigation(
enabled: config?.plus?.enabled == true,
},
{
- id: 4,
+ id: ID_PLAYGROUND,
variant,
icon: LuConstruction,
title: "UI Playground",
@@ -54,6 +70,6 @@ export default function useNavigation(
enabled: ENV !== "production",
},
] as NavData[],
- [config?.plus.enabled, variant],
+ [config?.plus.enabled, config?.semantic_search.enabled, variant],
);
}
diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx
index 07675d87e..68e5c9adc 100644
--- a/web/src/pages/Events.tsx
+++ b/web/src/pages/Events.tsx
@@ -15,7 +15,7 @@ import {
SegmentedReviewData,
} from "@/types/review";
import EventView from "@/views/events/EventView";
-import { RecordingView } from "@/views/events/RecordingView";
+import { RecordingView } from "@/views/recording/RecordingView";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
diff --git a/web/src/pages/Search.tsx b/web/src/pages/Search.tsx
new file mode 100644
index 000000000..e6725f6dd
--- /dev/null
+++ b/web/src/pages/Search.tsx
@@ -0,0 +1,201 @@
+import useApiFilter from "@/hooks/use-api-filter";
+import { useCameraPreviews } from "@/hooks/use-camera-previews";
+import { useOverlayState } from "@/hooks/use-overlay-state";
+import { FrigateConfig } from "@/types/frigateConfig";
+import { RecordingStartingPoint } from "@/types/record";
+import { SearchFilter, SearchResult } from "@/types/search";
+import { TimeRange } from "@/types/timeline";
+import { RecordingView } from "@/views/recording/RecordingView";
+import SearchView from "@/views/search/SearchView";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import useSWR from "swr";
+
+export default function Search() {
+ const { data: config } = useSWR("config", {
+ revalidateOnFocus: false,
+ });
+
+ // search field handler
+
+ const [searchTimeout, setSearchTimeout] = useState();
+ const [search, setSearch] = useState("");
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const [recording, setRecording] =
+ useOverlayState("recording");
+
+ // search filter
+
+ const [searchFilter, setSearchFilter, searchSearchParams] =
+ useApiFilter();
+
+ const onUpdateFilter = useCallback(
+ (newFilter: SearchFilter) => {
+ setSearchFilter(newFilter);
+ },
+ [setSearchFilter],
+ );
+
+ // search api
+
+ const [similaritySearch, setSimilaritySearch] = useState();
+
+ useEffect(() => {
+ if (similaritySearch) {
+ setSimilaritySearch(undefined);
+ }
+
+ if (searchTimeout) {
+ clearTimeout(searchTimeout);
+ }
+
+ setSearchTimeout(
+ setTimeout(() => {
+ setSearchTimeout(undefined);
+ setSearchTerm(search);
+ }, 500),
+ );
+ // we only want to update the searchTerm when search changes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [search]);
+
+ const searchQuery = useMemo(() => {
+ if (searchTerm.length == 0) {
+ return null;
+ }
+
+ if (similaritySearch) {
+ return [
+ "events/search",
+ {
+ query: similaritySearch.id,
+ cameras: searchSearchParams["cameras"],
+ labels: searchSearchParams["labels"],
+ zones: searchSearchParams["zones"],
+ before: searchSearchParams["before"],
+ after: searchSearchParams["after"],
+ include_thumbnails: 0,
+ search_type: "thumbnail",
+ },
+ ];
+ }
+
+ return [
+ "events/search",
+ {
+ query: searchTerm,
+ cameras: searchSearchParams["cameras"],
+ labels: searchSearchParams["labels"],
+ zones: searchSearchParams["zones"],
+ before: searchSearchParams["before"],
+ after: searchSearchParams["after"],
+ include_thumbnails: 0,
+ },
+ ];
+ }, [searchTerm, searchSearchParams, similaritySearch]);
+
+ const { data: searchResults, isLoading } =
+ useSWR(searchQuery);
+
+ const previewTimeRange = useMemo(() => {
+ if (!searchResults) {
+ return { after: 0, before: 0 };
+ }
+
+ return {
+ after: Math.min(...searchResults.map((res) => res.start_time)),
+ before: Math.max(
+ ...searchResults.map((res) => res.end_time ?? Date.now() / 1000),
+ ),
+ };
+ }, [searchResults]);
+
+ const allPreviews = useCameraPreviews(previewTimeRange, {
+ autoRefresh: false,
+ fetchPreviews: searchResults != undefined,
+ });
+
+ // selection
+
+ const onOpenSearch = useCallback(
+ (item: SearchResult) => {
+ setRecording({
+ camera: item.camera,
+ startTime: item.start_time,
+ severity: "alert",
+ });
+ },
+ [setRecording],
+ );
+
+ const selectedReviewData = useMemo(() => {
+ if (!recording) {
+ return undefined;
+ }
+
+ if (!config) {
+ return undefined;
+ }
+
+ if (!searchResults) {
+ return undefined;
+ }
+
+ const allCameras = searchFilter?.cameras ?? Object.keys(config.cameras);
+
+ return {
+ camera: recording.camera,
+ start_time: recording.startTime,
+ allCameras: allCameras,
+ };
+
+ // previews will not update after item is selected
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [recording, searchResults]);
+
+ const selectedTimeRange = useMemo(() => {
+ if (!recording) {
+ return undefined;
+ }
+
+ const time = new Date(recording.startTime * 1000);
+ time.setUTCMinutes(0, 0, 0);
+ const start = time.getTime() / 1000;
+ time.setHours(time.getHours() + 2);
+ const end = time.getTime() / 1000;
+ return {
+ after: start,
+ before: end,
+ };
+ }, [recording]);
+
+ if (recording) {
+ if (selectedReviewData && selectedTimeRange) {
+ return (
+
+ );
+ }
+ } else {
+ return (
+
+ );
+ }
+}
diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx
index 9424a3350..d91bdb147 100644
--- a/web/src/pages/SubmitPlus.tsx
+++ b/web/src/pages/SubmitPlus.tsx
@@ -1,8 +1,6 @@
import { baseUrl } from "@/api/baseUrl";
-import {
- CamerasFilterButton,
- GeneralFilterContent,
-} from "@/components/filter/ReviewFilterGroup";
+import { CamerasFilterButton } from "@/components/filter/CamerasFilterButton";
+import { GeneralFilterContent } from "@/components/filter/ReviewFilterGroup";
import Chip from "@/components/indicators/Chip";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts
index a38cbd847..5529db9c9 100644
--- a/web/src/types/frigateConfig.ts
+++ b/web/src/types/frigateConfig.ts
@@ -398,6 +398,10 @@ export interface FrigateConfig {
enabled: boolean;
};
+ semantic_search: {
+ enabled: boolean;
+ }
+
snapshots: {
bounding_box: boolean;
clean_copy: boolean;
diff --git a/web/src/types/search.ts b/web/src/types/search.ts
new file mode 100644
index 000000000..1cede118d
--- /dev/null
+++ b/web/src/types/search.ts
@@ -0,0 +1,20 @@
+export type SearchResult = {
+ id: string;
+ camera: string;
+ description?: string;
+ start_time: number;
+ end_time?: number;
+ score: number;
+ label: string;
+ sub_label?: string;
+ thumb_path?: string;
+ zones: string[];
+};
+
+export type SearchFilter = {
+ cameras?: string[];
+ labels?: string[];
+ zones?: string[];
+ before?: number;
+ after?: number;
+};
diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx
similarity index 100%
rename from web/src/views/events/RecordingView.tsx
rename to web/src/views/recording/RecordingView.tsx
diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx
new file mode 100644
index 000000000..52345f2a2
--- /dev/null
+++ b/web/src/views/search/SearchView.tsx
@@ -0,0 +1,126 @@
+import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
+import ActivityIndicator from "@/components/indicators/activity-indicator";
+import SearchDetailDialog from "@/components/overlay/SearchDetailDialog";
+import SearchThumbnailPlayer from "@/components/player/SearchThumbnailPlayer";
+import { Input } from "@/components/ui/input";
+import { Toaster } from "@/components/ui/sonner";
+import { Preview } from "@/types/preview";
+import { SearchFilter, SearchResult } from "@/types/search";
+import { useCallback, useState } from "react";
+import { LuSearchCheck, LuSearchX } from "react-icons/lu";
+
+type SearchViewProps = {
+ search: string;
+ searchTerm: string;
+ searchFilter?: SearchFilter;
+ searchResults?: SearchResult[];
+ allPreviews?: Preview[];
+ isLoading: boolean;
+ setSearch: (search: string) => void;
+ setSimilaritySearch: (search: SearchResult) => void;
+ onUpdateFilter: (filter: SearchFilter) => void;
+ onOpenSearch: (item: SearchResult) => void;
+};
+export default function SearchView({
+ search,
+ searchTerm,
+ searchFilter,
+ searchResults,
+ allPreviews,
+ isLoading,
+ setSearch,
+ setSimilaritySearch,
+ onUpdateFilter,
+ onOpenSearch,
+}: SearchViewProps) {
+ // detail
+
+ const [searchDetail, setSearchDetail] = useState();
+
+ // search interaction
+
+ const onSelectSearch = useCallback(
+ (item: SearchResult, detail: boolean) => {
+ if (detail) {
+ setSearchDetail(item);
+ } else {
+ onOpenSearch(item);
+ }
+ },
+ [onOpenSearch],
+ );
+
+ return (
+
+
+
setSimilaritySearch(searchDetail))
+ }
+ />
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+
+ {searchTerm.length == 0 && (
+
+
+ Search For Detections
+
+ )}
+
+ {searchTerm.length > 0 && searchResults?.length == 0 && (
+
+
+ No Detections Found
+
+ )}
+
+ {isLoading && (
+
+ )}
+
+
+ {searchResults &&
+ searchResults.map((value) => {
+ const selected = false;
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+}