mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-28 23:06:13 +02:00
This reverts commit d24b96d3bb.
This commit is contained in:
@@ -47,7 +47,7 @@ export default function ProtectedRoute({
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// Authenticated mode (external port): require login
|
||||
// Authenticated mode (8971): require login
|
||||
if (!auth.user) {
|
||||
return (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConnectionQualityIndicatorProps = {
|
||||
quality: "excellent" | "fair" | "poor" | "unusable";
|
||||
expectedFps: number;
|
||||
reconnects: number;
|
||||
stalls: number;
|
||||
};
|
||||
|
||||
export function ConnectionQualityIndicator({
|
||||
quality,
|
||||
expectedFps,
|
||||
reconnects,
|
||||
stalls,
|
||||
}: ConnectionQualityIndicatorProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
|
||||
const getColorClass = (quality: string): string => {
|
||||
switch (quality) {
|
||||
case "excellent":
|
||||
return "bg-success";
|
||||
case "fair":
|
||||
return "bg-yellow-500";
|
||||
case "poor":
|
||||
return "bg-orange-500";
|
||||
case "unusable":
|
||||
return "bg-destructive";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const qualityLabel = t(`cameras.connectionQuality.${quality}`);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-block size-3 cursor-pointer rounded-full",
|
||||
getColorClass(quality),
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold">
|
||||
{t("cameras.connectionQuality.title")}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="capitalize">{qualityLabel}</div>
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div>
|
||||
{t("cameras.connectionQuality.expectedFps")}:{" "}
|
||||
{expectedFps.toFixed(1)} {t("cameras.connectionQuality.fps")}
|
||||
</div>
|
||||
<div>
|
||||
{t("cameras.connectionQuality.reconnectsLastHour")}:{" "}
|
||||
{reconnects}
|
||||
</div>
|
||||
<div>
|
||||
{t("cameras.connectionQuality.stallsLastHour")}: {stalls}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { LuTrash } from "react-icons/lu";
|
||||
import { Button } from "../ui/button";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import { useCallback, useState } from "react";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,81 +14,35 @@ import {
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { DeleteClipType, Export, ExportCase } from "@/types/export";
|
||||
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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||
import BlurredIconButton from "../button/BlurredIconButton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
|
||||
type CaseCardProps = {
|
||||
className: string;
|
||||
exportCase: ExportCase;
|
||||
exports: Export[];
|
||||
onSelect: () => void;
|
||||
};
|
||||
export function CaseCard({
|
||||
className,
|
||||
exportCase,
|
||||
exports,
|
||||
onSelect,
|
||||
}: CaseCardProps) {
|
||||
const firstExport = useMemo(
|
||||
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
|
||||
[exports],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex aspect-video size-full cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-secondary md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={() => onSelect()}
|
||||
>
|
||||
{firstExport && (
|
||||
<img
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
src={`${baseUrl}${firstExport.thumb_path.replace("/media/frigate/", "")}`}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute bottom-2 left-2 z-20 flex items-center justify-start gap-2 text-white">
|
||||
<FaFolder />
|
||||
<div className="capitalize">{exportCase.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ExportCardProps = {
|
||||
type ExportProps = {
|
||||
className: string;
|
||||
exportedRecording: Export;
|
||||
onSelect: (selected: Export) => void;
|
||||
onRename: (original: string, update: string) => void;
|
||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||
onAssignToCase?: (selected: Export) => void;
|
||||
};
|
||||
export function ExportCard({
|
||||
|
||||
export default function ExportCard({
|
||||
className,
|
||||
exportedRecording,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
onAssignToCase,
|
||||
}: ExportCardProps) {
|
||||
}: ExportProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [loading, setLoading] = useState(
|
||||
exportedRecording.thumb_path.length > 0,
|
||||
);
|
||||
@@ -181,14 +136,12 @@ export function ExportCard({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
||||
"relative flex aspect-video items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!exportedRecording.in_progress) {
|
||||
onSelect(exportedRecording);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
|
||||
onClick={isDesktop ? undefined : () => setHovered(!hovered)}
|
||||
>
|
||||
{exportedRecording.in_progress ? (
|
||||
<ActivityIndicator />
|
||||
@@ -205,88 +158,95 @@ export function ExportCard({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!exportedRecording.in_progress && (
|
||||
<div className="absolute bottom-2 right-3 z-40">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<BlurredIconButton
|
||||
aria-label={t("tooltip.editName")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FiMoreVertical className="size-5" />
|
||||
</BlurredIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("tooltip.shareExport")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
shareOrCopy(
|
||||
`${baseUrl}export?id=${exportedRecording.id}`,
|
||||
exportedRecording.name.replaceAll("_", " "),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("tooltip.shareExport")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("tooltip.downloadVideo")}
|
||||
>
|
||||
{hovered && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
|
||||
<div className="absolute right-3 top-2">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{!exportedRecording.in_progress && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={() =>
|
||||
shareOrCopy(
|
||||
`${baseUrl}export?id=${exportedRecording.id}`,
|
||||
exportedRecording.name.replaceAll("_", " "),
|
||||
)
|
||||
}
|
||||
>
|
||||
<FaShareAlt className="size-4" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tooltip.shareExport")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!exportedRecording.in_progress && (
|
||||
<a
|
||||
download
|
||||
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t("tooltip.downloadVideo")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton>
|
||||
<FaDownload className="size-4" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("tooltip.downloadVideo")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && onAssignToCase && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("tooltip.assignToCase")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAssignToCase(exportedRecording);
|
||||
}}
|
||||
>
|
||||
{t("tooltip.assignToCase")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && !exportedRecording.in_progress && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={() =>
|
||||
setEditName({
|
||||
original: exportedRecording.name,
|
||||
update: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<MdEditSquare className="size-4" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("tooltip.editName")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditName({
|
||||
original: exportedRecording.name,
|
||||
update: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("tooltip.editName")}
|
||||
</DropdownMenuItem>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={() =>
|
||||
onDelete({
|
||||
file: exportedRecording.id,
|
||||
exportName: exportedRecording.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("tooltip.deleteExport")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete({
|
||||
file: exportedRecording.id,
|
||||
exportName: exportedRecording.name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("tooltip.deleteExport")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!exportedRecording.in_progress && (
|
||||
<Button
|
||||
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
|
||||
aria-label={t("button.play", { ns: "common" })}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onSelect(exportedRecording);
|
||||
}}
|
||||
>
|
||||
<FaPlay />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loading && (
|
||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DEFAULT_EXPORT_FILTERS,
|
||||
ExportFilter,
|
||||
ExportFilters,
|
||||
} from "@/types/export";
|
||||
import { CamerasFilterButton } from "./CamerasFilterButton";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useMemo } from "react";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
|
||||
type ExportFilterGroupProps = {
|
||||
className: string;
|
||||
filters?: ExportFilters[];
|
||||
filter?: ExportFilter;
|
||||
onUpdateFilter: (filter: ExportFilter) => void;
|
||||
};
|
||||
export default function ExportFilterGroup({
|
||||
className,
|
||||
filter,
|
||||
filters = DEFAULT_EXPORT_FILTERS,
|
||||
onUpdateFilter,
|
||||
}: ExportFilterGroupProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const allowedCameras = useAllowedCameras();
|
||||
|
||||
const filterValues = useMemo(
|
||||
() => ({
|
||||
cameras: allowedCameras,
|
||||
}),
|
||||
[allowedCameras],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(config.camera_groups).sort(
|
||||
(a, b) => a[1].order - b[1].order,
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"scrollbar-container flex justify-center gap-2 overflow-x-auto",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{filters.includes("cameras") && (
|
||||
<CamerasFilterButton
|
||||
allCameras={filterValues.cameras}
|
||||
groups={groups}
|
||||
selectedCameras={filter?.cameras}
|
||||
hideText={false}
|
||||
updateCameraFilter={(newCameras) => {
|
||||
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,14 +22,7 @@ import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { SelectSeparator } from "../ui/select";
|
||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import SaveExportOverlay from "./SaveExportOverlay";
|
||||
@@ -38,7 +31,6 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExportCase } from "@/types/export";
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
"1",
|
||||
@@ -75,9 +67,6 @@ export default function ExportDialog({
|
||||
}: ExportDialogProps) {
|
||||
const { t } = useTranslation(["components/dialog"]);
|
||||
const [name, setName] = useState("");
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const onStartExport = useCallback(() => {
|
||||
if (!range) {
|
||||
@@ -100,7 +89,6 @@ export default function ExportDialog({
|
||||
{
|
||||
playback: "realtime",
|
||||
name,
|
||||
export_case_id: selectedCaseId || undefined,
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
@@ -114,7 +102,6 @@ export default function ExportDialog({
|
||||
),
|
||||
});
|
||||
setName("");
|
||||
setSelectedCaseId(undefined);
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
}
|
||||
@@ -131,11 +118,10 @@ export default function ExportDialog({
|
||||
{ position: "top-center" },
|
||||
);
|
||||
});
|
||||
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
|
||||
}, [camera, name, range, setRange, setName, setMode, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setName("");
|
||||
setSelectedCaseId(undefined);
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
}, [setMode, setRange]);
|
||||
@@ -204,10 +190,8 @@ export default function ExportDialog({
|
||||
currentTime={currentTime}
|
||||
range={range}
|
||||
name={name}
|
||||
selectedCaseId={selectedCaseId}
|
||||
onStartExport={onStartExport}
|
||||
setName={setName}
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setRange={setRange}
|
||||
setMode={setMode}
|
||||
onCancel={handleCancel}
|
||||
@@ -223,10 +207,8 @@ type ExportContentProps = {
|
||||
currentTime: number;
|
||||
range?: TimeRange;
|
||||
name: string;
|
||||
selectedCaseId?: string;
|
||||
onStartExport: () => void;
|
||||
setName: (name: string) => void;
|
||||
setSelectedCaseId: (caseId: string | undefined) => void;
|
||||
setRange: (range: TimeRange | undefined) => void;
|
||||
setMode: (mode: ExportMode) => void;
|
||||
onCancel: () => void;
|
||||
@@ -236,17 +218,14 @@ export function ExportContent({
|
||||
currentTime,
|
||||
range,
|
||||
name,
|
||||
selectedCaseId,
|
||||
onStartExport,
|
||||
setName,
|
||||
setSelectedCaseId,
|
||||
setRange,
|
||||
setMode,
|
||||
onCancel,
|
||||
}: ExportContentProps) {
|
||||
const { t } = useTranslation(["components/dialog"]);
|
||||
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
||||
const { data: cases } = useSWR<ExportCase[]>("cases");
|
||||
|
||||
const onSelectTime = useCallback(
|
||||
(option: ExportOption) => {
|
||||
@@ -341,44 +320,6 @@ export function ExportContent({
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<div className="my-4">
|
||||
<Label className="text-sm text-secondary-foreground">
|
||||
{t("export.case.label", { defaultValue: "Case (optional)" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCaseId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedCaseId(value === "none" ? undefined : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue
|
||||
placeholder={t("export.case.placeholder", {
|
||||
defaultValue: "Select a case (optional)",
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
value="none"
|
||||
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("label.none", { ns: "common" })}
|
||||
</SelectItem>
|
||||
{cases
|
||||
?.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((caseItem) => (
|
||||
<SelectItem
|
||||
key={caseItem.id}
|
||||
value={caseItem.id}
|
||||
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{caseItem.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
||||
<DialogFooter
|
||||
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
|
||||
|
||||
@@ -75,9 +75,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
// exports
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const onStartExport = useCallback(() => {
|
||||
if (!range) {
|
||||
toast.error(t("toast.error.noValidTimeSelected"), {
|
||||
@@ -99,7 +96,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
{
|
||||
playback: "realtime",
|
||||
name,
|
||||
export_case_id: selectedCaseId || undefined,
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
@@ -118,7 +114,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
},
|
||||
);
|
||||
setName("");
|
||||
setSelectedCaseId(undefined);
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
}
|
||||
@@ -138,7 +133,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
},
|
||||
);
|
||||
});
|
||||
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
|
||||
}, [camera, name, range, setRange, setName, setMode, t]);
|
||||
|
||||
// filters
|
||||
|
||||
@@ -205,10 +200,8 @@ export default function MobileReviewSettingsDrawer({
|
||||
currentTime={currentTime}
|
||||
range={range}
|
||||
name={name}
|
||||
selectedCaseId={selectedCaseId}
|
||||
onStartExport={onStartExport}
|
||||
setName={setName}
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setRange={setRange}
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
@@ -220,7 +213,6 @@ export default function MobileReviewSettingsDrawer({
|
||||
onCancel={() => {
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
setSelectedCaseId(undefined);
|
||||
setDrawerMode("select");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type OptionAndInputDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
options: Option[];
|
||||
newValueKey: string;
|
||||
initialValue?: string;
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSave: (value: string) => void;
|
||||
onCreateNew: (name: string, description: string) => void;
|
||||
};
|
||||
|
||||
export default function OptionAndInputDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
options,
|
||||
newValueKey,
|
||||
initialValue,
|
||||
nameLabel,
|
||||
descriptionLabel,
|
||||
setOpen,
|
||||
onSave,
|
||||
onCreateNew,
|
||||
}: OptionAndInputDialogProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const firstOption = useMemo(() => options[0]?.value, [options]);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState<string | undefined>(
|
||||
initialValue ?? firstOption,
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [descriptionValue, setDescriptionValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedValue(initialValue ?? firstOption);
|
||||
setName("");
|
||||
setDescriptionValue("");
|
||||
}
|
||||
}, [open, initialValue, firstOption]);
|
||||
|
||||
const isNew = selectedValue === newValueKey;
|
||||
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const trimmedDescription = descriptionValue.trim();
|
||||
|
||||
if (isNew) {
|
||||
onCreateNew(trimmedName, trimmedDescription);
|
||||
} else {
|
||||
onSave(selectedValue);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className={cn("space-y-4", isMobile && "px-4")}
|
||||
onOpenAutoFocus={(e) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(val) => setSelectedValue(val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isNew && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{nameLabel}
|
||||
</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
<Input
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("button.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="select"
|
||||
disabled={disableSave}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("button.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -118,8 +118,6 @@ export default function JSMpegPlayer({
|
||||
const videoWrapper = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
let videoElement: JSMpeg.VideoElement | null = null;
|
||||
let socket: WebSocket | null = null;
|
||||
let socketMessageHandler: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
let frameCount = 0;
|
||||
|
||||
@@ -154,14 +152,12 @@ export default function JSMpegPlayer({
|
||||
videoElement.player.source &&
|
||||
videoElement.player.source.socket
|
||||
) {
|
||||
socket = videoElement.player.source.socket as WebSocket;
|
||||
socketMessageHandler = (event: MessageEvent) => {
|
||||
const socket = videoElement.player.source.socket;
|
||||
socket.addEventListener("message", (event: MessageEvent) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
bytesReceivedRef.current += event.data.byteLength;
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener("message", socketMessageHandler);
|
||||
});
|
||||
}
|
||||
|
||||
// Update stats every second
|
||||
@@ -201,23 +197,11 @@ export default function JSMpegPlayer({
|
||||
}
|
||||
if (videoElement) {
|
||||
try {
|
||||
videoElement.player?.destroy();
|
||||
// this causes issues in react strict mode
|
||||
// https://stackoverflow.com/questions/76822128/issue-with-cycjimmy-jsmpeg-player-in-react-18-cannot-read-properties-of-null-o
|
||||
videoElement.destroy();
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
|
||||
if (videoWrapper) {
|
||||
videoWrapper.innerHTML = "";
|
||||
// @ts-expect-error playerInstance is set by jsmpeg
|
||||
videoWrapper.playerInstance = null;
|
||||
}
|
||||
}
|
||||
if (socket) {
|
||||
if (socketMessageHandler) {
|
||||
socket.removeEventListener("message", socketMessageHandler);
|
||||
}
|
||||
|
||||
socket = null;
|
||||
socketMessageHandler = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,11 +82,6 @@ export default function LivePlayer({
|
||||
const internalContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const cameraName = useCameraFriendlyName(cameraConfig);
|
||||
|
||||
// player is showing on a dashboard if containerRef is not provided
|
||||
|
||||
const inDashboard = containerRef?.current == null;
|
||||
|
||||
// stats
|
||||
|
||||
const [stats, setStats] = useState<PlayerStatsType>({
|
||||
@@ -421,28 +416,6 @@ export default function LivePlayer({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{offline && inDashboard && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||
<div className="text-md">{t("streamOffline.title")}</div>
|
||||
<TbExclamationCircle className="size-6" />
|
||||
<p className="text-center text-sm">
|
||||
<Trans
|
||||
ns="components/player"
|
||||
values={{
|
||||
cameraName: cameraName,
|
||||
}}
|
||||
>
|
||||
streamOffline.desc
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{offline && !showStillWithoutActivity && cameraEnabled && (
|
||||
<div className="absolute inset-0 left-1/2 top-1/2 flex h-96 w-96 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="flex flex-col items-center justify-center rounded-lg bg-background/50 p-5">
|
||||
|
||||
Reference in New Issue
Block a user