Early 0.18 work (#22138)

* Update version

* Create scaffolding for case management (#21293)

* implement case management for export apis (#21295)

* refactor vainfo to search for first GPU (#21296)

use existing LibvaGpuSelector to pick appropritate libva device

* Case management UI (#21299)

* Refactor export cards to match existing cards in other UI pages

* Show cases separately from exports

* Add proper filtering and display of cases

* Add ability to edit and select cases for exports

* Cleanup typing

* Hide if no unassigned

* Cleanup hiding logic

* fix scrolling

* Improve layout

* Camera connection quality indicator (#21297)

* add camera connection quality metrics and indicator

* formatting

* move stall calcs to watchdog

* clean up

* change watchdog to 1s and separately track time for ffmpeg retry_interval

* implement status caching to reduce message volume

* Export filter UI (#21322)

* Get started on export filters

* implement basic filter

* Implement filtering and adjust api

* Improve filter handling

* Improve navigation

* Cleanup

* handle scrolling

* Refactor temperature reporting for detectors and implement Hailo temp reading (#21395)

* Add Hailo temperature retrieval

* Refactor `get_hailo_temps()` to use ctxmanager

* Show Hailo temps in system UI

* Move hailo_platform import to get_hailo_temps

* Refactor temperatures calculations to use within detector block

* Adjust webUI to handle new location

---------

Co-authored-by: tigattack <10629864+tigattack@users.noreply.github.com>

* Camera-specific hwaccel settings for timelapse exports (correct base) (#21386)

* added hwaccel_args to camera.record.export config struct

* populate camera.record.export.hwaccel_args with a cascade up to camera then global if 'auto'

* use new hwaccel args in export

* added documentation for camera-specific hwaccel export

* fix c/p error

* missed an import

* fleshed out the docs and comments a bit

* ruff lint

* separated out the tips in the doc

* fix documentation

* fix and simplify reference config doc

* Add support for GPU and NPU temperatures (#21495)

* Add rockchip temps

* Add support for GPU and NPU temperatures in the frontend

* Add support for Nvidia temperature

* Improve separation

* Adjust graph scaling

* Exports Improvements (#21521)

* Add images to case folder view

* Add ability to select case in export dialog

* Add to mobile review too

* Add API to handle deleting recordings  (#21520)

* Add recording delete API

* Re-organize recordings apis

* Fix import

* Consolidate query types

* Add media sync API endpoint (#21526)

* add media cleanup functions

* add endpoint

* remove scheduled sync recordings from cleanup

* move to utils dir

* tweak import

* remove sync_recordings and add config migrator

* remove sync_recordings

* docs

* remove key

* clean up docs

* docs fix

* docs tweak

* Media sync API refactor and UI (#21542)

* generic job infrastructure

* types and dispatcher changes for jobs

* save data in memory only for completed jobs

* implement media sync job and endpoints

* change logs to debug

* websocket hook and types

* frontend

* i18n

* docs tweaks

* endpoint descriptions

* tweak docs

* use same logging pattern in sync_recordings as the other sync functions (#21625)

* Fix incorrect counting in sync_recordings (#21626)

* Update go2rtc to v1.9.13 (#21648)

Co-authored-by: Eugeny Tulupov <eugeny.tulupov@spirent.com>

* Refactor Time-Lapse Export (#21668)

* refactor time lapse creation to be a separate API call with ability to pass arbitrary ffmpeg args

* Add CPU fallback

* Optimize empty directory cleanup for recordings (#21695)

The previous empty directory cleanup did a full recursive directory
walk, which can be extremely slow. This new implementation only removes
directories which have a chance of being empty due to a recent file
deletion.

* Implement llama.cpp GenAI Provider (#21690)

* Implement llama.cpp GenAI Provider

* Add docs

* Update links

* Fix broken mqtt links

* Fix more broken anchors

* Remove parents in remove_empty_directories (#21726)

The original implementation did a full directory tree walk to find and remove
empty directories, so this implementation should remove the parents as well,
like the original did.

* Implement LLM Chat API with tool calling support (#21731)

* Implement initial tools definiton APIs

* Add initial chat completion API with tool support

* Implement other providers

* Cleanup

* Offline preview image (#21752)

* use latest preview frame for latest image when camera is offline

* remove frame extraction logic

* tests

* frontend

* add description to api endpoint

* Update to ROCm 7.2.0 (#21753)

* Update to ROCm 7.2.0

* ROCm now works properly with JinaV1

* Arcface has compilation error

* Add live context tool to LLM (#21754)

* Add live context tool

* Improve handling of images in request

* Improve prompt caching

* Add networking options for configuring listening ports (#21779)

* feat: add X-Frame-Time when returning snapshot (#21932)

Co-authored-by: Florent MORICONI <170678386+fmcloudconsulting@users.noreply.github.com>

* Improve jsmpeg player websocket handling (#21943)

* improve jsmpeg player websocket handling

prevent websocket console messages from appearing when player is destroyed

* reformat files after ruff upgrade

* Allow API Events to be Detections or Alerts, depending on the Event Label (#21923)

* - API created events will be alerts OR detections, depending on the event label, defaulting to alerts
- Indefinite API events will extend the recording segment until those events are ended
- API event start time is the actual start time, instead of having a pre-buffer of record.event_pre_capture

* Instead of checking for indefinite events on a camera before deciding if we should end the segment, only update last_detection_time and last_alert_time if frame_time is greater, which should have the same effect

* Add the ability to set a pre_capture number of seconds when creating a manual event via the API. Default behavior unchanged

* Remove unnecessary _publish_segment_start() call

* Formatting

* handle last_alert_time or last_detection_time being None when checking them against the frame_time

* comment manual_info["label"].split(": ")[0] for clarity

* ffmpeg Preview Segment Optimization for "high" and "very_high" (#21996)

* Introduce qmax parameter for ffmpeg preview encoding

Added PREVIEW_QMAX_PARAM to control ffmpeg encoding quality.

* formatting

* Fix spacing in qmax parameters for preview quality

* Adapt to new Gemini format

* Fix frame time access

* Remove exceptions

* Cleanup

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Co-authored-by: tigattack <10629864+tigattack@users.noreply.github.com>
Co-authored-by: Andrew Roberts <adroberts@gmail.com>
Co-authored-by: Eugeny Tulupov <zhekka3@gmail.com>
Co-authored-by: Eugeny Tulupov <eugeny.tulupov@spirent.com>
Co-authored-by: John Shaw <1753078+johnshaw@users.noreply.github.com>
Co-authored-by: Eric Work <work.eric@gmail.com>
Co-authored-by: FL42 <46161216+fl42@users.noreply.github.com>
Co-authored-by: Florent MORICONI <170678386+fmcloudconsulting@users.noreply.github.com>
Co-authored-by: nulledy <254504350+nulledy@users.noreply.github.com>
This commit is contained in:
Nicolas Mowen
2026-02-26 21:16:10 -07:00
committed by GitHub
parent 7df3622243
commit d24b96d3bb
107 changed files with 6766 additions and 1050 deletions

View File

@@ -47,7 +47,7 @@ export default function ProtectedRoute({
return <Outlet />;
}
// Authenticated mode (8971): require login
// Authenticated mode (external port): require login
if (!auth.user) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />

View File

@@ -0,0 +1,76 @@
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>
);
}

View File

@@ -1,9 +1,8 @@
import ActivityIndicator from "../indicators/activity-indicator";
import { LuTrash } from "react-icons/lu";
import { Button } from "../ui/button";
import { useCallback, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
import { useCallback, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import { FiMoreVertical } from "react-icons/fi";
import { Skeleton } from "../ui/skeleton";
import {
Dialog,
@@ -14,35 +13,81 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { DeleteClipType, Export } from "@/types/export";
import { MdEditSquare } from "react-icons/md";
import { DeleteClipType, Export, ExportCase } from "@/types/export";
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 ExportProps = {
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 = {
className: string;
exportedRecording: Export;
onSelect: (selected: Export) => void;
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
};
export default function ExportCard({
export function ExportCard({
className,
exportedRecording,
onSelect,
onRename,
onDelete,
}: ExportProps) {
onAssignToCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin();
const [hovered, setHovered] = useState(false);
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
@@ -136,12 +181,14 @@ export default function ExportCard({
<div
className={cn(
"relative flex aspect-video items-center justify-center rounded-lg bg-black md:rounded-2xl",
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
className,
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
onClick={isDesktop ? undefined : () => setHovered(!hovered)}
onClick={() => {
if (!exportedRecording.in_progress) {
onSelect(exportedRecording);
}
}}
>
{exportedRecording.in_progress ? (
<ActivityIndicator />
@@ -158,95 +205,88 @@ export default function ExportCard({
)}
</>
)}
{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 && (
{!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")}
>
<a
download
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
onClick={(e) => e.stopPropagation()}
>
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton>
<FaDownload className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("tooltip.downloadVideo")}
</TooltipContent>
</Tooltip>
{t("tooltip.downloadVideo")}
</a>
)}
{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>
</DropdownMenuItem>
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.assignToCase")}
onClick={(e) => {
e.stopPropagation();
onAssignToCase(exportedRecording);
}}
>
{t("tooltip.assignToCase")}
</DropdownMenuItem>
)}
{isAdmin && (
<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>
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.editName")}
onClick={(e) => {
e.stopPropagation();
setEditName({
original: exportedRecording.name,
update: undefined,
});
}}
>
{t("tooltip.editName")}
</DropdownMenuItem>
)}
</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>
)}
</>
{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>
)}
{loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />

View File

@@ -0,0 +1,67 @@
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>
);
}

View File

@@ -22,7 +22,14 @@ import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay";
@@ -31,6 +38,7 @@ 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",
@@ -67,6 +75,9 @@ 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) {
@@ -89,6 +100,7 @@ export default function ExportDialog({
{
playback: "realtime",
name,
export_case_id: selectedCaseId || undefined,
},
)
.then((response) => {
@@ -102,6 +114,7 @@ export default function ExportDialog({
),
});
setName("");
setSelectedCaseId(undefined);
setRange(undefined);
setMode("none");
}
@@ -118,10 +131,11 @@ export default function ExportDialog({
{ position: "top-center" },
);
});
}, [camera, name, range, setRange, setName, setMode, t]);
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
const handleCancel = useCallback(() => {
setName("");
setSelectedCaseId(undefined);
setMode("none");
setRange(undefined);
}, [setMode, setRange]);
@@ -190,8 +204,10 @@ export default function ExportDialog({
currentTime={currentTime}
range={range}
name={name}
selectedCaseId={selectedCaseId}
onStartExport={onStartExport}
setName={setName}
setSelectedCaseId={setSelectedCaseId}
setRange={setRange}
setMode={setMode}
onCancel={handleCancel}
@@ -207,8 +223,10 @@ 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;
@@ -218,14 +236,17 @@ 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) => {
@@ -320,6 +341,44 @@ 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"}

View File

@@ -75,6 +75,9 @@ 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"), {
@@ -96,6 +99,7 @@ export default function MobileReviewSettingsDrawer({
{
playback: "realtime",
name,
export_case_id: selectedCaseId || undefined,
},
)
.then((response) => {
@@ -114,6 +118,7 @@ export default function MobileReviewSettingsDrawer({
},
);
setName("");
setSelectedCaseId(undefined);
setRange(undefined);
setMode("none");
}
@@ -133,7 +138,7 @@ export default function MobileReviewSettingsDrawer({
},
);
});
}, [camera, name, range, setRange, setName, setMode, t]);
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
// filters
@@ -200,8 +205,10 @@ export default function MobileReviewSettingsDrawer({
currentTime={currentTime}
range={range}
name={name}
selectedCaseId={selectedCaseId}
onStartExport={onStartExport}
setName={setName}
setSelectedCaseId={setSelectedCaseId}
setRange={setRange}
setMode={(mode) => {
setMode(mode);
@@ -213,6 +220,7 @@ export default function MobileReviewSettingsDrawer({
onCancel={() => {
setMode("none");
setRange(undefined);
setSelectedCaseId(undefined);
setDrawerMode("select");
}}
/>

View File

@@ -0,0 +1,166 @@
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>
);
}

View File

@@ -118,6 +118,8 @@ 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;
@@ -152,12 +154,14 @@ export default function JSMpegPlayer({
videoElement.player.source &&
videoElement.player.source.socket
) {
const socket = videoElement.player.source.socket;
socket.addEventListener("message", (event: MessageEvent) => {
socket = videoElement.player.source.socket as WebSocket;
socketMessageHandler = (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
bytesReceivedRef.current += event.data.byteLength;
}
});
};
socket.addEventListener("message", socketMessageHandler);
}
// Update stats every second
@@ -197,11 +201,23 @@ export default function JSMpegPlayer({
}
if (videoElement) {
try {
// 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();
videoElement.player?.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;
}
};
}

View File

@@ -82,6 +82,11 @@ 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>({
@@ -416,6 +421,28 @@ 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">