2024-03-02 03:43:02 +03:00
|
|
|
import {
|
2025-05-27 18:26:00 +03:00
|
|
|
useAudioLiveTranscription,
|
2024-03-02 03:43:02 +03:00
|
|
|
useAudioState,
|
2025-05-27 18:26:00 +03:00
|
|
|
useAudioTranscriptionState,
|
2024-05-16 17:32:39 +03:00
|
|
|
useAutotrackingState,
|
2024-03-02 03:43:02 +03:00
|
|
|
useDetectState,
|
2025-03-03 18:30:52 +03:00
|
|
|
useEnabledState,
|
2024-03-02 03:43:02 +03:00
|
|
|
usePtzCommand,
|
|
|
|
|
useRecordingsState,
|
|
|
|
|
useSnapshotsState,
|
|
|
|
|
} from "@/api/ws";
|
|
|
|
|
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
|
2024-04-20 01:12:03 +03:00
|
|
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
2024-03-02 03:43:02 +03:00
|
|
|
import LivePlayer from "@/components/player/LivePlayer";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2024-04-02 15:45:16 +03:00
|
|
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
2024-03-02 03:43:02 +03:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
2025-02-10 19:42:35 +03:00
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
2024-03-05 23:19:56 +03:00
|
|
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
2024-03-02 03:43:02 +03:00
|
|
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
2024-05-19 15:39:17 +03:00
|
|
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
2024-06-30 15:04:45 +03:00
|
|
|
import {
|
|
|
|
|
LivePlayerError,
|
|
|
|
|
LiveStreamMetadata,
|
|
|
|
|
VideoResolutionType,
|
|
|
|
|
} from "@/types/live";
|
2024-04-02 22:25:02 +03:00
|
|
|
import { RecordingStartingPoint } from "@/types/record";
|
2024-03-03 06:59:50 +03:00
|
|
|
import React, {
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from "react";
|
2024-03-02 03:43:02 +03:00
|
|
|
import {
|
|
|
|
|
isDesktop,
|
2024-06-30 15:04:45 +03:00
|
|
|
isFirefox,
|
2024-03-15 21:46:17 +03:00
|
|
|
isIOS,
|
2024-03-02 03:43:02 +03:00
|
|
|
isMobile,
|
2024-05-18 19:54:46 +03:00
|
|
|
isTablet,
|
2024-03-02 03:43:02 +03:00
|
|
|
useMobileOrientation,
|
|
|
|
|
} from "react-device-detect";
|
|
|
|
|
import {
|
2024-04-02 15:45:16 +03:00
|
|
|
FaCog,
|
2024-03-03 06:59:50 +03:00
|
|
|
FaCompress,
|
|
|
|
|
FaExpand,
|
2024-03-13 02:19:02 +03:00
|
|
|
FaMicrophone,
|
|
|
|
|
FaMicrophoneSlash,
|
2024-03-02 03:43:02 +03:00
|
|
|
} from "react-icons/fa";
|
|
|
|
|
import { GiSpeaker, GiSpeakerOff } from "react-icons/gi";
|
2024-04-02 15:45:16 +03:00
|
|
|
import {
|
2025-10-14 22:05:35 +03:00
|
|
|
TbCameraDown,
|
2025-02-10 19:42:35 +03:00
|
|
|
TbRecordMail,
|
|
|
|
|
TbRecordMailOff,
|
|
|
|
|
TbViewfinder,
|
|
|
|
|
TbViewfinderOff,
|
|
|
|
|
} from "react-icons/tb";
|
|
|
|
|
import { IoIosWarning, IoMdArrowRoundBack } from "react-icons/io";
|
|
|
|
|
import {
|
|
|
|
|
LuCheck,
|
2024-04-02 15:45:16 +03:00
|
|
|
LuEar,
|
|
|
|
|
LuEarOff,
|
2025-02-10 19:42:35 +03:00
|
|
|
LuExternalLink,
|
2024-04-02 22:25:02 +03:00
|
|
|
LuHistory,
|
2025-02-10 19:42:35 +03:00
|
|
|
LuInfo,
|
2024-04-02 15:45:16 +03:00
|
|
|
LuPictureInPicture,
|
2025-03-03 18:30:52 +03:00
|
|
|
LuPower,
|
|
|
|
|
LuPowerOff,
|
2024-04-02 15:45:16 +03:00
|
|
|
LuVideo,
|
|
|
|
|
LuVideoOff,
|
2025-02-10 19:42:35 +03:00
|
|
|
LuX,
|
2024-04-02 15:45:16 +03:00
|
|
|
} from "react-icons/lu";
|
2024-03-02 03:43:02 +03:00
|
|
|
import {
|
2025-05-27 18:26:00 +03:00
|
|
|
MdClosedCaption,
|
|
|
|
|
MdClosedCaptionDisabled,
|
2024-03-02 03:43:02 +03:00
|
|
|
MdNoPhotography,
|
2025-02-10 19:42:35 +03:00
|
|
|
MdOutlineRestartAlt,
|
2024-03-02 03:43:02 +03:00
|
|
|
MdPersonOff,
|
|
|
|
|
MdPersonSearch,
|
|
|
|
|
MdPhotoCamera,
|
|
|
|
|
} from "react-icons/md";
|
2025-02-10 19:42:35 +03:00
|
|
|
import { Link, useNavigate } from "react-router-dom";
|
2024-03-15 16:03:14 +03:00
|
|
|
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
2024-03-02 03:43:02 +03:00
|
|
|
import useSWR from "swr";
|
2024-07-03 02:14:38 +03:00
|
|
|
import { cn } from "@/lib/utils";
|
2024-08-09 15:46:39 +03:00
|
|
|
import { useSessionPersistence } from "@/hooks/use-session-persistence";
|
2025-03-16 18:36:20 +03:00
|
|
|
|
2025-02-10 19:42:35 +03:00
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectGroup,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
2025-09-23 05:21:51 +03:00
|
|
|
SelectValue,
|
2025-02-10 19:42:35 +03:00
|
|
|
} from "@/components/ui/select";
|
2025-12-01 16:59:54 +03:00
|
|
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
2025-02-10 19:42:35 +03:00
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
import axios from "axios";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { Toaster } from "@/components/ui/sonner";
|
2025-03-08 19:01:08 +03:00
|
|
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
2025-10-15 20:09:28 +03:00
|
|
|
import { useTranslation } from "react-i18next";
|
2025-05-28 15:10:45 +03:00
|
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
2025-12-13 17:12:37 +03:00
|
|
|
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
2025-09-30 02:45:55 +03:00
|
|
|
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
|
|
|
|
|
import ObjectSettingsView from "../settings/ObjectSettingsView";
|
2025-10-09 17:49:42 +03:00
|
|
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
2025-10-14 22:05:35 +03:00
|
|
|
import {
|
|
|
|
|
downloadSnapshot,
|
|
|
|
|
fetchCameraSnapshot,
|
|
|
|
|
generateSnapshotFilename,
|
|
|
|
|
grabVideoSnapshot,
|
|
|
|
|
SnapshotResult,
|
|
|
|
|
} from "@/utils/snapshotUtil";
|
|
|
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
2026-03-25 16:57:47 +03:00
|
|
|
import { Stage, Layer, Rect } from "react-konva";
|
|
|
|
|
import type { KonvaEventObject } from "konva/lib/Node";
|
|
|
|
|
|
|
|
|
|
/** Pixel threshold to distinguish drag from click. */
|
|
|
|
|
const DRAG_MIN_PX = 15;
|
2024-03-02 03:43:02 +03:00
|
|
|
|
|
|
|
|
type LiveCameraViewProps = {
|
2024-05-19 15:39:17 +03:00
|
|
|
config?: FrigateConfig;
|
2024-03-02 03:43:02 +03:00
|
|
|
camera: CameraConfig;
|
2024-08-20 00:01:21 +03:00
|
|
|
supportsFullscreen: boolean;
|
2024-05-31 15:58:33 +03:00
|
|
|
fullscreen: boolean;
|
|
|
|
|
toggleFullscreen: () => void;
|
2024-03-02 03:43:02 +03:00
|
|
|
};
|
2024-05-19 15:39:17 +03:00
|
|
|
export default function LiveCameraView({
|
|
|
|
|
config,
|
|
|
|
|
camera,
|
2024-08-20 00:01:21 +03:00
|
|
|
supportsFullscreen,
|
2024-05-31 15:58:33 +03:00
|
|
|
fullscreen,
|
|
|
|
|
toggleFullscreen,
|
2024-05-19 15:39:17 +03:00
|
|
|
}: LiveCameraViewProps) {
|
2025-03-17 15:26:01 +03:00
|
|
|
const { t } = useTranslation(["views/live", "components/dialog"]);
|
2024-03-02 03:43:02 +03:00
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const { isPortrait } = useMobileOrientation();
|
2024-03-03 06:59:50 +03:00
|
|
|
const mainRef = useRef<HTMLDivElement | null>(null);
|
2024-05-28 01:18:04 +03:00
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
2024-03-05 23:19:56 +03:00
|
|
|
const [{ width: windowWidth, height: windowHeight }] =
|
|
|
|
|
useResizeObserver(window);
|
2024-03-02 03:43:02 +03:00
|
|
|
|
2025-03-22 22:13:41 +03:00
|
|
|
// supported features
|
2025-03-25 14:48:06 +03:00
|
|
|
|
2025-12-17 07:35:43 +03:00
|
|
|
const [streamName, setStreamName, streamNameLoaded] =
|
|
|
|
|
useUserPersistence<string>(
|
|
|
|
|
`${camera.name}-stream`,
|
|
|
|
|
Object.values(camera.live.streams)[0],
|
|
|
|
|
);
|
2025-02-10 19:42:35 +03:00
|
|
|
|
2024-05-19 15:39:17 +03:00
|
|
|
const isRestreamed = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
config &&
|
2025-02-10 19:42:35 +03:00
|
|
|
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
|
|
|
|
|
[config, streamName],
|
2024-05-19 15:39:17 +03:00
|
|
|
);
|
|
|
|
|
|
2025-12-17 07:35:43 +03:00
|
|
|
// validate stored stream name and reset if now invalid
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!streamNameLoaded) return;
|
|
|
|
|
|
|
|
|
|
const available = Object.values(camera.live.streams || {});
|
|
|
|
|
if (available.length === 0) return;
|
|
|
|
|
|
|
|
|
|
if (streamName != null && !available.includes(streamName)) {
|
|
|
|
|
setStreamName(available[0]);
|
|
|
|
|
}
|
|
|
|
|
}, [streamNameLoaded, camera.live.streams, streamName, setStreamName]);
|
|
|
|
|
|
2024-05-17 16:30:22 +03:00
|
|
|
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
2025-03-25 14:48:06 +03:00
|
|
|
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
2024-05-17 16:30:22 +03:00
|
|
|
{
|
|
|
|
|
revalidateOnFocus: false,
|
2025-11-12 02:00:54 +03:00
|
|
|
revalidateOnReconnect: false,
|
|
|
|
|
revalidateIfStale: false,
|
|
|
|
|
dedupingInterval: 60000,
|
2024-05-17 16:30:22 +03:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-13 17:12:37 +03:00
|
|
|
const { twoWayAudio: supports2WayTalk, audioOutput: supportsAudioOutput } =
|
|
|
|
|
useMemo(() => detectCameraAudioFeatures(cameraMetadata), [cameraMetadata]);
|
2024-05-17 16:30:22 +03:00
|
|
|
|
2025-03-25 14:48:06 +03:00
|
|
|
// camera enabled state
|
|
|
|
|
const { payload: enabledState } = useEnabledState(camera.name);
|
|
|
|
|
const cameraEnabled = enabledState === "ON";
|
|
|
|
|
|
2025-05-27 18:26:00 +03:00
|
|
|
// for audio transcriptions
|
|
|
|
|
|
|
|
|
|
const { payload: audioTranscriptionState, send: sendTranscription } =
|
|
|
|
|
useAudioTranscriptionState(camera.name);
|
|
|
|
|
const { payload: transcription } = useAudioLiveTranscription(camera.name);
|
|
|
|
|
const transcriptionRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (transcription) {
|
|
|
|
|
if (transcriptionRef.current) {
|
|
|
|
|
transcriptionRef.current.scrollTop =
|
|
|
|
|
transcriptionRef.current.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [transcription]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
// disable transcriptions when unmounting
|
|
|
|
|
if (audioTranscriptionState == "ON") sendTranscription("OFF");
|
|
|
|
|
};
|
|
|
|
|
}, [audioTranscriptionState, sendTranscription]);
|
|
|
|
|
|
2026-03-25 16:57:47 +03:00
|
|
|
// click-to-move / drag-to-zoom overlay for PTZ cameras
|
2024-03-23 19:53:33 +03:00
|
|
|
|
|
|
|
|
const [clickOverlay, setClickOverlay] = useState(false);
|
|
|
|
|
const clickOverlayRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const { send: sendPtz } = usePtzCommand(camera.name);
|
|
|
|
|
|
2026-03-25 16:57:47 +03:00
|
|
|
// drag rectangle state in stage-local coordinates
|
|
|
|
|
const [ptzRect, setPtzRect] = useState<{
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
} | null>(null);
|
|
|
|
|
const [isPtzDrawing, setIsPtzDrawing] = useState(false);
|
|
|
|
|
// raw origin to determine drag direction (not min/max corrected)
|
|
|
|
|
const ptzOriginRef = useRef<{ x: number; y: number } | null>(null);
|
|
|
|
|
|
|
|
|
|
const [overlaySize] = useResizeObserver(clickOverlayRef);
|
|
|
|
|
|
|
|
|
|
const onPtzStageDown = useCallback(
|
|
|
|
|
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
|
|
|
|
|
const pos = e.target.getStage()?.getPointerPosition();
|
|
|
|
|
if (pos) {
|
|
|
|
|
setIsPtzDrawing(true);
|
|
|
|
|
ptzOriginRef.current = { x: pos.x, y: pos.y };
|
|
|
|
|
setPtzRect({ x: pos.x, y: pos.y, width: 0, height: 0 });
|
2024-03-23 19:53:33 +03:00
|
|
|
}
|
2026-03-25 16:57:47 +03:00
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
2024-03-23 19:53:33 +03:00
|
|
|
|
2026-03-25 16:57:47 +03:00
|
|
|
const onPtzStageMove = useCallback(
|
|
|
|
|
(e: KonvaEventObject<MouseEvent> | KonvaEventObject<TouchEvent>) => {
|
|
|
|
|
if (!isPtzDrawing || !ptzRect) return;
|
|
|
|
|
const pos = e.target.getStage()?.getPointerPosition();
|
|
|
|
|
if (pos) {
|
|
|
|
|
setPtzRect({
|
|
|
|
|
...ptzRect,
|
|
|
|
|
width: pos.x - ptzRect.x,
|
|
|
|
|
height: pos.y - ptzRect.y,
|
|
|
|
|
});
|
2024-03-23 19:53:33 +03:00
|
|
|
}
|
2026-03-25 16:57:47 +03:00
|
|
|
},
|
|
|
|
|
[isPtzDrawing, ptzRect],
|
|
|
|
|
);
|
2024-03-23 19:53:33 +03:00
|
|
|
|
2026-03-25 16:57:47 +03:00
|
|
|
const onPtzStageUp = useCallback(() => {
|
|
|
|
|
setIsPtzDrawing(false);
|
2024-03-23 19:53:33 +03:00
|
|
|
|
2026-03-25 16:57:47 +03:00
|
|
|
if (!ptzRect || !ptzOriginRef.current || overlaySize.width === 0) {
|
|
|
|
|
setPtzRect(null);
|
|
|
|
|
ptzOriginRef.current = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-03-23 19:53:33 +03:00
|
|
|
|
2026-03-25 16:57:47 +03:00
|
|
|
const endX = ptzRect.x + ptzRect.width;
|
|
|
|
|
const endY = ptzRect.y + ptzRect.height;
|
|
|
|
|
const distX = Math.abs(ptzRect.width);
|
|
|
|
|
const distY = Math.abs(ptzRect.height);
|
|
|
|
|
|
|
|
|
|
if (distX < DRAG_MIN_PX && distY < DRAG_MIN_PX) {
|
|
|
|
|
// click — pan/tilt to point without zoom
|
|
|
|
|
const normX = endX / overlaySize.width;
|
|
|
|
|
const normY = endY / overlaySize.height;
|
|
|
|
|
const pan = (normX - 0.5) * 2;
|
|
|
|
|
const tilt = (0.5 - normY) * 2;
|
|
|
|
|
sendPtz(`move_relative_${pan}_${tilt}`);
|
|
|
|
|
} else {
|
|
|
|
|
// drag — pan/tilt to box center, zoom based on box size
|
|
|
|
|
const origin = ptzOriginRef.current;
|
|
|
|
|
|
|
|
|
|
const n0x = Math.min(origin.x, endX) / overlaySize.width;
|
|
|
|
|
const n0y = Math.min(origin.y, endY) / overlaySize.height;
|
|
|
|
|
const n1x = Math.max(origin.x, endX) / overlaySize.width;
|
|
|
|
|
const n1y = Math.max(origin.y, endY) / overlaySize.height;
|
|
|
|
|
|
|
|
|
|
let boxW = n1x - n0x;
|
|
|
|
|
let boxH = n1y - n0y;
|
|
|
|
|
|
|
|
|
|
// correct box to match camera aspect ratio so zoom is uniform
|
|
|
|
|
const frameAR = overlaySize.width / overlaySize.height;
|
|
|
|
|
const boxAR = boxW / boxH;
|
|
|
|
|
if (boxAR > frameAR) {
|
|
|
|
|
boxH = boxW / frameAR;
|
|
|
|
|
} else {
|
|
|
|
|
boxW = boxH * frameAR;
|
2024-03-23 19:53:33 +03:00
|
|
|
}
|
2026-03-25 16:57:47 +03:00
|
|
|
|
|
|
|
|
const centerX = (n0x + n1x) / 2;
|
|
|
|
|
const centerY = (n0y + n1y) / 2;
|
|
|
|
|
const pan = (centerX - 0.5) * 2;
|
|
|
|
|
const tilt = (0.5 - centerY) * 2;
|
|
|
|
|
|
|
|
|
|
// zoom magnitude from box size (small box = more zoom)
|
|
|
|
|
let zoom = Math.max(0.01, Math.min(1, Math.max(boxW, boxH)));
|
|
|
|
|
// drag direction: top-left → bottom-right = zoom in, reverse = zoom out
|
|
|
|
|
const zoomIn = endX > origin.x && endY > origin.y;
|
|
|
|
|
if (!zoomIn) zoom = -zoom;
|
|
|
|
|
|
|
|
|
|
sendPtz(`move_relative_${pan}_${tilt}_${zoom}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setPtzRect(null);
|
|
|
|
|
ptzOriginRef.current = null;
|
|
|
|
|
}, [ptzRect, overlaySize, sendPtz]);
|
2024-03-23 19:53:33 +03:00
|
|
|
|
2024-05-31 15:58:33 +03:00
|
|
|
// pip state
|
2024-03-03 06:59:50 +03:00
|
|
|
|
2024-04-18 19:34:18 +03:00
|
|
|
useEffect(() => {
|
|
|
|
|
setPip(document.pictureInPictureElement != null);
|
|
|
|
|
// we know that these deps are correct
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [document.pictureInPictureElement]);
|
|
|
|
|
|
2024-03-02 03:43:02 +03:00
|
|
|
// playback state
|
|
|
|
|
|
2024-08-09 15:46:39 +03:00
|
|
|
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
|
2024-03-13 02:19:02 +03:00
|
|
|
const [mic, setMic] = useState(false);
|
2024-06-04 18:11:32 +03:00
|
|
|
const [webRTC, setWebRTC] = useState(false);
|
2024-04-02 15:45:16 +03:00
|
|
|
const [pip, setPip] = useState(false);
|
2024-05-31 16:52:42 +03:00
|
|
|
const [lowBandwidth, setLowBandwidth] = useState(false);
|
2024-03-02 03:43:02 +03:00
|
|
|
|
2025-12-01 16:59:54 +03:00
|
|
|
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
|
2025-02-10 19:42:35 +03:00
|
|
|
`${camera.name}-background-play`,
|
|
|
|
|
false,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [showStats, setShowStats] = useState(false);
|
2025-09-30 02:45:55 +03:00
|
|
|
const [debug, setDebug] = useState(false);
|
2025-02-10 19:42:35 +03:00
|
|
|
|
2025-10-09 17:49:42 +03:00
|
|
|
useSearchEffect("debug", (value: string) => {
|
|
|
|
|
if (value === "true") {
|
|
|
|
|
setDebug(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
2024-04-30 15:52:56 +03:00
|
|
|
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
|
|
|
|
|
width: 0,
|
|
|
|
|
height: 0,
|
|
|
|
|
});
|
|
|
|
|
|
2024-03-13 17:04:11 +03:00
|
|
|
const preferredLiveMode = useMemo(() => {
|
2024-05-29 01:35:36 +03:00
|
|
|
if (mic) {
|
2024-03-13 17:04:11 +03:00
|
|
|
return "webrtc";
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-04 18:11:32 +03:00
|
|
|
if (webRTC && isRestreamed) {
|
|
|
|
|
return "webrtc";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (webRTC && !isRestreamed) {
|
|
|
|
|
return "jsmpeg";
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-31 16:52:42 +03:00
|
|
|
if (lowBandwidth) {
|
|
|
|
|
return "jsmpeg";
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-03 16:44:25 +03:00
|
|
|
if (!("MediaSource" in window || "ManagedMediaSource" in window)) {
|
|
|
|
|
return "webrtc";
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-17 21:16:48 +03:00
|
|
|
if (!isRestreamed) {
|
|
|
|
|
return "jsmpeg";
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-13 17:04:11 +03:00
|
|
|
return "mse";
|
2024-06-04 18:11:32 +03:00
|
|
|
}, [lowBandwidth, mic, webRTC, isRestreamed]);
|
2024-05-31 16:52:42 +03:00
|
|
|
|
2026-04-23 05:37:17 +03:00
|
|
|
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
|
2024-10-01 00:55:44 +03:00
|
|
|
if (!modifiers.down) {
|
2025-10-02 16:21:37 +03:00
|
|
|
return true;
|
2024-10-01 00:55:44 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (key) {
|
|
|
|
|
case "m":
|
|
|
|
|
if (supportsAudioOutput) {
|
|
|
|
|
setAudio(!audio);
|
2025-10-02 16:21:37 +03:00
|
|
|
return true;
|
2024-10-01 00:55:44 +03:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "t":
|
|
|
|
|
if (supports2WayTalk) {
|
|
|
|
|
setMic(!mic);
|
2025-10-02 16:21:37 +03:00
|
|
|
return true;
|
2024-10-01 00:55:44 +03:00
|
|
|
}
|
|
|
|
|
break;
|
2026-04-23 05:37:17 +03:00
|
|
|
case "Escape":
|
|
|
|
|
if (!fullscreen) {
|
|
|
|
|
navigate(-1);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
break;
|
2024-10-01 00:55:44 +03:00
|
|
|
}
|
2025-10-02 16:21:37 +03:00
|
|
|
|
|
|
|
|
return false;
|
2024-10-01 00:55:44 +03:00
|
|
|
});
|
|
|
|
|
|
2024-05-31 16:52:42 +03:00
|
|
|
// layout state
|
2024-03-13 17:04:11 +03:00
|
|
|
|
2024-03-05 23:19:56 +03:00
|
|
|
const windowAspectRatio = useMemo(() => {
|
|
|
|
|
return windowWidth / windowHeight;
|
|
|
|
|
}, [windowWidth, windowHeight]);
|
|
|
|
|
|
2024-05-28 16:11:35 +03:00
|
|
|
const containerAspectRatio = useMemo(() => {
|
|
|
|
|
if (!containerRef.current) {
|
|
|
|
|
return windowAspectRatio;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return containerRef.current.clientWidth / containerRef.current.clientHeight;
|
|
|
|
|
}, [windowAspectRatio, containerRef]);
|
|
|
|
|
|
2024-03-05 23:19:56 +03:00
|
|
|
const cameraAspectRatio = useMemo(() => {
|
2024-04-30 15:52:56 +03:00
|
|
|
if (fullResolution.width && fullResolution.height) {
|
|
|
|
|
return fullResolution.width / fullResolution.height;
|
|
|
|
|
} else {
|
|
|
|
|
return camera.detect.width / camera.detect.height;
|
|
|
|
|
}
|
|
|
|
|
}, [camera, fullResolution]);
|
2024-03-05 23:19:56 +03:00
|
|
|
|
2024-05-28 17:05:04 +03:00
|
|
|
const constrainedAspectRatio = useMemo<number>(() => {
|
2024-03-05 23:19:56 +03:00
|
|
|
if (isMobile || fullscreen) {
|
|
|
|
|
return cameraAspectRatio;
|
|
|
|
|
} else {
|
2024-05-28 16:11:35 +03:00
|
|
|
return containerAspectRatio < cameraAspectRatio
|
|
|
|
|
? containerAspectRatio
|
|
|
|
|
: cameraAspectRatio;
|
|
|
|
|
}
|
|
|
|
|
}, [cameraAspectRatio, containerAspectRatio, fullscreen]);
|
|
|
|
|
|
|
|
|
|
const growClassName = useMemo(() => {
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
if (isPortrait) {
|
|
|
|
|
return "absolute left-0.5 right-0.5 top-[50%] -translate-y-[50%]";
|
|
|
|
|
} else {
|
2024-05-28 17:05:04 +03:00
|
|
|
if (cameraAspectRatio > containerAspectRatio) {
|
2024-05-28 16:11:35 +03:00
|
|
|
return "p-2 absolute left-0 top-[50%] -translate-y-[50%]";
|
|
|
|
|
} else {
|
|
|
|
|
return "p-2 absolute top-0.5 bottom-0.5 left-[50%] -translate-x-[50%]";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fullscreen) {
|
2024-05-28 17:05:04 +03:00
|
|
|
if (cameraAspectRatio > containerAspectRatio) {
|
2024-05-28 16:11:35 +03:00
|
|
|
return "absolute inset-x-2 top-[50%] -translate-y-[50%]";
|
|
|
|
|
} else {
|
|
|
|
|
return "absolute inset-y-2 left-[50%] -translate-x-[50%]";
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return "absolute top-0.5 bottom-0.5 left-[50%] -translate-x-[50%]";
|
2024-03-05 23:19:56 +03:00
|
|
|
}
|
2024-05-28 17:05:04 +03:00
|
|
|
}, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]);
|
2024-03-05 23:19:56 +03:00
|
|
|
|
2025-03-04 23:30:34 +03:00
|
|
|
// On mobile devices that support it, try to orient screen
|
|
|
|
|
// to best fit the camera feed in fullscreen mode
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
const screenOrientation = screen.orientation as any;
|
2025-09-14 15:51:39 +03:00
|
|
|
if (!screenOrientation?.lock || !screenOrientation?.unlock) {
|
2025-03-04 23:30:34 +03:00
|
|
|
// Browser does not support ScreenOrientation APIs that we need
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fullscreen) {
|
|
|
|
|
const orientationForBestFit =
|
|
|
|
|
cameraAspectRatio > 1 ? "landscape" : "portrait";
|
|
|
|
|
|
|
|
|
|
// If the current device doesn't support locking orientation,
|
|
|
|
|
// this promise will reject with an error that we can ignore
|
|
|
|
|
screenOrientation.lock(orientationForBestFit).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => screenOrientation.unlock();
|
|
|
|
|
}, [fullscreen, cameraAspectRatio]);
|
|
|
|
|
|
2024-08-17 21:16:48 +03:00
|
|
|
const handleError = useCallback(
|
|
|
|
|
(e: LivePlayerError) => {
|
|
|
|
|
if (e) {
|
|
|
|
|
if (
|
|
|
|
|
!webRTC &&
|
|
|
|
|
config &&
|
|
|
|
|
config.go2rtc?.webrtc?.candidates?.length > 0
|
|
|
|
|
) {
|
|
|
|
|
setWebRTC(true);
|
|
|
|
|
} else {
|
|
|
|
|
setWebRTC(false);
|
|
|
|
|
setLowBandwidth(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[config, webRTC],
|
|
|
|
|
);
|
2024-06-30 15:04:45 +03:00
|
|
|
|
2024-03-02 03:43:02 +03:00
|
|
|
return (
|
2025-09-30 02:45:55 +03:00
|
|
|
<TransformWrapper
|
|
|
|
|
minScale={1.0}
|
|
|
|
|
wheel={{ smoothStep: 0.005 }}
|
2026-03-25 16:57:47 +03:00
|
|
|
disabled={debug || clickOverlay}
|
|
|
|
|
panning={{ disabled: clickOverlay }}
|
2025-09-30 02:45:55 +03:00
|
|
|
>
|
2025-02-10 19:42:35 +03:00
|
|
|
<Toaster position="top-center" closeButton={true} />
|
2024-03-02 03:43:02 +03:00
|
|
|
<div
|
2024-03-15 17:57:58 +03:00
|
|
|
ref={mainRef}
|
2024-03-03 06:59:50 +03:00
|
|
|
className={
|
|
|
|
|
fullscreen
|
2024-05-14 18:06:44 +03:00
|
|
|
? `fixed inset-0 z-30 bg-black`
|
|
|
|
|
: `flex size-full flex-col p-2 ${isMobile ? "landscape:flex-row landscape:gap-1" : ""}`
|
2024-03-03 06:59:50 +03:00
|
|
|
}
|
2024-03-02 03:43:02 +03:00
|
|
|
>
|
2024-03-15 17:57:58 +03:00
|
|
|
<div
|
|
|
|
|
className={
|
|
|
|
|
fullscreen
|
2024-05-14 18:06:44 +03:00
|
|
|
? `absolute right-32 top-1 z-40 ${isMobile ? "landscape:bottom-1 landscape:left-2 landscape:right-auto landscape:top-auto" : ""}`
|
|
|
|
|
: `flex h-12 w-full flex-row items-center justify-between ${isMobile ? "landscape:h-full landscape:w-12 landscape:flex-col" : ""}`
|
2024-03-15 17:57:58 +03:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{!fullscreen ? (
|
2024-04-07 23:37:33 +03:00
|
|
|
<div
|
|
|
|
|
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
|
|
|
|
|
>
|
2024-04-02 22:25:02 +03:00
|
|
|
<Button
|
|
|
|
|
className={`flex items-center gap-2.5 rounded-lg`}
|
2025-03-16 18:36:20 +03:00
|
|
|
aria-label={t("label.back", { ns: "common" })}
|
2024-04-02 22:25:02 +03:00
|
|
|
size="sm"
|
|
|
|
|
onClick={() => navigate(-1)}
|
|
|
|
|
>
|
2024-04-11 23:54:09 +03:00
|
|
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
2025-03-16 18:36:20 +03:00
|
|
|
{isDesktop && (
|
|
|
|
|
<div className="text-primary">
|
|
|
|
|
{t("button.back", { ns: "common" })}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-04-02 22:25:02 +03:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
className="flex items-center gap-2.5 rounded-lg"
|
2025-03-16 18:36:20 +03:00
|
|
|
aria-label={t("history.label")}
|
2024-04-02 22:25:02 +03:00
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
2024-04-10 16:40:17 +03:00
|
|
|
navigate("review", {
|
2024-04-02 22:25:02 +03:00
|
|
|
state: {
|
|
|
|
|
severity: "alert",
|
|
|
|
|
recording: {
|
|
|
|
|
camera: camera.name,
|
|
|
|
|
startTime: Date.now() / 1000 - 30,
|
|
|
|
|
severity: "alert",
|
|
|
|
|
} as RecordingStartingPoint,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
>
|
2024-04-11 23:54:09 +03:00
|
|
|
<LuHistory className="size-5 text-secondary-foreground" />
|
2025-03-16 18:36:20 +03:00
|
|
|
{isDesktop && (
|
|
|
|
|
<div className="text-primary">
|
|
|
|
|
{t("button.history", { ns: "common" })}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-04-02 22:25:02 +03:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
2024-03-15 17:57:58 +03:00
|
|
|
) : (
|
|
|
|
|
<div />
|
|
|
|
|
)}
|
2025-09-23 05:21:51 +03:00
|
|
|
<div
|
|
|
|
|
className={`flex flex-row items-center gap-2 *:rounded-lg ${isMobile ? "landscape:flex-col" : ""}`}
|
|
|
|
|
>
|
|
|
|
|
{fullscreen && (
|
|
|
|
|
<Button
|
|
|
|
|
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
|
|
|
|
|
aria-label={t("label.back", { ns: "common" })}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => navigate(-1)}
|
|
|
|
|
>
|
|
|
|
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
|
|
|
|
{isDesktop && (
|
|
|
|
|
<div className="text-secondary-foreground">
|
|
|
|
|
{t("button.back", { ns: "common" })}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{supportsFullscreen && (
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={fullscreen ? FaCompress : FaExpand}
|
|
|
|
|
isActive={fullscreen}
|
2025-09-30 02:45:55 +03:00
|
|
|
disabled={debug}
|
2025-09-23 05:21:51 +03:00
|
|
|
title={
|
|
|
|
|
fullscreen
|
|
|
|
|
? t("button.close", { ns: "common" })
|
|
|
|
|
: t("button.fullscreen", { ns: "common" })
|
|
|
|
|
}
|
|
|
|
|
onClick={toggleFullscreen}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{!isIOS && !isFirefox && preferredLiveMode != "jsmpeg" && (
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={LuPictureInPicture}
|
|
|
|
|
isActive={pip}
|
|
|
|
|
title={
|
|
|
|
|
pip
|
|
|
|
|
? t("button.close", { ns: "common" })
|
|
|
|
|
: t("button.pictureInPicture", { ns: "common" })
|
|
|
|
|
}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!pip) {
|
|
|
|
|
setPip(true);
|
|
|
|
|
} else {
|
|
|
|
|
document.exitPictureInPicture();
|
|
|
|
|
setPip(false);
|
2025-03-17 15:26:01 +03:00
|
|
|
}
|
2025-09-23 05:21:51 +03:00
|
|
|
}}
|
2025-09-30 02:45:55 +03:00
|
|
|
disabled={!cameraEnabled || debug}
|
2025-09-23 05:21:51 +03:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{supports2WayTalk && (
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
|
|
|
|
|
isActive={mic}
|
|
|
|
|
title={
|
|
|
|
|
mic
|
|
|
|
|
? t("twoWayTalk.disable", { ns: "views/live" })
|
|
|
|
|
: t("twoWayTalk.enable", { ns: "views/live" })
|
2024-05-16 17:32:39 +03:00
|
|
|
}
|
2025-09-23 05:21:51 +03:00
|
|
|
onClick={() => {
|
|
|
|
|
setMic(!mic);
|
|
|
|
|
if (!mic && !audio) {
|
|
|
|
|
setAudio(true);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-09-30 02:45:55 +03:00
|
|
|
disabled={!cameraEnabled || debug}
|
2025-09-23 05:21:51 +03:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={audio ? GiSpeaker : GiSpeakerOff}
|
|
|
|
|
isActive={audio ?? false}
|
|
|
|
|
title={
|
|
|
|
|
audio
|
|
|
|
|
? t("cameraAudio.disable", { ns: "views/live" })
|
|
|
|
|
: t("cameraAudio.enable", { ns: "views/live" })
|
2025-05-27 18:26:00 +03:00
|
|
|
}
|
2025-09-23 05:21:51 +03:00
|
|
|
onClick={() => setAudio(!audio)}
|
2025-09-30 02:45:55 +03:00
|
|
|
disabled={!cameraEnabled || debug}
|
2024-03-15 16:03:14 +03:00
|
|
|
/>
|
2025-09-23 05:21:51 +03:00
|
|
|
)}
|
|
|
|
|
<FrigateCameraFeatures
|
|
|
|
|
camera={camera}
|
|
|
|
|
recordingEnabled={camera.record.enabled_in_config}
|
|
|
|
|
audioDetectEnabled={camera.audio.enabled_in_config}
|
|
|
|
|
autotrackingEnabled={camera.onvif.autotracking.enabled_in_config}
|
|
|
|
|
transcriptionEnabled={
|
|
|
|
|
camera.audio_transcription.enabled_in_config
|
|
|
|
|
}
|
|
|
|
|
fullscreen={fullscreen}
|
|
|
|
|
streamName={streamName ?? ""}
|
|
|
|
|
setStreamName={setStreamName}
|
|
|
|
|
preferredLiveMode={preferredLiveMode}
|
|
|
|
|
playInBackground={playInBackground ?? false}
|
|
|
|
|
setPlayInBackground={setPlayInBackground}
|
|
|
|
|
showStats={showStats}
|
|
|
|
|
setShowStats={setShowStats}
|
|
|
|
|
isRestreamed={isRestreamed ?? false}
|
|
|
|
|
setLowBandwidth={setLowBandwidth}
|
|
|
|
|
supportsAudioOutput={supportsAudioOutput}
|
|
|
|
|
supports2WayTalk={supports2WayTalk}
|
|
|
|
|
cameraEnabled={cameraEnabled}
|
2025-09-30 02:45:55 +03:00
|
|
|
debug={debug}
|
|
|
|
|
setDebug={setDebug}
|
2025-09-23 05:21:51 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
2024-03-02 03:43:02 +03:00
|
|
|
</div>
|
2025-09-30 02:45:55 +03:00
|
|
|
{!debug ? (
|
|
|
|
|
<div id="player-container" className="size-full" ref={containerRef}>
|
|
|
|
|
<TransformComponent
|
|
|
|
|
wrapperStyle={{
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
}}
|
|
|
|
|
contentStyle={{
|
|
|
|
|
position: "relative",
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
padding: "8px",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
2026-03-25 22:14:32 +03:00
|
|
|
className={cn(
|
|
|
|
|
"flex flex-col items-center justify-center",
|
|
|
|
|
growClassName,
|
|
|
|
|
)}
|
2025-09-30 02:45:55 +03:00
|
|
|
ref={clickOverlayRef}
|
|
|
|
|
style={{
|
|
|
|
|
aspectRatio: constrainedAspectRatio,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-03-25 16:57:47 +03:00
|
|
|
{clickOverlay && overlaySize.width > 0 && (
|
2026-03-25 22:14:32 +03:00
|
|
|
<div
|
|
|
|
|
className="absolute z-40 cursor-crosshair"
|
|
|
|
|
style={{
|
|
|
|
|
width: overlaySize.width,
|
|
|
|
|
height: overlaySize.height,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-03-25 16:57:47 +03:00
|
|
|
<Stage
|
|
|
|
|
width={overlaySize.width}
|
|
|
|
|
height={overlaySize.height}
|
|
|
|
|
onMouseDown={onPtzStageDown}
|
|
|
|
|
onMouseMove={onPtzStageMove}
|
|
|
|
|
onMouseUp={onPtzStageUp}
|
|
|
|
|
onTouchStart={onPtzStageDown}
|
|
|
|
|
onTouchMove={onPtzStageMove}
|
|
|
|
|
onTouchEnd={onPtzStageUp}
|
|
|
|
|
>
|
|
|
|
|
<Layer>
|
|
|
|
|
{ptzRect && (
|
|
|
|
|
<Rect
|
|
|
|
|
x={ptzRect.x}
|
|
|
|
|
y={ptzRect.y}
|
|
|
|
|
width={ptzRect.width}
|
|
|
|
|
height={ptzRect.height}
|
|
|
|
|
stroke="white"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
dash={[6, 4]}
|
|
|
|
|
opacity={0.8}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</Layer>
|
|
|
|
|
</Stage>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-09-30 02:45:55 +03:00
|
|
|
<LivePlayer
|
|
|
|
|
key={camera.name}
|
|
|
|
|
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
|
|
|
|
windowVisible
|
|
|
|
|
showStillWithoutActivity={false}
|
2025-10-29 17:20:11 +03:00
|
|
|
alwaysShowCameraName={false}
|
2025-09-30 02:45:55 +03:00
|
|
|
cameraConfig={camera}
|
|
|
|
|
playAudio={audio}
|
|
|
|
|
playInBackground={playInBackground ?? false}
|
|
|
|
|
showStats={showStats}
|
|
|
|
|
micEnabled={mic}
|
|
|
|
|
iOSCompatFullScreen={isIOS}
|
|
|
|
|
preferredLiveMode={preferredLiveMode}
|
|
|
|
|
useWebGL={true}
|
|
|
|
|
streamName={streamName ?? ""}
|
|
|
|
|
pip={pip}
|
|
|
|
|
containerRef={containerRef}
|
|
|
|
|
setFullResolution={setFullResolution}
|
|
|
|
|
onError={handleError}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</TransformComponent>
|
|
|
|
|
{camera?.audio?.enabled_in_config &&
|
|
|
|
|
audioTranscriptionState == "ON" &&
|
|
|
|
|
transcription != null && (
|
|
|
|
|
<div
|
|
|
|
|
ref={transcriptionRef}
|
2026-05-30 01:00:30 +03:00
|
|
|
className="scrollbar-container absolute bottom-4 left-1/2 max-h-[15vh] w-[75%] -translate-x-1/2 overflow-y-auto rounded-lg bg-black/70 p-2 text-white md:w-[50%]"
|
2025-09-30 02:45:55 +03:00
|
|
|
>
|
|
|
|
|
{transcription}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2024-05-28 01:18:04 +03:00
|
|
|
<TransformComponent
|
|
|
|
|
wrapperStyle={{
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
|
|
|
|
}}
|
|
|
|
|
contentStyle={{
|
|
|
|
|
position: "relative",
|
|
|
|
|
width: "100%",
|
|
|
|
|
height: "100%",
|
2024-03-15 17:57:58 +03:00
|
|
|
}}
|
|
|
|
|
>
|
2025-09-30 02:45:55 +03:00
|
|
|
<ObjectSettingsView selectedCamera={camera.name} />
|
2024-05-28 01:18:04 +03:00
|
|
|
</TransformComponent>
|
2025-09-30 02:45:55 +03:00
|
|
|
)}
|
2024-03-02 03:43:02 +03:00
|
|
|
</div>
|
2024-06-30 15:04:45 +03:00
|
|
|
{camera.onvif.host != "" && (
|
|
|
|
|
<div className="flex flex-col items-center justify-center">
|
|
|
|
|
<PtzControlPanel
|
2025-09-30 02:45:55 +03:00
|
|
|
className={debug && isMobile ? "bottom-auto top-[25%]" : ""}
|
2024-06-30 15:04:45 +03:00
|
|
|
camera={camera.name}
|
2025-05-15 01:44:06 +03:00
|
|
|
enabled={cameraEnabled}
|
2024-06-30 15:04:45 +03:00
|
|
|
clickOverlay={clickOverlay}
|
|
|
|
|
setClickOverlay={setClickOverlay}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-03-15 17:57:58 +03:00
|
|
|
</TransformWrapper>
|
2024-03-02 03:43:02 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-02 15:45:16 +03:00
|
|
|
type FrigateCameraFeaturesProps = {
|
2025-02-10 19:42:35 +03:00
|
|
|
camera: CameraConfig;
|
2024-08-12 16:21:21 +03:00
|
|
|
recordingEnabled: boolean;
|
2024-04-02 15:45:16 +03:00
|
|
|
audioDetectEnabled: boolean;
|
2024-05-16 17:32:39 +03:00
|
|
|
autotrackingEnabled: boolean;
|
2025-05-27 18:26:00 +03:00
|
|
|
transcriptionEnabled: boolean;
|
2024-04-02 15:45:16 +03:00
|
|
|
fullscreen: boolean;
|
2025-02-10 19:42:35 +03:00
|
|
|
streamName: string;
|
|
|
|
|
setStreamName?: (value: string | undefined) => void;
|
|
|
|
|
preferredLiveMode: string;
|
|
|
|
|
playInBackground: boolean;
|
|
|
|
|
setPlayInBackground: (value: boolean | undefined) => void;
|
|
|
|
|
showStats: boolean;
|
|
|
|
|
setShowStats: (value: boolean) => void;
|
|
|
|
|
isRestreamed: boolean;
|
|
|
|
|
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
|
|
|
|
|
supportsAudioOutput: boolean;
|
|
|
|
|
supports2WayTalk: boolean;
|
2025-03-03 18:30:52 +03:00
|
|
|
cameraEnabled: boolean;
|
2025-09-30 02:45:55 +03:00
|
|
|
debug: boolean;
|
|
|
|
|
setDebug: (debug: boolean) => void;
|
2024-04-02 15:45:16 +03:00
|
|
|
};
|
|
|
|
|
function FrigateCameraFeatures({
|
|
|
|
|
camera,
|
2024-08-12 16:21:21 +03:00
|
|
|
recordingEnabled,
|
2024-04-02 15:45:16 +03:00
|
|
|
audioDetectEnabled,
|
2024-05-16 17:32:39 +03:00
|
|
|
autotrackingEnabled,
|
2025-05-27 18:26:00 +03:00
|
|
|
transcriptionEnabled,
|
2024-04-02 15:45:16 +03:00
|
|
|
fullscreen,
|
2025-02-10 19:42:35 +03:00
|
|
|
streamName,
|
|
|
|
|
setStreamName,
|
|
|
|
|
preferredLiveMode,
|
|
|
|
|
playInBackground,
|
|
|
|
|
setPlayInBackground,
|
|
|
|
|
showStats,
|
|
|
|
|
setShowStats,
|
|
|
|
|
isRestreamed,
|
|
|
|
|
setLowBandwidth,
|
|
|
|
|
supportsAudioOutput,
|
|
|
|
|
supports2WayTalk,
|
2025-03-03 18:30:52 +03:00
|
|
|
cameraEnabled,
|
2025-09-30 02:45:55 +03:00
|
|
|
debug,
|
|
|
|
|
setDebug,
|
2024-04-02 15:45:16 +03:00
|
|
|
}: FrigateCameraFeaturesProps) {
|
2025-03-17 15:26:01 +03:00
|
|
|
const { t } = useTranslation(["views/live", "components/dialog"]);
|
2025-05-28 15:10:45 +03:00
|
|
|
const { getLocaleDocUrl } = useDocDomain();
|
2025-03-16 18:36:20 +03:00
|
|
|
|
2025-02-10 19:42:35 +03:00
|
|
|
const { payload: detectState, send: sendDetect } = useDetectState(
|
|
|
|
|
camera.name,
|
|
|
|
|
);
|
2025-03-03 18:30:52 +03:00
|
|
|
const { payload: enabledState, send: sendEnabled } = useEnabledState(
|
|
|
|
|
camera.name,
|
|
|
|
|
);
|
2025-02-10 19:42:35 +03:00
|
|
|
const { payload: recordState, send: sendRecord } = useRecordingsState(
|
|
|
|
|
camera.name,
|
|
|
|
|
);
|
|
|
|
|
const { payload: snapshotState, send: sendSnapshot } = useSnapshotsState(
|
|
|
|
|
camera.name,
|
|
|
|
|
);
|
|
|
|
|
const { payload: audioState, send: sendAudio } = useAudioState(camera.name);
|
2024-05-16 17:32:39 +03:00
|
|
|
const { payload: autotrackingState, send: sendAutotracking } =
|
2025-02-10 19:42:35 +03:00
|
|
|
useAutotrackingState(camera.name);
|
2025-05-27 18:26:00 +03:00
|
|
|
const { payload: transcriptionState, send: sendTranscription } =
|
|
|
|
|
useAudioTranscriptionState(camera.name);
|
2025-02-10 19:42:35 +03:00
|
|
|
|
2025-03-08 19:01:08 +03:00
|
|
|
// roles
|
|
|
|
|
|
|
|
|
|
const isAdmin = useIsAdmin();
|
|
|
|
|
|
2025-02-10 19:42:35 +03:00
|
|
|
// manual event
|
|
|
|
|
|
|
|
|
|
const recordingEventIdRef = useRef<string | null>(null);
|
|
|
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
|
|
|
const [activeToastId, setActiveToastId] = useState<string | number | null>(
|
|
|
|
|
null,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const createEvent = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await axios.post(
|
|
|
|
|
`events/${camera.name}/on_demand/create`,
|
|
|
|
|
{
|
|
|
|
|
include_recording: true,
|
|
|
|
|
duration: null,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (response.data.success) {
|
|
|
|
|
recordingEventIdRef.current = response.data.event_id;
|
|
|
|
|
setIsRecording(true);
|
|
|
|
|
const toastId = toast.success(
|
|
|
|
|
<div className="flex flex-col space-y-3">
|
2025-03-16 18:36:20 +03:00
|
|
|
<div className="font-semibold">{t("manualRecording.started")}</div>
|
2025-10-15 20:09:28 +03:00
|
|
|
{!camera.record.enabled ||
|
|
|
|
|
(camera.record.alerts.retain.days == 0 && (
|
|
|
|
|
<div>{t("manualRecording.recordDisabledTips")}</div>
|
|
|
|
|
))}
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>,
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
duration: 10000,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
setActiveToastId(toastId);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(t("manualRecording.failedToStart"), {
|
2025-02-10 19:42:35 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-03-16 18:36:20 +03:00
|
|
|
}, [camera, t]);
|
2025-02-10 19:42:35 +03:00
|
|
|
|
|
|
|
|
const endEvent = useCallback(() => {
|
|
|
|
|
if (activeToastId) {
|
|
|
|
|
toast.dismiss(activeToastId);
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (recordingEventIdRef.current) {
|
|
|
|
|
axios.put(`events/${recordingEventIdRef.current}/end`, {
|
|
|
|
|
end_time: Math.ceil(Date.now() / 1000),
|
|
|
|
|
});
|
|
|
|
|
recordingEventIdRef.current = null;
|
|
|
|
|
setIsRecording(false);
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.success(t("manualRecording.ended"), {
|
2025-02-10 19:42:35 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(t("manualRecording.failedToEnd"), {
|
2025-02-10 19:42:35 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-03-16 18:36:20 +03:00
|
|
|
}, [activeToastId, t]);
|
2025-02-10 19:42:35 +03:00
|
|
|
|
2025-11-17 17:12:05 +03:00
|
|
|
const endEventViaBeacon = useCallback(() => {
|
|
|
|
|
if (!recordingEventIdRef.current) return;
|
|
|
|
|
|
|
|
|
|
const url = `${window.location.origin}/api/events/${recordingEventIdRef.current}/end`;
|
|
|
|
|
const payload = JSON.stringify({
|
|
|
|
|
end_time: Math.ceil(Date.now() / 1000),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// this needs to be a synchronous XMLHttpRequest to guarantee the PUT
|
|
|
|
|
// reaches the server before the browser kills the page
|
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
|
|
|
try {
|
|
|
|
|
xhr.open("PUT", url, false);
|
|
|
|
|
xhr.setRequestHeader("Content-Type", "application/json");
|
|
|
|
|
xhr.setRequestHeader("X-CSRF-TOKEN", "1");
|
|
|
|
|
xhr.setRequestHeader("X-CACHE-BYPASS", "1");
|
|
|
|
|
xhr.withCredentials = true;
|
|
|
|
|
xhr.send(payload);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Silently ignore errors during unload
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-02-10 19:42:35 +03:00
|
|
|
const handleEventButtonClick = useCallback(() => {
|
|
|
|
|
if (isRecording) {
|
|
|
|
|
endEvent();
|
|
|
|
|
} else {
|
|
|
|
|
createEvent();
|
|
|
|
|
}
|
|
|
|
|
}, [createEvent, endEvent, isRecording]);
|
|
|
|
|
|
2025-10-14 22:05:35 +03:00
|
|
|
const [isSnapshotLoading, setIsSnapshotLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
const handleSnapshotClick = useCallback(async () => {
|
|
|
|
|
setIsSnapshotLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
let result: SnapshotResult;
|
|
|
|
|
|
|
|
|
|
if (isRestreamed && preferredLiveMode !== "jsmpeg") {
|
|
|
|
|
// For restreamed streams with video elements (MSE/WebRTC), grab directly from video element
|
|
|
|
|
result = await grabVideoSnapshot();
|
|
|
|
|
} else {
|
|
|
|
|
// For detect stream or JSMpeg players, use the API endpoint
|
|
|
|
|
result = await fetchCameraSnapshot(camera.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
const { dataUrl } = result.data;
|
|
|
|
|
const filename = generateSnapshotFilename(camera.name);
|
|
|
|
|
downloadSnapshot(dataUrl, filename);
|
|
|
|
|
toast.success(t("snapshot.downloadStarted"));
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(t("snapshot.captureFailed"));
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSnapshotLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [camera.name, isRestreamed, preferredLiveMode, t]);
|
|
|
|
|
|
2025-02-10 19:42:35 +03:00
|
|
|
useEffect(() => {
|
2025-11-17 17:12:05 +03:00
|
|
|
// Handle page unload/close (browser close, tab close, refresh, navigation to external site)
|
|
|
|
|
const handleBeforeUnload = () => {
|
|
|
|
|
if (recordingEventIdRef.current) {
|
|
|
|
|
endEventViaBeacon();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
|
|
|
|
2025-02-10 19:42:35 +03:00
|
|
|
// ensure manual event is stopped when component unmounts
|
|
|
|
|
return () => {
|
2025-11-17 17:12:05 +03:00
|
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
|
|
|
|
2025-02-10 19:42:35 +03:00
|
|
|
if (recordingEventIdRef.current) {
|
|
|
|
|
endEvent();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// mount/unmount only
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
2024-04-02 15:45:16 +03:00
|
|
|
// desktop shows icons part of row
|
2024-05-18 19:54:46 +03:00
|
|
|
if (isDesktop || isTablet) {
|
2024-04-02 15:45:16 +03:00
|
|
|
return (
|
|
|
|
|
<>
|
2025-03-08 19:01:08 +03:00
|
|
|
{isAdmin && (
|
|
|
|
|
<>
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
|
|
|
|
|
isActive={enabledState == "ON"}
|
2025-03-16 18:36:20 +03:00
|
|
|
title={
|
2026-05-24 23:59:56 +03:00
|
|
|
enabledState == "ON" ? t("camera.turnOff") : t("camera.turnOn")
|
2025-03-16 18:36:20 +03:00
|
|
|
}
|
2025-03-08 19:01:08 +03:00
|
|
|
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
|
2025-09-30 02:45:55 +03:00
|
|
|
disabled={debug}
|
2025-03-08 19:01:08 +03:00
|
|
|
/>
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
|
|
|
|
|
isActive={detectState == "ON"}
|
2025-03-16 18:36:20 +03:00
|
|
|
title={
|
|
|
|
|
detectState == "ON" ? t("detect.disable") : t("detect.enable")
|
|
|
|
|
}
|
2025-03-08 19:01:08 +03:00
|
|
|
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
|
|
|
|
|
disabled={!cameraEnabled}
|
|
|
|
|
/>
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={recordState == "ON" ? LuVideo : LuVideoOff}
|
|
|
|
|
isActive={recordState == "ON"}
|
2025-03-16 18:36:20 +03:00
|
|
|
title={
|
|
|
|
|
recordState == "ON"
|
|
|
|
|
? t("recording.disable")
|
UI fixes (#23127)
* hide camera overrides badge from system sections
* show empty card on camera metrics page when no cameras are defined
* fix enabled camera state switch after adding via wizard
Cameras added mid-session have no WS state until the dispatcher publishes camera_activity (which only happens on a fresh onConnect). Fall back to the config's enabled value so the switch reflects reality immediately after the wizard closes.
* guard camera enabled access
console would throw errors after adding via camera wizard
* fix useOptimisticState dropping debounced setState under StrictMode
* use openvino on cpu as default model
- faster than tflite on cpu
- add to default generated config
* use an enum for model_size
the frontend will then render this as a select dropdown because of the changes in the json schema
* i18n
* sync object filter entries with tracked labels in camera config form
Filter sub-collapsibles in the camera Objects section are driven by `filters` dict keys, but profile merges and live track-switch edits don't add matching entries, so newly tracked labels (like from a profile override) had no collapsible. Synthesize default filter entries from `track` in the form data so every tracked label renders a collapsible; baseline data also gets the synthesized entries, so save payloads are unchanged.
* revalidate raw paths cache after config save so CameraPathWidget shows fresh credentials
* fix test
* restore masked ffmpeg credentials when persisting camera config
* formatting
* rebuild ffmpeg commands when enabling recording for the first time
Toggling record.enabled from the config UI updated the in-memory config but left ffmpeg running with its original command, so the record output args were never wired in and nothing landed in the cache for the maintainer to move. The record config update now rebuilds ffmpeg_cmds when enabled_in_config transitions, and the camera watchdog restarts ffmpeg on a false to true transition so the record output gets wired in. MQTT toggles, which only flip record.enabled at runtime, are unaffected and continue to work via the maintainer's drop/keep gate.
* keep record toggle switch in single camera view disabled until enabled in config
* fix override detection for sections unset in the global config
Override badges and the blue dot now compare against schema defaults for sections like motion that the API serializes as null when omitted from the global YAML, instead of treating any populated camera config as an override
* add support for config-aware patterns in section hiddenFields
Section configs can now declare dynamic hidden-field entries as functions of the loaded config; objects.ts uses this to hide auto-populated attribute filters (DHL, face, license_plate, etc.) from the form, save flow, and override popover when those labels aren't user-settable
* siimplify object filters handling
live updating was getting very messy. users will just need to save once they enable a new object in order to see filters for that object
* tweaks
* update docs for new detector default
* make genai provider required and add special case for UI
prevent validation errors from appearing on initial creation of genai provider by setting the first option in the select dropdown as default
2026-05-07 16:53:07 +03:00
|
|
|
: camera.record.enabled_in_config
|
|
|
|
|
? t("recording.enable")
|
|
|
|
|
: t("recording.disabledInConfig")
|
2025-03-16 18:36:20 +03:00
|
|
|
}
|
2025-03-08 19:01:08 +03:00
|
|
|
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
UI fixes (#23127)
* hide camera overrides badge from system sections
* show empty card on camera metrics page when no cameras are defined
* fix enabled camera state switch after adding via wizard
Cameras added mid-session have no WS state until the dispatcher publishes camera_activity (which only happens on a fresh onConnect). Fall back to the config's enabled value so the switch reflects reality immediately after the wizard closes.
* guard camera enabled access
console would throw errors after adding via camera wizard
* fix useOptimisticState dropping debounced setState under StrictMode
* use openvino on cpu as default model
- faster than tflite on cpu
- add to default generated config
* use an enum for model_size
the frontend will then render this as a select dropdown because of the changes in the json schema
* i18n
* sync object filter entries with tracked labels in camera config form
Filter sub-collapsibles in the camera Objects section are driven by `filters` dict keys, but profile merges and live track-switch edits don't add matching entries, so newly tracked labels (like from a profile override) had no collapsible. Synthesize default filter entries from `track` in the form data so every tracked label renders a collapsible; baseline data also gets the synthesized entries, so save payloads are unchanged.
* revalidate raw paths cache after config save so CameraPathWidget shows fresh credentials
* fix test
* restore masked ffmpeg credentials when persisting camera config
* formatting
* rebuild ffmpeg commands when enabling recording for the first time
Toggling record.enabled from the config UI updated the in-memory config but left ffmpeg running with its original command, so the record output args were never wired in and nothing landed in the cache for the maintainer to move. The record config update now rebuilds ffmpeg_cmds when enabled_in_config transitions, and the camera watchdog restarts ffmpeg on a false to true transition so the record output gets wired in. MQTT toggles, which only flip record.enabled at runtime, are unaffected and continue to work via the maintainer's drop/keep gate.
* keep record toggle switch in single camera view disabled until enabled in config
* fix override detection for sections unset in the global config
Override badges and the blue dot now compare against schema defaults for sections like motion that the API serializes as null when omitted from the global YAML, instead of treating any populated camera config as an override
* add support for config-aware patterns in section hiddenFields
Section configs can now declare dynamic hidden-field entries as functions of the loaded config; objects.ts uses this to hide auto-populated attribute filters (DHL, face, license_plate, etc.) from the form, save flow, and override popover when those labels aren't user-settable
* siimplify object filters handling
live updating was getting very messy. users will just need to save once they enable a new object in order to see filters for that object
* tweaks
* update docs for new detector default
* make genai provider required and add special case for UI
prevent validation errors from appearing on initial creation of genai provider by setting the first option in the select dropdown as default
2026-05-07 16:53:07 +03:00
|
|
|
disabled={!cameraEnabled || !camera.record.enabled_in_config}
|
2025-03-08 19:01:08 +03:00
|
|
|
/>
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
|
|
|
|
|
isActive={snapshotState == "ON"}
|
2025-03-16 18:36:20 +03:00
|
|
|
title={
|
|
|
|
|
snapshotState == "ON"
|
|
|
|
|
? t("snapshots.disable")
|
|
|
|
|
: t("snapshots.enable")
|
|
|
|
|
}
|
2025-03-08 19:01:08 +03:00
|
|
|
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
|
|
|
|
|
disabled={!cameraEnabled}
|
|
|
|
|
/>
|
|
|
|
|
{audioDetectEnabled && (
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={audioState == "ON" ? LuEar : LuEarOff}
|
|
|
|
|
isActive={audioState == "ON"}
|
2025-03-16 18:36:20 +03:00
|
|
|
title={
|
|
|
|
|
audioState == "ON"
|
|
|
|
|
? t("audioDetect.disable")
|
|
|
|
|
: t("audioDetect.enable")
|
|
|
|
|
}
|
2025-03-08 19:01:08 +03:00
|
|
|
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
|
|
|
|
|
disabled={!cameraEnabled}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-05-27 18:26:00 +03:00
|
|
|
{audioDetectEnabled && transcriptionEnabled && (
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={
|
|
|
|
|
transcriptionState == "ON"
|
|
|
|
|
? MdClosedCaption
|
|
|
|
|
: MdClosedCaptionDisabled
|
|
|
|
|
}
|
|
|
|
|
isActive={transcriptionState == "ON"}
|
|
|
|
|
title={
|
|
|
|
|
transcriptionState == "ON"
|
|
|
|
|
? t("transcription.disable")
|
|
|
|
|
: t("transcription.enable")
|
|
|
|
|
}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
sendTranscription(transcriptionState == "ON" ? "OFF" : "ON")
|
|
|
|
|
}
|
|
|
|
|
disabled={!cameraEnabled || audioState == "OFF"}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-03-08 19:01:08 +03:00
|
|
|
{autotrackingEnabled && (
|
|
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={
|
|
|
|
|
autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
|
|
|
|
|
}
|
|
|
|
|
isActive={autotrackingState == "ON"}
|
2025-03-16 18:36:20 +03:00
|
|
|
title={
|
|
|
|
|
autotrackingState == "ON"
|
|
|
|
|
? t("autotracking.disable")
|
|
|
|
|
: t("autotracking.enable")
|
|
|
|
|
}
|
2025-03-08 19:01:08 +03:00
|
|
|
onClick={() =>
|
|
|
|
|
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
|
|
|
|
}
|
|
|
|
|
disabled={!cameraEnabled}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2024-05-16 17:32:39 +03:00
|
|
|
)}
|
2025-02-10 19:42:35 +03:00
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className={cn(
|
|
|
|
|
"p-2 md:p-0",
|
|
|
|
|
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
|
|
|
|
)}
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={isRecording ? TbRecordMail : TbRecordMailOff}
|
|
|
|
|
isActive={isRecording}
|
2025-03-16 18:36:20 +03:00
|
|
|
title={t("manualRecording." + (isRecording ? "stop" : "start"))}
|
2025-02-10 19:42:35 +03:00
|
|
|
onClick={handleEventButtonClick}
|
2025-09-30 02:45:55 +03:00
|
|
|
disabled={!cameraEnabled || debug}
|
2025-02-10 19:42:35 +03:00
|
|
|
/>
|
2025-10-14 22:05:35 +03:00
|
|
|
<CameraFeatureToggle
|
|
|
|
|
className="p-2 md:p-0"
|
|
|
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
|
|
|
Icon={TbCameraDown}
|
|
|
|
|
isActive={false}
|
|
|
|
|
title={t("snapshot.takeSnapshot")}
|
|
|
|
|
onClick={handleSnapshotClick}
|
|
|
|
|
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
|
|
|
|
loading={isSnapshotLoading}
|
|
|
|
|
/>
|
2025-11-12 02:00:54 +03:00
|
|
|
{!fullscreen && (
|
2026-04-21 17:48:48 +03:00
|
|
|
<DropdownMenu>
|
2025-11-12 02:00:54 +03:00
|
|
|
<DropdownMenuTrigger>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex flex-col items-center justify-center rounded-lg bg-secondary p-2 text-secondary-foreground md:p-0",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<FaCog
|
|
|
|
|
className={`text-secondary-foreground" size-5 md:m-[6px]`}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent className="max-w-96">
|
|
|
|
|
<div className="flex flex-col gap-5 p-4">
|
|
|
|
|
{!isRestreamed && (
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label>
|
|
|
|
|
{t("streaming.label", { ns: "components/dialog" })}
|
|
|
|
|
</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", {
|
2025-03-16 18:36:20 +03:00
|
|
|
ns: "components/dialog",
|
|
|
|
|
})}
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
2025-11-12 02:00:54 +03:00
|
|
|
<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="flex flex-col gap-1">
|
|
|
|
|
<Label htmlFor="streaming-method">
|
|
|
|
|
{t("stream.title")}
|
|
|
|
|
</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={streamName}
|
|
|
|
|
disabled={debug}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
setStreamName?.(value);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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 && (
|
2025-02-10 19:42:35 +03:00
|
|
|
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
2025-11-12 02:00:54 +03:00
|
|
|
<>
|
|
|
|
|
<LuX className="size-8 text-danger" />
|
|
|
|
|
<div>{t("stream.debug.picker")}</div>
|
|
|
|
|
</>
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-12 02:00:54 +03:00
|
|
|
{preferredLiveMode != "jsmpeg" &&
|
|
|
|
|
!debug &&
|
|
|
|
|
isRestreamed && (
|
|
|
|
|
<div className="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-80 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-80 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>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
2025-11-12 02:00:54 +03:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{preferredLiveMode == "jsmpeg" &&
|
|
|
|
|
!debug &&
|
|
|
|
|
isRestreamed && (
|
|
|
|
|
<div className="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>
|
2025-09-30 02:45:55 +03:00
|
|
|
</div>
|
2025-11-12 02:00:54 +03:00
|
|
|
<Button
|
|
|
|
|
className={`flex items-center gap-2.5 rounded-lg`}
|
|
|
|
|
aria-label={t("stream.lowBandwidth.resetStream")}
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setLowBandwidth(false)}
|
|
|
|
|
>
|
|
|
|
|
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
|
|
|
|
<div className="text-primary-variant">
|
|
|
|
|
{t("stream.lowBandwidth.resetStream")}
|
|
|
|
|
</div>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{isRestreamed && (
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label
|
|
|
|
|
className="mx-0 cursor-pointer text-primary"
|
|
|
|
|
htmlFor="backgroundplay"
|
|
|
|
|
>
|
|
|
|
|
{t("stream.playInBackground.label")}
|
|
|
|
|
</Label>
|
|
|
|
|
<Switch
|
|
|
|
|
className="ml-1"
|
|
|
|
|
id="backgroundplay"
|
|
|
|
|
disabled={debug}
|
|
|
|
|
checked={playInBackground}
|
|
|
|
|
onCheckedChange={(checked) =>
|
|
|
|
|
setPlayInBackground(checked)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{t("stream.playInBackground.tips")}
|
|
|
|
|
</p>
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label
|
|
|
|
|
className="mx-0 cursor-pointer text-primary"
|
2025-11-12 02:00:54 +03:00
|
|
|
htmlFor="showstats"
|
2025-02-10 19:42:35 +03:00
|
|
|
>
|
2025-11-12 02:00:54 +03:00
|
|
|
{t("streaming.showStats.label", {
|
|
|
|
|
ns: "components/dialog",
|
|
|
|
|
})}
|
2025-02-10 19:42:35 +03:00
|
|
|
</Label>
|
|
|
|
|
<Switch
|
|
|
|
|
className="ml-1"
|
2025-11-12 02:00:54 +03:00
|
|
|
id="showstats"
|
2025-09-30 02:45:55 +03:00
|
|
|
disabled={debug}
|
2025-11-12 02:00:54 +03:00
|
|
|
checked={showStats}
|
|
|
|
|
onCheckedChange={(checked) => setShowStats(checked)}
|
2025-02-10 19:42:35 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2025-11-12 02:00:54 +03:00
|
|
|
{t("streaming.showStats.desc", {
|
2025-03-16 18:36:20 +03:00
|
|
|
ns: "components/dialog",
|
|
|
|
|
})}
|
2025-11-12 02:00:54 +03:00
|
|
|
</p>
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
2025-11-12 02:00:54 +03:00
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label
|
|
|
|
|
className="mx-0 cursor-pointer text-primary"
|
|
|
|
|
htmlFor="debug"
|
|
|
|
|
>
|
|
|
|
|
{t("streaming.debugView", {
|
|
|
|
|
ns: "components/dialog",
|
|
|
|
|
})}
|
|
|
|
|
</Label>
|
|
|
|
|
<Switch
|
|
|
|
|
className="ml-1"
|
|
|
|
|
id="debug"
|
|
|
|
|
checked={debug}
|
|
|
|
|
onCheckedChange={(checked) => setDebug(checked)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-11-12 02:00:54 +03:00
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
)}
|
2024-04-02 15:45:16 +03:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mobile doesn't show settings in fullscreen view
|
|
|
|
|
if (fullscreen) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Drawer>
|
|
|
|
|
<DrawerTrigger>
|
|
|
|
|
<CameraFeatureToggle
|
2024-04-07 23:37:33 +03:00
|
|
|
className="p-2 landscape:size-9"
|
2024-04-02 15:45:16 +03:00
|
|
|
variant="primary"
|
|
|
|
|
Icon={FaCog}
|
|
|
|
|
isActive={false}
|
2025-03-16 18:36:20 +03:00
|
|
|
title={t("cameraSettings.title", { camera })}
|
2024-04-02 15:45:16 +03:00
|
|
|
/>
|
|
|
|
|
</DrawerTrigger>
|
2025-11-24 16:34:56 +03:00
|
|
|
<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 && (
|
|
|
|
|
<>
|
2025-03-08 19:01:08 +03:00
|
|
|
<FilterSwitch
|
2026-05-24 23:59:56 +03:00
|
|
|
label={t("cameraSettings.camera")}
|
2025-11-24 16:34:56 +03:00
|
|
|
isChecked={enabledState == "ON"}
|
2025-03-08 19:01:08 +03:00
|
|
|
onCheckedChange={() =>
|
2025-11-24 16:34:56 +03:00
|
|
|
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
2025-03-08 19:01:08 +03:00
|
|
|
}
|
|
|
|
|
/>
|
2025-05-27 18:26:00 +03:00
|
|
|
<FilterSwitch
|
2025-11-24 16:34:56 +03:00
|
|
|
label={t("cameraSettings.objectDetection")}
|
|
|
|
|
isChecked={detectState == "ON"}
|
2025-05-27 18:26:00 +03:00
|
|
|
onCheckedChange={() =>
|
2025-11-24 16:34:56 +03:00
|
|
|
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
2025-05-27 18:26:00 +03:00
|
|
|
}
|
|
|
|
|
/>
|
2025-11-24 16:34:56 +03:00
|
|
|
{recordingEnabled && (
|
|
|
|
|
<FilterSwitch
|
|
|
|
|
label={t("cameraSettings.recording")}
|
|
|
|
|
isChecked={recordState == "ON"}
|
|
|
|
|
onCheckedChange={() =>
|
|
|
|
|
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-03-08 19:01:08 +03:00
|
|
|
<FilterSwitch
|
2025-11-24 16:34:56 +03:00
|
|
|
label={t("cameraSettings.snapshots")}
|
|
|
|
|
isChecked={snapshotState == "ON"}
|
2025-03-08 19:01:08 +03:00
|
|
|
onCheckedChange={() =>
|
2025-11-24 16:34:56 +03:00
|
|
|
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
2025-03-08 19:01:08 +03:00
|
|
|
}
|
|
|
|
|
/>
|
2025-11-24 16:34:56 +03:00
|
|
|
{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")
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-03-08 19:01:08 +03:00
|
|
|
|
2025-11-24 16:34:56 +03:00
|
|
|
<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",
|
|
|
|
|
})}
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
2026-04-21 17:48:48 +03:00
|
|
|
<Popover>
|
2025-11-24 16:34:56 +03:00
|
|
|
<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>
|
2025-09-30 02:45:55 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-24 16:34:56 +03:00
|
|
|
{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>
|
|
|
|
|
)}
|
2025-09-30 02:45:55 +03:00
|
|
|
|
2025-11-24 16:34:56 +03:00
|
|
|
{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>
|
2026-04-21 17:48:48 +03:00
|
|
|
<Popover>
|
2025-11-24 16:34:56 +03:00
|
|
|
<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>
|
2026-04-21 17:48:48 +03:00
|
|
|
<Popover>
|
2025-11-24 16:34:56 +03:00
|
|
|
<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")}
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
2025-11-24 16:34:56 +03:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-02-10 19:42:35 +03:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-24 16:34:56 +03:00
|
|
|
<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>
|
2025-02-10 19:42:35 +03:00
|
|
|
<Button
|
2025-11-24 16:34:56 +03:00
|
|
|
onClick={handleEventButtonClick}
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-auto w-full whitespace-normal",
|
|
|
|
|
isRecording &&
|
|
|
|
|
"animate-pulse bg-red-500 hover:bg-red-600",
|
|
|
|
|
)}
|
2025-09-30 02:45:55 +03:00
|
|
|
disabled={debug}
|
2025-02-10 19:42:35 +03:00
|
|
|
>
|
2025-11-24 16:34:56 +03:00
|
|
|
{t("manualRecording." + (isRecording ? "end" : "start"))}
|
2025-02-10 19:42:35 +03:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-11-24 16:34:56 +03:00
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{t("manualRecording.tips")}
|
2025-02-10 19:42:35 +03:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-11-24 16:34:56 +03:00
|
|
|
{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 className="mb-3 flex flex-col">
|
2025-02-10 19:42:35 +03:00
|
|
|
<FilterSwitch
|
2025-11-24 16:34:56 +03:00
|
|
|
label={t("streaming.debugView", { ns: "components/dialog" })}
|
|
|
|
|
isChecked={debug}
|
|
|
|
|
onCheckedChange={(checked) => setDebug(checked)}
|
2025-02-10 19:42:35 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
2025-11-24 16:34:56 +03:00
|
|
|
</div>
|
|
|
|
|
</>
|
2025-02-10 19:42:35 +03:00
|
|
|
</div>
|
2024-04-02 15:45:16 +03:00
|
|
|
</DrawerContent>
|
|
|
|
|
</Drawer>
|
|
|
|
|
);
|
|
|
|
|
}
|