mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 23:29:01 +03:00
Miscellaneous Fixes (#21024)
* fix wording in reference config * spacing tweaks * make live view settings drawer scrollable * clarify audio transcription docs * change audio transcription icon to activity indicator when transcription is in progress the backend doesn't implement any kind of queueing for speech event transcription * tracking details tweaks - Add attribute box overlay and area - Add score - Throttle swr revalidation during video component rerendering * add mse codecs to console debug on errors * add camera name
This commit is contained in:
@@ -461,6 +461,40 @@ export function useEmbeddingsReindexProgress(
|
||||
return { payload: data };
|
||||
}
|
||||
|
||||
export function useAudioTranscriptionProcessState(
|
||||
revalidateOnFocus: boolean = true,
|
||||
): { payload: string } {
|
||||
const {
|
||||
value: { payload },
|
||||
send: sendCommand,
|
||||
} = useWs("audio_transcription_state", "audioTranscriptionState");
|
||||
|
||||
const data = useDeepMemo(
|
||||
payload ? (JSON.parse(payload as string) as string) : "idle",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let listener = undefined;
|
||||
if (revalidateOnFocus) {
|
||||
sendCommand("audioTranscriptionState");
|
||||
listener = () => {
|
||||
if (document.visibilityState == "visible") {
|
||||
sendCommand("audioTranscriptionState");
|
||||
}
|
||||
};
|
||||
addEventListener("visibilitychange", listener);
|
||||
}
|
||||
return () => {
|
||||
if (listener) {
|
||||
removeEventListener("visibilitychange", listener);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [revalidateOnFocus]);
|
||||
|
||||
return { payload: data || "idle" };
|
||||
}
|
||||
|
||||
export function useBirdseyeLayout(revalidateOnFocus: boolean = true): {
|
||||
payload: string;
|
||||
} {
|
||||
|
||||
@@ -42,6 +42,7 @@ type ObjectData = {
|
||||
pathPoints: PathPoint[];
|
||||
currentZones: string[];
|
||||
currentBox?: number[];
|
||||
currentAttributeBox?: number[];
|
||||
};
|
||||
|
||||
export default function ObjectTrackOverlay({
|
||||
@@ -105,6 +106,12 @@ export default function ObjectTrackOverlay({
|
||||
selectedObjectIds.length > 0
|
||||
? ["event_ids", { ids: selectedObjectIds.join(",") }]
|
||||
: null,
|
||||
null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch timeline data for each object ID using fixed number of hooks
|
||||
@@ -112,7 +119,12 @@ export default function ObjectTrackOverlay({
|
||||
selectedObjectIds.length > 0
|
||||
? `timeline?source_id=${selectedObjectIds.join(",")}&limit=1000`
|
||||
: null,
|
||||
{ revalidateOnFocus: false },
|
||||
null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
|
||||
@@ -270,6 +282,7 @@ export default function ObjectTrackOverlay({
|
||||
);
|
||||
|
||||
const currentBox = nearbyTimelineEvent?.data?.box;
|
||||
const currentAttributeBox = nearbyTimelineEvent?.data?.attribute_box;
|
||||
|
||||
return {
|
||||
objectId,
|
||||
@@ -278,6 +291,7 @@ export default function ObjectTrackOverlay({
|
||||
pathPoints: combinedPoints,
|
||||
currentZones,
|
||||
currentBox,
|
||||
currentAttributeBox,
|
||||
};
|
||||
})
|
||||
.filter((obj: ObjectData) => obj.pathPoints.length > 0); // Only include objects with path data
|
||||
@@ -482,6 +496,20 @@ export default function ObjectTrackOverlay({
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
{objData.currentAttributeBox && showBoundingBoxes && (
|
||||
<g>
|
||||
<rect
|
||||
x={objData.currentAttributeBox[0] * videoWidth}
|
||||
y={objData.currentAttributeBox[1] * videoHeight}
|
||||
width={objData.currentAttributeBox[2] * videoWidth}
|
||||
height={objData.currentAttributeBox[3] * videoHeight}
|
||||
fill="none"
|
||||
stroke={objData.color}
|
||||
strokeWidth={boxStroke}
|
||||
opacity="0.9"
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -92,6 +92,7 @@ import { DialogPortal } from "@radix-ui/react-dialog";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
import { HiSparkles } from "react-icons/hi";
|
||||
import { useAudioTranscriptionProcessState } from "@/api/ws";
|
||||
|
||||
const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||
@@ -1076,6 +1077,11 @@ function ObjectDetailsTab({
|
||||
});
|
||||
}, [search, t]);
|
||||
|
||||
// audio transcription processing state
|
||||
|
||||
const { payload: audioTranscriptionProcessState } =
|
||||
useAudioTranscriptionProcessState();
|
||||
|
||||
// frigate+ submission
|
||||
|
||||
type SubmissionState = "reviewing" | "uploading" | "submitted";
|
||||
@@ -1431,10 +1437,20 @@ function ObjectDetailsTab({
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("itemMenu.audioTranscription.label")}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
className={cn(
|
||||
"text-primary/40",
|
||||
audioTranscriptionProcessState === "processing"
|
||||
? "cursor-not-allowed"
|
||||
: "hover:text-primary/80",
|
||||
)}
|
||||
onClick={onTranscribe}
|
||||
disabled={audioTranscriptionProcessState === "processing"}
|
||||
>
|
||||
<FaMicrophone className="size-4" />
|
||||
{audioTranscriptionProcessState === "processing" ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
<FaMicrophone className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -75,12 +75,15 @@ export function TrackingDetails({
|
||||
setIsVideoLoading(true);
|
||||
}, [event.id]);
|
||||
|
||||
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
|
||||
"timeline",
|
||||
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>(
|
||||
["timeline", { source_id: event.id }],
|
||||
null,
|
||||
{
|
||||
source_id: event.id,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
},
|
||||
]);
|
||||
);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@@ -104,6 +107,12 @@ export function TrackingDetails({
|
||||
},
|
||||
]
|
||||
: null,
|
||||
null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
// Convert a timeline timestamp to actual video player time, accounting for
|
||||
@@ -714,53 +723,6 @@ export function TrackingDetails({
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{eventSequence.map((item, idx) => {
|
||||
const isActive =
|
||||
Math.abs(
|
||||
(effectiveTime ?? 0) - (item.timestamp ?? 0),
|
||||
) <= 0.5;
|
||||
const formattedEventTimestamp = config
|
||||
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t(
|
||||
"time.formattedTimestampHourMinuteSecond.24hour",
|
||||
{ ns: "common" },
|
||||
)
|
||||
: t(
|
||||
"time.formattedTimestampHourMinuteSecond.12hour",
|
||||
{ ns: "common" },
|
||||
),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})
|
||||
: "";
|
||||
|
||||
const ratio =
|
||||
Array.isArray(item.data.box) &&
|
||||
item.data.box.length >= 4
|
||||
? (
|
||||
aspectRatio *
|
||||
(item.data.box[2] / item.data.box[3])
|
||||
).toFixed(2)
|
||||
: "N/A";
|
||||
const areaPx =
|
||||
Array.isArray(item.data.box) &&
|
||||
item.data.box.length >= 4
|
||||
? Math.round(
|
||||
(config.cameras[event.camera]?.detect?.width ??
|
||||
0) *
|
||||
(config.cameras[event.camera]?.detect
|
||||
?.height ?? 0) *
|
||||
(item.data.box[2] * item.data.box[3]),
|
||||
)
|
||||
: undefined;
|
||||
const areaPct =
|
||||
Array.isArray(item.data.box) &&
|
||||
item.data.box.length >= 4
|
||||
? (item.data.box[2] * item.data.box[3]).toFixed(4)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||
@@ -770,11 +732,7 @@ export function TrackingDetails({
|
||||
>
|
||||
<LifecycleIconRow
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
formattedEventTimestamp={formattedEventTimestamp}
|
||||
ratio={ratio}
|
||||
areaPx={areaPx}
|
||||
areaPct={areaPct}
|
||||
event={event}
|
||||
onClick={() => handleLifecycleClick(item)}
|
||||
setSelectedZone={setSelectedZone}
|
||||
getZoneColor={getZoneColor}
|
||||
@@ -798,11 +756,7 @@ export function TrackingDetails({
|
||||
|
||||
type LifecycleIconRowProps = {
|
||||
item: TrackingDetailsSequence;
|
||||
isActive?: boolean;
|
||||
formattedEventTimestamp: string;
|
||||
ratio: string;
|
||||
areaPx?: number;
|
||||
areaPct?: string;
|
||||
event: Event;
|
||||
onClick: () => void;
|
||||
setSelectedZone: (z: string) => void;
|
||||
getZoneColor: (zoneName: string) => number[] | undefined;
|
||||
@@ -812,11 +766,7 @@ type LifecycleIconRowProps = {
|
||||
|
||||
function LifecycleIconRow({
|
||||
item,
|
||||
isActive,
|
||||
formattedEventTimestamp,
|
||||
ratio,
|
||||
areaPx,
|
||||
areaPct,
|
||||
event,
|
||||
onClick,
|
||||
setSelectedZone,
|
||||
getZoneColor,
|
||||
@@ -826,9 +776,101 @@ function LifecycleIconRow({
|
||||
const { t } = useTranslation(["views/explore", "components/player"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!config) {
|
||||
return 16 / 9;
|
||||
}
|
||||
|
||||
return (
|
||||
config.cameras[event.camera].detect.width /
|
||||
config.cameras[event.camera].detect.height
|
||||
);
|
||||
}, [config, event]);
|
||||
|
||||
const isActive = useMemo(
|
||||
() => Math.abs((effectiveTime ?? 0) - (item.timestamp ?? 0)) <= 0.5,
|
||||
[effectiveTime, item.timestamp],
|
||||
);
|
||||
|
||||
const formattedEventTimestamp = useMemo(
|
||||
() =>
|
||||
config
|
||||
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})
|
||||
: "",
|
||||
[config, item.timestamp, t],
|
||||
);
|
||||
|
||||
const ratio = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||
? (aspectRatio * (item.data.box[2] / item.data.box[3])).toFixed(2)
|
||||
: "N/A",
|
||||
[aspectRatio, item.data.box],
|
||||
);
|
||||
|
||||
const areaPx = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||
? Math.round(
|
||||
(config?.cameras[event.camera]?.detect?.width ?? 0) *
|
||||
(config?.cameras[event.camera]?.detect?.height ?? 0) *
|
||||
(item.data.box[2] * item.data.box[3]),
|
||||
)
|
||||
: undefined,
|
||||
[config, event.camera, item.data.box],
|
||||
);
|
||||
|
||||
const attributeAreaPx = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.attribute_box) &&
|
||||
item.data.attribute_box.length >= 4
|
||||
? Math.round(
|
||||
(config?.cameras[event.camera]?.detect?.width ?? 0) *
|
||||
(config?.cameras[event.camera]?.detect?.height ?? 0) *
|
||||
(item.data.attribute_box[2] * item.data.attribute_box[3]),
|
||||
)
|
||||
: undefined,
|
||||
[config, event.camera, item.data.attribute_box],
|
||||
);
|
||||
|
||||
const attributeAreaPct = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.attribute_box) &&
|
||||
item.data.attribute_box.length >= 4
|
||||
? (item.data.attribute_box[2] * item.data.attribute_box[3]).toFixed(4)
|
||||
: undefined,
|
||||
[item.data.attribute_box],
|
||||
);
|
||||
|
||||
const areaPct = useMemo(
|
||||
() =>
|
||||
Array.isArray(item.data.box) && item.data.box.length >= 4
|
||||
? (item.data.box[2] * item.data.box[3]).toFixed(4)
|
||||
: undefined,
|
||||
[item.data.box],
|
||||
);
|
||||
|
||||
const score = useMemo(() => {
|
||||
if (item.data.score !== undefined) {
|
||||
return (item.data.score * 100).toFixed(0) + "%";
|
||||
}
|
||||
return "N/A";
|
||||
}, [item.data.score]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -856,16 +898,28 @@ function LifecycleIconRow({
|
||||
<div className="text-md flex items-start break-words text-left">
|
||||
{getLifecycleItemDescription(item)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-secondary-foreground md:gap-5">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="my-2 ml-2 flex flex-col flex-wrap items-start gap-1.5 text-xs text-secondary-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.score")}
|
||||
</span>
|
||||
<span className="font-medium text-primary">{score}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
||||
</span>
|
||||
<span className="font-medium text-primary">{ratio}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")}
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")}{" "}
|
||||
{attributeAreaPx !== undefined &&
|
||||
attributeAreaPct !== undefined && (
|
||||
<span className="text-primary-variant">
|
||||
({getTranslatedLabel(item.data.label)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{areaPx !== undefined && areaPct !== undefined ? (
|
||||
<span className="font-medium text-primary">
|
||||
@@ -876,9 +930,25 @@ function LifecycleIconRow({
|
||||
<span>N/A</span>
|
||||
)}
|
||||
</div>
|
||||
{attributeAreaPx !== undefined &&
|
||||
attributeAreaPct !== undefined && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-primary-variant">
|
||||
{t("trackingDetails.lifecycleItemDesc.header.area")} (
|
||||
{getTranslatedLabel(item.data.attribute)})
|
||||
</span>
|
||||
<span className="font-medium text-primary">
|
||||
{t("information.pixels", {
|
||||
ns: "common",
|
||||
area: attributeAreaPx,
|
||||
})}{" "}
|
||||
· {attributeAreaPct}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.data?.zones && item.data.zones.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{item.data.zones.map((zone, zidx) => {
|
||||
const color = getZoneColor(zone)?.join(",") ?? "0,0,0";
|
||||
return (
|
||||
|
||||
@@ -82,6 +82,7 @@ function MSEPlayer({
|
||||
[key: string]: (msg: { value: string; type: string }) => void;
|
||||
}>({});
|
||||
const msRef = useRef<MediaSource | null>(null);
|
||||
const mseCodecRef = useRef<string | null>(null);
|
||||
|
||||
const wsURL = useMemo(() => {
|
||||
return `${baseUrl.replace(/^http/, "ws")}live/mse/api/ws?src=${camera}`;
|
||||
@@ -93,6 +94,10 @@ function MSEPlayer({
|
||||
console.error(
|
||||
`${camera} - MSE error '${error}': ${description} See the documentation: https://docs.frigate.video/configuration/live/#live-player-error-messages`,
|
||||
);
|
||||
if (mseCodecRef.current) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`${camera} - MSE codec in use: ${mseCodecRef.current}`);
|
||||
}
|
||||
onError?.(error);
|
||||
},
|
||||
[camera, onError],
|
||||
@@ -299,6 +304,9 @@ function MSEPlayer({
|
||||
onmessageRef.current["mse"] = (msg) => {
|
||||
if (msg.type !== "mse") return;
|
||||
|
||||
// Store the codec value for error logging
|
||||
mseCodecRef.current = msg.value;
|
||||
|
||||
let sb: SourceBuffer | undefined;
|
||||
try {
|
||||
sb = msRef.current?.addSourceBuffer(msg.value);
|
||||
|
||||
@@ -16,6 +16,7 @@ export type TrackingDetailsSequence = {
|
||||
data: {
|
||||
camera: string;
|
||||
label: string;
|
||||
score: number;
|
||||
sub_label: string;
|
||||
box?: [number, number, number, number];
|
||||
region: [number, number, number, number];
|
||||
|
||||
@@ -1376,329 +1376,343 @@ function FrigateCameraFeatures({
|
||||
title={t("cameraSettings.title", { camera })}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="rounded-2xl px-2 py-4">
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.cameraEnabled")}
|
||||
isChecked={enabledState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.objectDetection")}
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{recordingEnabled && (
|
||||
<DrawerContent className="max-h-[75dvh] overflow-hidden rounded-2xl">
|
||||
<div className="scrollbar-container mt-2 flex h-auto flex-col gap-2 overflow-y-auto px-2 py-4">
|
||||
<>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.recording")}
|
||||
isChecked={recordState == "ON"}
|
||||
label={t("cameraSettings.cameraEnabled")}
|
||||
isChecked={enabledState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.snapshots")}
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{audioDetectEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.audioDetection")}
|
||||
isChecked={audioState == "ON"}
|
||||
label={t("cameraSettings.objectDetection")}
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{audioDetectEnabled && transcriptionEnabled && (
|
||||
{recordingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.recording")}
|
||||
isChecked={recordState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.transcription")}
|
||||
disabled={audioState == "OFF"}
|
||||
isChecked={transcriptionState == "ON"}
|
||||
label={t("cameraSettings.snapshots")}
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendTranscription(transcriptionState == "ON" ? "OFF" : "ON")
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.autotracking")}
|
||||
isChecked={autotrackingState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{audioDetectEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.audioDetection")}
|
||||
isChecked={audioState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{audioDetectEnabled && transcriptionEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.transcription")}
|
||||
disabled={audioState == "OFF"}
|
||||
isChecked={transcriptionState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendTranscription(
|
||||
transcriptionState == "ON" ? "OFF" : "ON",
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label={t("cameraSettings.autotracking")}
|
||||
isChecked={autotrackingState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Label>{t("stream.title")}</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
{t("streaming.restreaming.disabled", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("streaming.restreaming.desc.title", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed && Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="mt-1 p-2">
|
||||
<div className="mb-1 text-sm">{t("stream.title")}</div>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
disabled={debug}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{debug && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<>
|
||||
<LuX className="size-8 text-danger" />
|
||||
<div>{t("stream.debug.picker")}</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode != "jsmpeg" && !debug && isRestreamed && (
|
||||
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.audio.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
{t("stream.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed &&
|
||||
supportsAudioOutput && (
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Label>{t("stream.title")}</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.twoWayTalk.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
{t("stream.twoWayTalk.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/live/#webrtc-extra-configuration",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
{t("streaming.restreaming.disabled", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
{t("streaming.restreaming.desc.title", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed &&
|
||||
Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="mt-1 p-2">
|
||||
<div className="mb-1 text-sm">{t("stream.title")}</div>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
disabled={debug}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{debug && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<>
|
||||
<LuX className="size-8 text-danger" />
|
||||
<div>{t("stream.debug.picker")}</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed && (
|
||||
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.audio.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.audio.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
{t("stream.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
!debug &&
|
||||
isRestreamed &&
|
||||
supportsAudioOutput && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>{t("stream.twoWayTalk.available")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>{t("stream.twoWayTalk.unavailable")}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
{t("stream.twoWayTalk.tips")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/live/#webrtc-extra-configuration",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<div className="mt-2 flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
<p className="text-sm">
|
||||
{t("stream.lowBandwidth.tips")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={debug}
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.lowBandwidth.resetStream")}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<div className="mt-2 flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
<p className="text-sm">{t("stream.lowBandwidth.tips")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<div className="mb-1 text-sm font-medium leading-none">
|
||||
{t("manualRecording.title")}
|
||||
</div>
|
||||
<div className="flex flex-row items-stretch gap-2">
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label={t("stream.lowBandwidth.resetStream")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={debug}
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
onClick={handleSnapshotClick}
|
||||
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
||||
className="h-auto w-full whitespace-normal"
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
{t("stream.lowBandwidth.resetStream")}
|
||||
</div>
|
||||
{isSnapshotLoading && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("snapshot.takeSnapshot")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEventButtonClick}
|
||||
className={cn(
|
||||
"h-auto w-full whitespace-normal",
|
||||
isRecording &&
|
||||
"animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
disabled={debug}
|
||||
>
|
||||
{t("manualRecording." + (isRecording ? "end" : "start"))}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("manualRecording.tips")}
|
||||
</p>
|
||||
</div>
|
||||
{isRestreamed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label={t("manualRecording.playInBackground.label")}
|
||||
isChecked={playInBackground}
|
||||
onCheckedChange={(checked) => {
|
||||
setPlayInBackground(checked);
|
||||
}}
|
||||
disabled={debug}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
{t("manualRecording.playInBackground.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label={t("manualRecording.showStats.label")}
|
||||
isChecked={showStats}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowStats(checked);
|
||||
}}
|
||||
disabled={debug}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
{t("manualRecording.showStats.desc")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<div className="mb-1 text-sm font-medium leading-none">
|
||||
{t("manualRecording.title")}
|
||||
</div>
|
||||
<div className="flex flex-row items-stretch gap-2">
|
||||
<Button
|
||||
onClick={handleSnapshotClick}
|
||||
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
||||
className="h-auto w-full whitespace-normal"
|
||||
>
|
||||
{isSnapshotLoading && (
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
)}
|
||||
{t("snapshot.takeSnapshot")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEventButtonClick}
|
||||
className={cn(
|
||||
"h-auto w-full whitespace-normal",
|
||||
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
disabled={debug}
|
||||
>
|
||||
{t("manualRecording." + (isRecording ? "end" : "start"))}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("manualRecording.tips")}
|
||||
</p>
|
||||
</div>
|
||||
{isRestreamed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mb-3 flex flex-col">
|
||||
<FilterSwitch
|
||||
label={t("manualRecording.playInBackground.label")}
|
||||
isChecked={playInBackground}
|
||||
onCheckedChange={(checked) => {
|
||||
setPlayInBackground(checked);
|
||||
}}
|
||||
disabled={debug}
|
||||
label={t("streaming.debugView", { ns: "components/dialog" })}
|
||||
isChecked={debug}
|
||||
onCheckedChange={(checked) => setDebug(checked)}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
{t("manualRecording.playInBackground.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label={t("manualRecording.showStats.label")}
|
||||
isChecked={showStats}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowStats(checked);
|
||||
}}
|
||||
disabled={debug}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
{t("manualRecording.showStats.desc")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mb-3 flex flex-col">
|
||||
<FilterSwitch
|
||||
label={t("streaming.debugView", { ns: "components/dialog" })}
|
||||
isChecked={debug}
|
||||
onCheckedChange={(checked) => setDebug(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -784,7 +784,7 @@ export default function AuthenticationView({
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
{section === "users" && UsersSection}
|
||||
{section === "roles" && RolesSection}
|
||||
{!section && (
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function CameraManagementView({
|
||||
closeButton
|
||||
/>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
{viewMode === "settings" ? (
|
||||
<>
|
||||
<Heading as="h4" className="mb-2">
|
||||
|
||||
@@ -298,7 +298,7 @@ export default function CameraReviewSettingsView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("cameraReview.title")}
|
||||
</Heading>
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function EnrichmentsSettingsView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("enrichments.title")}
|
||||
</Heading>
|
||||
|
||||
@@ -211,7 +211,7 @@ export default function FrigatePlusSettingsView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("frigatePlus.title")}
|
||||
</Heading>
|
||||
|
||||
@@ -434,7 +434,7 @@ export default function MasksAndZonesView({
|
||||
{cameraConfig && editingPolygons && (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
{editPane == "zone" && (
|
||||
<ZoneEditPane
|
||||
polygons={editingPolygons}
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function MotionTunerView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mr-3 md:mt-0 md:w-3/12">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("motionDetectionTuner.title")}
|
||||
</Heading>
|
||||
|
||||
@@ -331,7 +331,7 @@ export default function NotificationView({
|
||||
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
return (
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<Heading as="h4" className="mb-2">
|
||||
@@ -385,7 +385,7 @@ export default function NotificationView({
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div
|
||||
className={cn(
|
||||
isAdmin && "grid w-full grid-cols-1 gap-4 md:grid-cols-2",
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function ObjectSettingsView({
|
||||
return (
|
||||
<div className="mt-1 flex size-full flex-col pb-2 md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("debug.title")}
|
||||
</Heading>
|
||||
@@ -434,7 +434,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
{t("debug.objectShapeFilterDrawing.area")}
|
||||
</p>
|
||||
{obj.area ? (
|
||||
<>
|
||||
<div className="text-end">
|
||||
<div className="text-xs">
|
||||
px: {obj.area.toString()}
|
||||
</div>
|
||||
@@ -448,7 +448,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
|
||||
.toFixed(4)
|
||||
.toString()}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
|
||||
@@ -440,7 +440,7 @@ export default function TriggerView({
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none md:mr-3 md:mt-0">
|
||||
{!isSemanticSearchEnabled ? (
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function UiSettingsView() {
|
||||
<>
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("general.title")}
|
||||
</Heading>
|
||||
|
||||
Reference in New Issue
Block a user