mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-26 06:11:54 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
1828 lines
66 KiB
TypeScript
1828 lines
66 KiB
TypeScript
import {
|
|
useAudioLiveTranscription,
|
|
useAudioState,
|
|
useAudioTranscriptionState,
|
|
useAutotrackingState,
|
|
useDetectState,
|
|
useEnabledState,
|
|
usePtzCommand,
|
|
useRecordingsState,
|
|
useSnapshotsState,
|
|
} from "@/api/ws";
|
|
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
|
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
|
import LivePlayer from "@/components/player/LivePlayer";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
|
import {
|
|
LivePlayerError,
|
|
LiveStreamMetadata,
|
|
VideoResolutionType,
|
|
} from "@/types/live";
|
|
import { RecordingStartingPoint } from "@/types/record";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
isDesktop,
|
|
isFirefox,
|
|
isIOS,
|
|
isMobile,
|
|
isTablet,
|
|
useMobileOrientation,
|
|
} from "react-device-detect";
|
|
import {
|
|
FaCog,
|
|
FaCompress,
|
|
FaExpand,
|
|
FaMicrophone,
|
|
FaMicrophoneSlash,
|
|
} from "react-icons/fa";
|
|
import { GiSpeaker, GiSpeakerOff } from "react-icons/gi";
|
|
import {
|
|
TbCameraDown,
|
|
TbRecordMail,
|
|
TbRecordMailOff,
|
|
TbViewfinder,
|
|
TbViewfinderOff,
|
|
} from "react-icons/tb";
|
|
import { IoIosWarning, IoMdArrowRoundBack } from "react-icons/io";
|
|
import {
|
|
LuCheck,
|
|
LuEar,
|
|
LuEarOff,
|
|
LuExternalLink,
|
|
LuHistory,
|
|
LuInfo,
|
|
LuPictureInPicture,
|
|
LuPower,
|
|
LuPowerOff,
|
|
LuVideo,
|
|
LuVideoOff,
|
|
LuX,
|
|
} from "react-icons/lu";
|
|
import {
|
|
MdClosedCaption,
|
|
MdClosedCaptionDisabled,
|
|
MdNoPhotography,
|
|
MdOutlineRestartAlt,
|
|
MdPersonOff,
|
|
MdPersonSearch,
|
|
MdPhotoCamera,
|
|
} from "react-icons/md";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
|
import useSWR from "swr";
|
|
import { cn } from "@/lib/utils";
|
|
import { useSessionPersistence } from "@/hooks/use-session-persistence";
|
|
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
|
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";
|
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
|
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
|
|
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
|
|
import ObjectSettingsView from "../settings/ObjectSettingsView";
|
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
|
import {
|
|
downloadSnapshot,
|
|
fetchCameraSnapshot,
|
|
generateSnapshotFilename,
|
|
grabVideoSnapshot,
|
|
SnapshotResult,
|
|
} from "@/utils/snapshotUtil";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
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;
|
|
|
|
type LiveCameraViewProps = {
|
|
config?: FrigateConfig;
|
|
camera: CameraConfig;
|
|
supportsFullscreen: boolean;
|
|
fullscreen: boolean;
|
|
toggleFullscreen: () => void;
|
|
};
|
|
export default function LiveCameraView({
|
|
config,
|
|
camera,
|
|
supportsFullscreen,
|
|
fullscreen,
|
|
toggleFullscreen,
|
|
}: LiveCameraViewProps) {
|
|
const { t } = useTranslation(["views/live", "components/dialog"]);
|
|
const navigate = useNavigate();
|
|
const { isPortrait } = useMobileOrientation();
|
|
const mainRef = useRef<HTMLDivElement | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [{ width: windowWidth, height: windowHeight }] =
|
|
useResizeObserver(window);
|
|
|
|
// supported features
|
|
|
|
const [streamName, setStreamName, streamNameLoaded] =
|
|
useUserPersistence<string>(
|
|
`${camera.name}-stream`,
|
|
Object.values(camera.live.streams)[0],
|
|
);
|
|
|
|
const isRestreamed = useMemo(
|
|
() =>
|
|
config &&
|
|
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
|
|
[config, streamName],
|
|
);
|
|
|
|
// 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]);
|
|
|
|
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
|
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
|
{
|
|
revalidateOnFocus: false,
|
|
revalidateOnReconnect: false,
|
|
revalidateIfStale: false,
|
|
dedupingInterval: 60000,
|
|
},
|
|
);
|
|
|
|
const { twoWayAudio: supports2WayTalk, audioOutput: supportsAudioOutput } =
|
|
useMemo(() => detectCameraAudioFeatures(cameraMetadata), [cameraMetadata]);
|
|
|
|
// camera enabled state
|
|
const { payload: enabledState } = useEnabledState(camera.name);
|
|
const cameraEnabled = enabledState === "ON";
|
|
|
|
// 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]);
|
|
|
|
// click-to-move / drag-to-zoom overlay for PTZ cameras
|
|
|
|
const [clickOverlay, setClickOverlay] = useState(false);
|
|
const clickOverlayRef = useRef<HTMLDivElement>(null);
|
|
const { send: sendPtz } = usePtzCommand(camera.name);
|
|
|
|
// 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 });
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
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,
|
|
});
|
|
}
|
|
},
|
|
[isPtzDrawing, ptzRect],
|
|
);
|
|
|
|
const onPtzStageUp = useCallback(() => {
|
|
setIsPtzDrawing(false);
|
|
|
|
if (!ptzRect || !ptzOriginRef.current || overlaySize.width === 0) {
|
|
setPtzRect(null);
|
|
ptzOriginRef.current = null;
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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]);
|
|
|
|
// pip state
|
|
|
|
useEffect(() => {
|
|
setPip(document.pictureInPictureElement != null);
|
|
// we know that these deps are correct
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [document.pictureInPictureElement]);
|
|
|
|
// playback state
|
|
|
|
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
|
|
const [mic, setMic] = useState(false);
|
|
const [webRTC, setWebRTC] = useState(false);
|
|
const [pip, setPip] = useState(false);
|
|
const [lowBandwidth, setLowBandwidth] = useState(false);
|
|
|
|
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
|
|
`${camera.name}-background-play`,
|
|
false,
|
|
);
|
|
|
|
const [showStats, setShowStats] = useState(false);
|
|
const [debug, setDebug] = useState(false);
|
|
|
|
useSearchEffect("debug", (value: string) => {
|
|
if (value === "true") {
|
|
setDebug(true);
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
|
|
const preferredLiveMode = useMemo(() => {
|
|
if (mic) {
|
|
return "webrtc";
|
|
}
|
|
|
|
if (webRTC && isRestreamed) {
|
|
return "webrtc";
|
|
}
|
|
|
|
if (webRTC && !isRestreamed) {
|
|
return "jsmpeg";
|
|
}
|
|
|
|
if (lowBandwidth) {
|
|
return "jsmpeg";
|
|
}
|
|
|
|
if (!("MediaSource" in window || "ManagedMediaSource" in window)) {
|
|
return "webrtc";
|
|
}
|
|
|
|
if (!isRestreamed) {
|
|
return "jsmpeg";
|
|
}
|
|
|
|
return "mse";
|
|
}, [lowBandwidth, mic, webRTC, isRestreamed]);
|
|
|
|
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
|
|
if (!modifiers.down) {
|
|
return true;
|
|
}
|
|
|
|
switch (key) {
|
|
case "m":
|
|
if (supportsAudioOutput) {
|
|
setAudio(!audio);
|
|
return true;
|
|
}
|
|
break;
|
|
case "t":
|
|
if (supports2WayTalk) {
|
|
setMic(!mic);
|
|
return true;
|
|
}
|
|
break;
|
|
case "Escape":
|
|
if (!fullscreen) {
|
|
navigate(-1);
|
|
return true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
// layout state
|
|
|
|
const windowAspectRatio = useMemo(() => {
|
|
return windowWidth / windowHeight;
|
|
}, [windowWidth, windowHeight]);
|
|
|
|
const containerAspectRatio = useMemo(() => {
|
|
if (!containerRef.current) {
|
|
return windowAspectRatio;
|
|
}
|
|
|
|
return containerRef.current.clientWidth / containerRef.current.clientHeight;
|
|
}, [windowAspectRatio, containerRef]);
|
|
|
|
const cameraAspectRatio = useMemo(() => {
|
|
if (fullResolution.width && fullResolution.height) {
|
|
return fullResolution.width / fullResolution.height;
|
|
} else {
|
|
return camera.detect.width / camera.detect.height;
|
|
}
|
|
}, [camera, fullResolution]);
|
|
|
|
const constrainedAspectRatio = useMemo<number>(() => {
|
|
if (isMobile || fullscreen) {
|
|
return cameraAspectRatio;
|
|
} else {
|
|
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 {
|
|
if (cameraAspectRatio > containerAspectRatio) {
|
|
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) {
|
|
if (cameraAspectRatio > containerAspectRatio) {
|
|
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%]";
|
|
}
|
|
}, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]);
|
|
|
|
// 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;
|
|
if (!screenOrientation?.lock || !screenOrientation?.unlock) {
|
|
// 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]);
|
|
|
|
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],
|
|
);
|
|
|
|
return (
|
|
<TransformWrapper
|
|
minScale={1.0}
|
|
wheel={{ smoothStep: 0.005 }}
|
|
disabled={debug || clickOverlay}
|
|
panning={{ disabled: clickOverlay }}
|
|
>
|
|
<Toaster position="top-center" closeButton={true} />
|
|
<div
|
|
ref={mainRef}
|
|
className={
|
|
fullscreen
|
|
? `fixed inset-0 z-30 bg-black`
|
|
: `flex size-full flex-col p-2 ${isMobile ? "landscape:flex-row landscape:gap-1" : ""}`
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
fullscreen
|
|
? `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" : ""}`
|
|
}
|
|
>
|
|
{!fullscreen ? (
|
|
<div
|
|
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
|
|
>
|
|
<Button
|
|
className={`flex items-center gap-2.5 rounded-lg`}
|
|
aria-label={t("label.back", { ns: "common" })}
|
|
size="sm"
|
|
onClick={() => navigate(-1)}
|
|
>
|
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
|
{isDesktop && (
|
|
<div className="text-primary">
|
|
{t("button.back", { ns: "common" })}
|
|
</div>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
className="flex items-center gap-2.5 rounded-lg"
|
|
aria-label={t("history.label")}
|
|
size="sm"
|
|
onClick={() => {
|
|
navigate("review", {
|
|
state: {
|
|
severity: "alert",
|
|
recording: {
|
|
camera: camera.name,
|
|
startTime: Date.now() / 1000 - 30,
|
|
severity: "alert",
|
|
} as RecordingStartingPoint,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<LuHistory className="size-5 text-secondary-foreground" />
|
|
{isDesktop && (
|
|
<div className="text-primary">
|
|
{t("button.history", { ns: "common" })}
|
|
</div>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<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}
|
|
disabled={debug}
|
|
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);
|
|
}
|
|
}}
|
|
disabled={!cameraEnabled || debug}
|
|
/>
|
|
)}
|
|
{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" })
|
|
}
|
|
onClick={() => {
|
|
setMic(!mic);
|
|
if (!mic && !audio) {
|
|
setAudio(true);
|
|
}
|
|
}}
|
|
disabled={!cameraEnabled || debug}
|
|
/>
|
|
)}
|
|
{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" })
|
|
}
|
|
onClick={() => setAudio(!audio)}
|
|
disabled={!cameraEnabled || debug}
|
|
/>
|
|
)}
|
|
<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}
|
|
debug={debug}
|
|
setDebug={setDebug}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!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
|
|
className={cn(
|
|
"flex flex-col items-center justify-center",
|
|
growClassName,
|
|
)}
|
|
ref={clickOverlayRef}
|
|
style={{
|
|
aspectRatio: constrainedAspectRatio,
|
|
}}
|
|
>
|
|
{clickOverlay && overlaySize.width > 0 && (
|
|
<div
|
|
className="absolute z-40 cursor-crosshair"
|
|
style={{
|
|
width: overlaySize.width,
|
|
height: overlaySize.height,
|
|
}}
|
|
>
|
|
<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>
|
|
)}
|
|
<LivePlayer
|
|
key={camera.name}
|
|
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
|
windowVisible
|
|
showStillWithoutActivity={false}
|
|
alwaysShowCameraName={false}
|
|
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}
|
|
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%]"
|
|
>
|
|
{transcription}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<TransformComponent
|
|
wrapperStyle={{
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
contentStyle={{
|
|
position: "relative",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
<ObjectSettingsView selectedCamera={camera.name} />
|
|
</TransformComponent>
|
|
)}
|
|
</div>
|
|
{camera.onvif.host != "" && (
|
|
<div className="flex flex-col items-center justify-center">
|
|
<PtzControlPanel
|
|
className={debug && isMobile ? "bottom-auto top-[25%]" : ""}
|
|
camera={camera.name}
|
|
enabled={cameraEnabled}
|
|
clickOverlay={clickOverlay}
|
|
setClickOverlay={setClickOverlay}
|
|
/>
|
|
</div>
|
|
)}
|
|
</TransformWrapper>
|
|
);
|
|
}
|
|
|
|
type FrigateCameraFeaturesProps = {
|
|
camera: CameraConfig;
|
|
recordingEnabled: boolean;
|
|
audioDetectEnabled: boolean;
|
|
autotrackingEnabled: boolean;
|
|
transcriptionEnabled: boolean;
|
|
fullscreen: boolean;
|
|
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;
|
|
cameraEnabled: boolean;
|
|
debug: boolean;
|
|
setDebug: (debug: boolean) => void;
|
|
};
|
|
function FrigateCameraFeatures({
|
|
camera,
|
|
recordingEnabled,
|
|
audioDetectEnabled,
|
|
autotrackingEnabled,
|
|
transcriptionEnabled,
|
|
fullscreen,
|
|
streamName,
|
|
setStreamName,
|
|
preferredLiveMode,
|
|
playInBackground,
|
|
setPlayInBackground,
|
|
showStats,
|
|
setShowStats,
|
|
isRestreamed,
|
|
setLowBandwidth,
|
|
supportsAudioOutput,
|
|
supports2WayTalk,
|
|
cameraEnabled,
|
|
debug,
|
|
setDebug,
|
|
}: FrigateCameraFeaturesProps) {
|
|
const { t } = useTranslation(["views/live", "components/dialog"]);
|
|
const { getLocaleDocUrl } = useDocDomain();
|
|
|
|
const { payload: detectState, send: sendDetect } = useDetectState(
|
|
camera.name,
|
|
);
|
|
const { payload: enabledState, send: sendEnabled } = useEnabledState(
|
|
camera.name,
|
|
);
|
|
const { payload: recordState, send: sendRecord } = useRecordingsState(
|
|
camera.name,
|
|
);
|
|
const { payload: snapshotState, send: sendSnapshot } = useSnapshotsState(
|
|
camera.name,
|
|
);
|
|
const { payload: audioState, send: sendAudio } = useAudioState(camera.name);
|
|
const { payload: autotrackingState, send: sendAutotracking } =
|
|
useAutotrackingState(camera.name);
|
|
const { payload: transcriptionState, send: sendTranscription } =
|
|
useAudioTranscriptionState(camera.name);
|
|
|
|
// roles
|
|
|
|
const isAdmin = useIsAdmin();
|
|
|
|
// 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">
|
|
<div className="font-semibold">{t("manualRecording.started")}</div>
|
|
{!camera.record.enabled ||
|
|
(camera.record.alerts.retain.days == 0 && (
|
|
<div>{t("manualRecording.recordDisabledTips")}</div>
|
|
))}
|
|
</div>,
|
|
{
|
|
position: "top-center",
|
|
duration: 10000,
|
|
},
|
|
);
|
|
setActiveToastId(toastId);
|
|
}
|
|
} catch (error) {
|
|
toast.error(t("manualRecording.failedToStart"), {
|
|
position: "top-center",
|
|
});
|
|
}
|
|
}, [camera, t]);
|
|
|
|
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);
|
|
toast.success(t("manualRecording.ended"), {
|
|
position: "top-center",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast.error(t("manualRecording.failedToEnd"), {
|
|
position: "top-center",
|
|
});
|
|
}
|
|
}, [activeToastId, t]);
|
|
|
|
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
|
|
}
|
|
}, []);
|
|
|
|
const handleEventButtonClick = useCallback(() => {
|
|
if (isRecording) {
|
|
endEvent();
|
|
} else {
|
|
createEvent();
|
|
}
|
|
}, [createEvent, endEvent, isRecording]);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
// Handle page unload/close (browser close, tab close, refresh, navigation to external site)
|
|
const handleBeforeUnload = () => {
|
|
if (recordingEventIdRef.current) {
|
|
endEventViaBeacon();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
|
|
// ensure manual event is stopped when component unmounts
|
|
return () => {
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
|
|
if (recordingEventIdRef.current) {
|
|
endEvent();
|
|
}
|
|
};
|
|
// mount/unmount only
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// desktop shows icons part of row
|
|
if (isDesktop || isTablet) {
|
|
return (
|
|
<>
|
|
{isAdmin && (
|
|
<>
|
|
<CameraFeatureToggle
|
|
className="p-2 md:p-0"
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
|
|
isActive={enabledState == "ON"}
|
|
title={
|
|
enabledState == "ON" ? t("camera.turnOff") : t("camera.turnOn")
|
|
}
|
|
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
|
|
disabled={debug}
|
|
/>
|
|
<CameraFeatureToggle
|
|
className="p-2 md:p-0"
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
|
|
isActive={detectState == "ON"}
|
|
title={
|
|
detectState == "ON" ? t("detect.disable") : t("detect.enable")
|
|
}
|
|
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"}
|
|
title={
|
|
recordState == "ON"
|
|
? t("recording.disable")
|
|
: camera.record.enabled_in_config
|
|
? t("recording.enable")
|
|
: t("recording.disabledInConfig")
|
|
}
|
|
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
|
disabled={!cameraEnabled || !camera.record.enabled_in_config}
|
|
/>
|
|
<CameraFeatureToggle
|
|
className="p-2 md:p-0"
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
|
|
isActive={snapshotState == "ON"}
|
|
title={
|
|
snapshotState == "ON"
|
|
? t("snapshots.disable")
|
|
: t("snapshots.enable")
|
|
}
|
|
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"}
|
|
title={
|
|
audioState == "ON"
|
|
? t("audioDetect.disable")
|
|
: t("audioDetect.enable")
|
|
}
|
|
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
|
|
disabled={!cameraEnabled}
|
|
/>
|
|
)}
|
|
{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"}
|
|
/>
|
|
)}
|
|
{autotrackingEnabled && (
|
|
<CameraFeatureToggle
|
|
className="p-2 md:p-0"
|
|
variant={fullscreen ? "overlay" : "primary"}
|
|
Icon={
|
|
autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
|
|
}
|
|
isActive={autotrackingState == "ON"}
|
|
title={
|
|
autotrackingState == "ON"
|
|
? t("autotracking.disable")
|
|
: t("autotracking.enable")
|
|
}
|
|
onClick={() =>
|
|
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
|
}
|
|
disabled={!cameraEnabled}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
<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}
|
|
title={t("manualRecording." + (isRecording ? "stop" : "start"))}
|
|
onClick={handleEventButtonClick}
|
|
disabled={!cameraEnabled || debug}
|
|
/>
|
|
<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}
|
|
/>
|
|
{!fullscreen && (
|
|
<DropdownMenu>
|
|
<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", {
|
|
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="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 && (
|
|
<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="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>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<Label
|
|
className="mx-0 cursor-pointer text-primary"
|
|
htmlFor="showstats"
|
|
>
|
|
{t("streaming.showStats.label", {
|
|
ns: "components/dialog",
|
|
})}
|
|
</Label>
|
|
<Switch
|
|
className="ml-1"
|
|
id="showstats"
|
|
disabled={debug}
|
|
checked={showStats}
|
|
onCheckedChange={(checked) => setShowStats(checked)}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("streaming.showStats.desc", {
|
|
ns: "components/dialog",
|
|
})}
|
|
</p>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// mobile doesn't show settings in fullscreen view
|
|
if (fullscreen) {
|
|
return;
|
|
}
|
|
|
|
return (
|
|
<Drawer>
|
|
<DrawerTrigger>
|
|
<CameraFeatureToggle
|
|
className="p-2 landscape:size-9"
|
|
variant="primary"
|
|
Icon={FaCog}
|
|
isActive={false}
|
|
title={t("cameraSettings.title", { camera })}
|
|
/>
|
|
</DrawerTrigger>
|
|
<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.camera")}
|
|
isChecked={enabledState == "ON"}
|
|
onCheckedChange={() =>
|
|
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
|
|
}
|
|
/>
|
|
<FilterSwitch
|
|
label={t("cameraSettings.objectDetection")}
|
|
isChecked={detectState == "ON"}
|
|
onCheckedChange={() =>
|
|
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
|
}
|
|
/>
|
|
{recordingEnabled && (
|
|
<FilterSwitch
|
|
label={t("cameraSettings.recording")}
|
|
isChecked={recordState == "ON"}
|
|
onCheckedChange={() =>
|
|
sendRecord(recordState == "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"}
|
|
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="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>
|
|
)}
|
|
<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">
|
|
<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">
|
|
<FilterSwitch
|
|
label={t("streaming.debugView", { ns: "components/dialog" })}
|
|
isChecked={debug}
|
|
onCheckedChange={(checked) => setDebug(checked)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
);
|
|
}
|