Files
frigate/web/src/views/live/LiveCameraView.tsx
T

2034 lines
74 KiB
TypeScript
Raw Normal View History

2024-03-01 17:43:02 -07:00
import {
2025-05-27 10:26:00 -05:00
useAudioLiveTranscription,
2024-03-01 17:43:02 -07:00
useAudioState,
2025-05-27 10:26:00 -05:00
useAudioTranscriptionState,
useAutotrackingState,
2024-03-01 17:43:02 -07:00
useDetectState,
2025-03-03 09:30:52 -06:00
useEnabledState,
2024-03-01 17:43:02 -07:00
usePtzCommand,
useRecordingsState,
useSnapshotsState,
} from "@/api/ws";
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
import FilterSwitch from "@/components/filter/FilterSwitch";
2024-03-01 17:43:02 -07:00
import LivePlayer from "@/components/player/LivePlayer";
2026-09-16 09:12:06 -05:00
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
import LiveStreamSelect from "@/components/player/LiveStreamSelect";
import { useAutoLiveStream } from "@/hooks/use-auto-live-stream";
2024-03-01 17:43:02 -07:00
import { Button } from "@/components/ui/button";
2024-04-02 06:45:16 -06:00
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
2024-03-01 17:43:02 -07:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
2025-02-10 10:42:35 -06:00
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
2024-03-05 14:19:56 -06:00
import { useResizeObserver } from "@/hooks/resize-observer";
2024-03-01 17:43:02 -07:00
import useKeyboardListener from "@/hooks/use-keyboard-listener";
2026-09-16 09:12:06 -05:00
import {
useWebRTCAvailableForStream,
useWebRTCGloballyAvailable,
} from "@/hooks/use-webrtc-availability";
2024-05-19 06:39:17 -06:00
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import {
LiveAutoReason,
LivePlayerError,
2026-09-16 09:12:06 -05:00
TwoWayTalkError,
LivePlayerMode,
LiveStreamMetadata,
VideoResolutionType,
2026-09-16 09:12:06 -05:00
WebRTCUnavailableReason,
} from "@/types/live";
2024-04-02 13:25:02 -06:00
import { RecordingStartingPoint } from "@/types/record";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2024-03-01 17:43:02 -07:00
import {
isDesktop,
isFirefox,
2024-03-15 12:46:17 -06:00
isIOS,
2024-03-01 17:43:02 -07:00
isMobile,
2024-05-18 10:54:46 -06:00
isTablet,
2024-03-01 17:43:02 -07:00
useMobileOrientation,
} from "react-device-detect";
import {
2024-04-02 06:45:16 -06:00
FaCog,
FaCompress,
FaExpand,
FaMicrophone,
FaMicrophoneSlash,
2024-03-01 17:43:02 -07:00
} from "react-icons/fa";
import { GiSpeaker, GiSpeakerOff } from "react-icons/gi";
2024-04-02 06:45:16 -06:00
import {
TbCameraDown,
2025-02-10 10:42:35 -06:00
TbRecordMail,
TbRecordMailOff,
TbViewfinder,
TbViewfinderOff,
} from "react-icons/tb";
import { IoIosWarning, IoMdArrowRoundBack } from "react-icons/io";
import {
LuCheck,
2024-04-02 06:45:16 -06:00
LuEar,
LuEarOff,
2025-02-10 10:42:35 -06:00
LuExternalLink,
2024-04-02 13:25:02 -06:00
LuHistory,
2025-02-10 10:42:35 -06:00
LuInfo,
2024-04-02 06:45:16 -06:00
LuPictureInPicture,
2025-03-03 09:30:52 -06:00
LuPower,
LuPowerOff,
2024-04-02 06:45:16 -06:00
LuVideo,
LuVideoOff,
2025-02-10 10:42:35 -06:00
LuX,
2024-04-02 06:45:16 -06:00
} from "react-icons/lu";
2024-03-01 17:43:02 -07:00
import {
2025-05-27 10:26:00 -05:00
MdClosedCaption,
MdClosedCaptionDisabled,
2024-03-01 17:43:02 -07:00
MdNoPhotography,
2025-02-10 10:42:35 -06:00
MdOutlineRestartAlt,
2024-03-01 17:43:02 -07:00
MdPersonOff,
MdPersonSearch,
MdPhotoCamera,
} from "react-icons/md";
2025-02-10 10:42:35 -06:00
import { Link, useNavigate } from "react-router-dom";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
2024-03-01 17:43:02 -07:00
import useSWR from "swr";
import { cn } from "@/lib/utils";
import { useSessionPersistence } from "@/hooks/use-session-persistence";
import { useUserPersistence } from "@/hooks/use-user-persistence";
2025-02-10 10:42:35 -06: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 10:01:08 -06:00
import { useIsAdmin } from "@/hooks/use-is-admin";
2025-10-15 11:09:28 -06:00
import { useTranslation } from "react-i18next";
2025-05-28 20:10:45 +08:00
import { useDocDomain } from "@/hooks/use-doc-domain";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
import ObjectSettingsView from "../settings/ObjectSettingsView";
2025-10-09 08:49:42 -06:00
import { useSearchEffect } from "@/hooks/use-overlay-state";
import {
downloadSnapshot,
fetchCameraSnapshot,
generateSnapshotFilename,
grabVideoSnapshot,
SnapshotResult,
} from "@/utils/snapshotUtil";
import ActivityIndicator from "@/components/indicators/activity-indicator";
2026-03-25 08:57:47 -05: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-01 17:43:02 -07:00
type LiveCameraViewProps = {
2024-05-19 06:39:17 -06:00
config?: FrigateConfig;
2024-03-01 17:43:02 -07:00
camera: CameraConfig;
supportsFullscreen: boolean;
fullscreen: boolean;
toggleFullscreen: () => void;
2024-03-01 17:43:02 -07:00
};
2024-05-19 06:39:17 -06:00
export default function LiveCameraView({
config,
camera,
supportsFullscreen,
fullscreen,
toggleFullscreen,
2024-05-19 06:39:17 -06:00
}: LiveCameraViewProps) {
2025-03-17 07:26:01 -05:00
const { t } = useTranslation(["views/live", "components/dialog"]);
2024-03-01 17:43:02 -07:00
const navigate = useNavigate();
const { isPortrait } = useMobileOrientation();
const mainRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
2024-03-05 14:19:56 -06:00
const [{ width: windowWidth, height: windowHeight }] =
useResizeObserver(window);
2024-03-01 17:43:02 -07:00
// supported features
// an absent saved stream means auto
const [pinnedStream, setPinnedStream, streamNameLoaded, clearPinnedStream] =
useUserPersistence<string>(`${camera.name}-stream`);
2025-02-10 10:42:35 -06:00
2026-09-16 09:12:06 -05:00
const [
userPreferredLiveMode,
setUserPreferredLiveMode,
userPreferredLiveModeLoaded,
] = useUserPersistence<LivePlayerMode>(
`${camera.name}-preferred-live-mode`,
"mse",
);
const [forceLowBandwidth, setForceLowBandwidth, forceLowBandwidthLoaded] =
useUserPersistence<boolean>(`${camera.name}-force-low-bandwidth`, false);
// useUserPersistence seeds state with the defaults and loads asynchronously,
// so until this is true the values above are not the user's choices.
const preferencesLoaded =
streamNameLoaded && userPreferredLiveModeLoaded && forceLowBandwidthLoaded;
const [mic, setMic] = useState(false);
const [debug, setDebug] = useState(false);
// error fallback latches, layered on top in preferredLiveMode
const [webRTC, setWebRTC] = useState(false);
const [lowBandwidth, setLowBandwidth] = useState(false);
const liveStreams = useMemo(
() => Object.values(camera.live.streams || {}),
[camera.live.streams],
);
const autoAvailable = liveStreams.length > 1;
const autoSelected = autoAvailable && pinnedStream == undefined;
const autoStream = useAutoLiveStream({
cameraName: camera.name,
streams: liveStreams,
selected: autoSelected,
paused:
!preferencesLoaded ||
mic ||
debug ||
webRTC ||
lowBandwidth ||
(forceLowBandwidth ?? false),
});
const {
handleError: autoHandleError,
reset: resetAutoStream,
onHealthSample,
} = autoStream;
// the stream actually playing; everything downstream keys off it
const streamName = autoSelected
? autoStream.streamName
: (pinnedStream ?? liveStreams[0]);
const selectStream = useCallback(
(stream: string | undefined) => {
if (stream == undefined) {
clearPinnedStream();
} else {
setPinnedStream(stream);
}
},
[clearPinnedStream, setPinnedStream],
);
2024-05-19 06:39:17 -06:00
const isRestreamed = useMemo(
() =>
config &&
2025-02-10 10:42:35 -06:00
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
[config, streamName],
2024-05-19 06:39:17 -06:00
);
// a pin to a stream no longer in config falls back to auto
2025-12-16 21:35:43 -07:00
useEffect(() => {
if (!streamNameLoaded || pinnedStream == undefined) return;
2025-12-16 21:35:43 -07:00
if (liveStreams.length > 0 && !liveStreams.includes(pinnedStream)) {
clearPinnedStream();
2025-12-16 21:35:43 -07:00
}
}, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]);
2025-12-16 21:35:43 -07:00
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
2025-11-11 16:00:54 -07:00
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
},
);
2026-09-16 09:12:06 -05:00
const webRTCAvailability = useWebRTCAvailableForStream(
cameraMetadata,
streamName,
);
const isWebRTCAvailable = webRTCAvailability.available;
// Two-way talk is the sendonly backchannel: global, not per-stream.
const { globallyAvailable: webRTCGloballyAvailable } =
useWebRTCGloballyAvailable();
// "checking" means the probe has not answered yet, and it re-enters that on
// every mount, so treating it as unavailable downgrades the saved choice.
const webRTCVerdictPending = webRTCAvailability.reason === "checking";
const webRTCUsable = isWebRTCAvailable || webRTCVerdictPending;
// Resolves the saved preference without overwriting it. Transient error
// fallbacks layer on top in preferredLiveMode.
const resolvedUserMode = useMemo<LivePlayerMode>(() => {
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
if (!isRestreamed) {
return "jsmpeg";
}
// jsmpeg is the force low-bandwidth switch, not a technology choice.
const requested =
!userPreferredLiveMode || userPreferredLiveMode === "jsmpeg"
? "mse"
: userPreferredLiveMode;
if (requested === "webrtc" && !webRTCUsable) {
return mseSupported ? "mse" : "jsmpeg";
}
if (requested === "mse" && !mseSupported) {
return webRTCUsable ? "webrtc" : "jsmpeg";
}
return requested;
}, [userPreferredLiveMode, webRTCUsable, isRestreamed]);
const { twoWayAudio: supports2WayTalk, audioOutput: supportsAudioOutput } =
useMemo(() => detectCameraAudioFeatures(cameraMetadata), [cameraMetadata]);
// camera enabled state
const { payload: enabledState } = useEnabledState(camera.name);
const cameraEnabled = enabledState === "ON";
2025-05-27 10:26:00 -05: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 08:57:47 -05:00
// 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);
2026-03-25 08:57:47 -05: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);
2026-03-25 08:57:47 -05:00
const [overlaySize] = useResizeObserver(clickOverlayRef);
2026-03-25 08:57:47 -05:00
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 });
}
},
2026-03-25 08:57:47 -05:00
[],
);
2026-03-25 08:57:47 -05: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,
});
}
},
[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
2024-04-18 10:34:18 -06: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-01 17:43:02 -07:00
// playback state
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
2024-04-02 06:45:16 -06:00
const [pip, setPip] = useState(false);
2024-03-01 17:43:02 -07:00
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
2025-02-10 10:42:35 -06:00
`${camera.name}-background-play`,
false,
);
const [showStats, setShowStats] = useState(false);
2025-10-09 08:49:42 -06:00
useSearchEffect("debug", (value: string) => {
if (value === "true") {
setDebug(true);
}
return true;
});
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
width: 0,
height: 0,
});
const preferredLiveMode = useMemo(() => {
2026-09-16 09:12:06 -05:00
if (mic && isWebRTCAvailable) {
return "webrtc";
}
if (forceLowBandwidth || autoStream.atFloor) {
2026-09-16 09:12:06 -05:00
return "jsmpeg";
}
if (webRTC && isRestreamed && isWebRTCAvailable) {
return "webrtc";
}
if (webRTC && !isRestreamed) {
return "jsmpeg";
}
2024-05-31 07:52:42 -06:00
if (lowBandwidth) {
return "jsmpeg";
}
2026-09-16 09:12:06 -05:00
if (
!("MediaSource" in window || "ManagedMediaSource" in window) &&
isWebRTCAvailable
) {
return "webrtc";
}
2026-09-16 09:12:06 -05:00
return resolvedUserMode;
}, [
lowBandwidth,
forceLowBandwidth,
mic,
webRTC,
isRestreamed,
resolvedUserMode,
isWebRTCAvailable,
autoStream.atFloor,
2026-09-16 09:12:06 -05:00
]);
2024-08-17 13:16:48 -05:00
2026-09-16 09:12:06 -05:00
// A latched error fallback would keep overriding the user's new choice.
useEffect(() => {
setWebRTC(false);
setLowBandwidth(false);
}, [userPreferredLiveMode, streamName, forceLowBandwidth]);
// A fallback chosen before the verdict arrived was made on incomplete info.
useEffect(() => {
if (!webRTCVerdictPending) {
setWebRTC(false);
setLowBandwidth(false);
}
}, [webRTCVerdictPending]);
2024-05-31 07:52:42 -06:00
2026-04-22 20:37:17 -06:00
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
2024-09-30 15:55:44 -06:00
if (!modifiers.down) {
2025-10-02 07:21:37 -06:00
return true;
2024-09-30 15:55:44 -06:00
}
switch (key) {
case "m":
if (supportsAudioOutput) {
setAudio(!audio);
2025-10-02 07:21:37 -06:00
return true;
2024-09-30 15:55:44 -06:00
}
break;
case "t":
if (supports2WayTalk) {
setMic(!mic);
2025-10-02 07:21:37 -06:00
return true;
2024-09-30 15:55:44 -06:00
}
break;
2026-04-22 20:37:17 -06:00
case "Escape":
if (!fullscreen) {
navigate(-1);
return true;
}
break;
2024-09-30 15:55:44 -06:00
}
2025-10-02 07:21:37 -06:00
return false;
2024-09-30 15:55:44 -06:00
});
2024-05-31 07:52:42 -06:00
// layout state
2024-03-05 14:19:56 -06:00
const windowAspectRatio = useMemo(() => {
return windowWidth / windowHeight;
}, [windowWidth, windowHeight]);
2024-05-28 08:11:35 -05:00
const containerAspectRatio = useMemo(() => {
if (!containerRef.current) {
return windowAspectRatio;
}
return containerRef.current.clientWidth / containerRef.current.clientHeight;
}, [windowAspectRatio, containerRef]);
2024-03-05 14:19:56 -06:00
const cameraAspectRatio = useMemo(() => {
if (fullResolution.width && fullResolution.height) {
return fullResolution.width / fullResolution.height;
} else {
return camera.detect.width / camera.detect.height;
}
}, [camera, fullResolution]);
2024-03-05 14:19:56 -06:00
2024-05-28 09:05:04 -05:00
const constrainedAspectRatio = useMemo<number>(() => {
2024-03-05 14:19:56 -06:00
if (isMobile || fullscreen) {
return cameraAspectRatio;
} else {
2024-05-28 08:11:35 -05:00
return containerAspectRatio < cameraAspectRatio
? containerAspectRatio
: cameraAspectRatio;
2024-03-05 14:19:56 -06:00
}
2024-05-28 08:11:35 -05:00
}, [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 09:05:04 -05:00
if (cameraAspectRatio > containerAspectRatio) {
2024-05-28 08:11:35 -05: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 09:05:04 -05:00
if (cameraAspectRatio > containerAspectRatio) {
2024-05-28 08:11:35 -05: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-05-28 09:05:04 -05:00
}, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]);
2024-03-05 14:19:56 -06: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;
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]);
2024-08-17 13:16:48 -05:00
const handleError = useCallback(
(e: LivePlayerError) => {
if (e) {
// auto answers congestion and codec failures with a lower stream
if (autoHandleError(e)) {
return;
}
2026-09-16 09:12:06 -05:00
// WebRTC can now be the user's own choice, so the fallback flag no
// longer implies untried: hopping to the mode that just failed sticks.
if (preferredLiveMode !== "webrtc" && webRTCUsable) {
2024-08-17 13:16:48 -05:00
setWebRTC(true);
} else {
setWebRTC(false);
setLowBandwidth(true);
}
}
},
[autoHandleError, preferredLiveMode, webRTCUsable],
2026-09-16 09:12:06 -05:00
);
const resetStream = useCallback(() => {
setLowBandwidth(false);
resetAutoStream();
}, [resetAutoStream]);
2026-09-16 09:12:06 -05:00
const handleMicrophoneError = useCallback(
(error: TwoWayTalkError) => {
setMic(false);
toast.error(t(`twoWayTalk.error.${error}`), { position: "top-center" });
},
[t],
2024-08-17 13:16:48 -05:00
);
2024-03-01 17:43:02 -07:00
return (
<TransformWrapper
minScale={1.0}
wheel={{ smoothStep: 0.005 }}
2026-03-25 08:57:47 -05:00
disabled={debug || clickOverlay}
panning={{ disabled: clickOverlay }}
>
2025-02-10 10:42:35 -06:00
<Toaster position="top-center" closeButton={true} />
2024-03-01 17:43:02 -07:00
<div
2024-03-15 08:57:58 -06:00
ref={mainRef}
className={
fullscreen
2024-05-14 10:06:44 -05:00
? `fixed inset-0 z-30 bg-black`
: `flex size-full flex-col p-2 ${isMobile ? "landscape:flex-row landscape:gap-1" : ""}`
}
2024-03-01 17:43:02 -07:00
>
<div
2024-03-15 08:57:58 -06:00
className={
fullscreen
2024-05-14 10:06:44 -05: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 08:57:58 -06:00
}
>
{!fullscreen ? (
2024-04-07 14:37:33 -06:00
<div
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
>
2024-04-02 13:25:02 -06:00
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("label.back", { ns: "common" })}
2024-04-02 13:25:02 -06:00
size="sm"
onClick={() => navigate(-1)}
>
2024-04-11 14:54:09 -06:00
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
2024-04-02 13:25:02 -06:00
</Button>
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("history.label")}
2024-04-02 13:25:02 -06:00
size="sm"
onClick={() => {
2024-04-10 07:40:17 -06:00
navigate("review", {
2024-04-02 13:25:02 -06:00
state: {
severity: "alert",
recording: {
camera: camera.name,
startTime: Date.now() / 1000 - 30,
severity: "alert",
} as RecordingStartingPoint,
},
});
}}
>
2024-04-11 14:54:09 -06:00
<LuHistory className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.history", { ns: "common" })}
</div>
)}
2024-04-02 13:25:02 -06:00
</Button>
</div>
2024-03-15 08:57:58 -06:00
) : (
<div />
)}
2025-09-22 21:21:51 -05: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}
disabled={debug}
2025-09-22 21:21:51 -05:00
title={
fullscreen
? t("button.close", { ns: "common" })
: t("button.fullscreen", { ns: "common" })
}
2025-09-22 21:21:51 -05:00
onClick={toggleFullscreen}
2024-03-15 08:57:58 -06:00
/>
2025-09-22 21:21:51 -05:00
)}
{!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}
2025-09-22 21:21:51 -05:00
/>
)}
{supports2WayTalk && (
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
isActive={mic}
title={
2026-09-16 09:12:06 -05:00
!webRTCGloballyAvailable
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
: mic
? t("twoWayTalk.disable", { ns: "views/live" })
: t("twoWayTalk.enable", { ns: "views/live" })
2025-09-22 21:21:51 -05:00
}
onClick={() => {
setMic(!mic);
if (!mic && !audio) {
setAudio(true);
}
}}
2026-09-16 09:12:06 -05:00
disabled={!cameraEnabled || debug || !webRTCGloballyAvailable}
2025-09-22 21:21:51 -05: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" })
}
onClick={() => setAudio(!audio)}
disabled={!cameraEnabled || debug}
2025-09-22 21:21:51 -05: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}
pinnedStream={pinnedStream}
playingStream={streamName ?? ""}
autoAvailable={autoAvailable}
autoReason={autoStream.reason}
onSelectStream={selectStream}
2026-09-16 09:12:06 -05:00
userPreferredLiveMode={resolvedUserMode}
setUserPreferredLiveMode={setUserPreferredLiveMode}
forceLowBandwidth={forceLowBandwidth ?? false}
setForceLowBandwidth={setForceLowBandwidth}
2025-09-22 21:21:51 -05:00
preferredLiveMode={preferredLiveMode}
playInBackground={playInBackground ?? false}
setPlayInBackground={setPlayInBackground}
showStats={showStats}
setShowStats={setShowStats}
isRestreamed={isRestreamed ?? false}
2026-09-16 09:12:06 -05:00
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCAvailability.reason}
onResetStream={resetStream}
2025-09-22 21:21:51 -05:00
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
cameraEnabled={cameraEnabled}
debug={debug}
setDebug={setDebug}
2025-09-22 21:21:51 -05:00
/>
</div>
2024-03-15 08:57:58 -06:00
</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
2026-03-25 14:14:32 -05:00
className={cn(
"flex flex-col items-center justify-center",
growClassName,
)}
ref={clickOverlayRef}
style={{
aspectRatio: constrainedAspectRatio,
}}
>
2026-03-25 08:57:47 -05:00
{clickOverlay && overlaySize.width > 0 && (
2026-03-25 14:14:32 -05:00
<div
className="absolute z-40 cursor-crosshair"
style={{
width: overlaySize.width,
height: overlaySize.height,
}}
>
2026-03-25 08:57:47 -05: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>
)}
<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}
2026-09-16 09:12:06 -05:00
autoLive={preferencesLoaded}
useWebGL={true}
streamName={streamName ?? ""}
pip={pip}
containerRef={containerRef}
setFullResolution={setFullResolution}
onError={handleError}
onHealthSample={autoSelected ? onHealthSample : undefined}
streamAuto={autoSelected}
2026-09-16 09:12:06 -05:00
onMicrophoneError={handleMicrophoneError}
/>
</div>
</TransformComponent>
{camera?.audio?.enabled_in_config &&
audioTranscriptionState == "ON" &&
transcription != null && (
<div
ref={transcriptionRef}
2026-05-29 17:00:30 -05: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%]"
>
{transcription}
</div>
)}
</div>
) : (
<TransformComponent
wrapperStyle={{
width: "100%",
height: "100%",
}}
contentStyle={{
position: "relative",
width: "100%",
height: "100%",
2024-03-15 08:57:58 -06:00
}}
>
<ObjectSettingsView selectedCamera={camera.name} />
</TransformComponent>
)}
2024-03-01 17:43:02 -07:00
</div>
{camera.onvif.host != "" && (
<div className="flex flex-col items-center justify-center">
<PtzControlPanel
className={debug && isMobile ? "bottom-auto top-[25%]" : ""}
camera={camera.name}
2025-05-14 16:44:06 -06:00
enabled={cameraEnabled}
clickOverlay={clickOverlay}
setClickOverlay={setClickOverlay}
/>
</div>
)}
2024-03-15 08:57:58 -06:00
</TransformWrapper>
2024-03-01 17:43:02 -07:00
);
}
2024-04-02 06:45:16 -06:00
type FrigateCameraFeaturesProps = {
2025-02-10 10:42:35 -06:00
camera: CameraConfig;
2024-08-12 07:21:21 -06:00
recordingEnabled: boolean;
2024-04-02 06:45:16 -06:00
audioDetectEnabled: boolean;
autotrackingEnabled: boolean;
2025-05-27 10:26:00 -05:00
transcriptionEnabled: boolean;
2024-04-02 06:45:16 -06:00
fullscreen: boolean;
pinnedStream: string | undefined;
playingStream: string;
autoAvailable: boolean;
autoReason?: LiveAutoReason;
onSelectStream: (stream: string | undefined) => void;
2026-09-16 09:12:06 -05:00
userPreferredLiveMode: LivePlayerMode;
setUserPreferredLiveMode: (value: LivePlayerMode | undefined) => void;
forceLowBandwidth: boolean;
setForceLowBandwidth: (value: boolean | undefined) => void;
preferredLiveMode: LivePlayerMode;
2025-02-10 10:42:35 -06:00
playInBackground: boolean;
setPlayInBackground: (value: boolean | undefined) => void;
showStats: boolean;
setShowStats: (value: boolean) => void;
isRestreamed: boolean;
2026-09-16 09:12:06 -05:00
isWebRTCAvailable: boolean;
webRTCUnavailableReason?: WebRTCUnavailableReason;
onResetStream: () => void;
2025-02-10 10:42:35 -06:00
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
2025-03-03 09:30:52 -06:00
cameraEnabled: boolean;
debug: boolean;
setDebug: (debug: boolean) => void;
2024-04-02 06:45:16 -06:00
};
function FrigateCameraFeatures({
camera,
2024-08-12 07:21:21 -06:00
recordingEnabled,
2024-04-02 06:45:16 -06:00
audioDetectEnabled,
autotrackingEnabled,
2025-05-27 10:26:00 -05:00
transcriptionEnabled,
2024-04-02 06:45:16 -06:00
fullscreen,
pinnedStream,
playingStream,
autoAvailable,
autoReason,
onSelectStream,
2026-09-16 09:12:06 -05:00
userPreferredLiveMode,
setUserPreferredLiveMode,
forceLowBandwidth,
setForceLowBandwidth,
2025-02-10 10:42:35 -06:00
preferredLiveMode,
playInBackground,
setPlayInBackground,
showStats,
setShowStats,
isRestreamed,
2026-09-16 09:12:06 -05:00
isWebRTCAvailable,
webRTCUnavailableReason,
onResetStream,
2025-02-10 10:42:35 -06:00
supportsAudioOutput,
supports2WayTalk,
2025-03-03 09:30:52 -06:00
cameraEnabled,
debug,
setDebug,
2024-04-02 06:45:16 -06:00
}: FrigateCameraFeaturesProps) {
2025-03-17 07:26:01 -05:00
const { t } = useTranslation(["views/live", "components/dialog"]);
2025-05-28 20:10:45 +08:00
const { getLocaleDocUrl } = useDocDomain();
2025-02-10 10:42:35 -06:00
const { payload: detectState, send: sendDetect } = useDetectState(
camera.name,
);
2025-03-03 09:30:52 -06:00
const { payload: enabledState, send: sendEnabled } = useEnabledState(
camera.name,
);
2025-02-10 10:42:35 -06: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);
const { payload: autotrackingState, send: sendAutotracking } =
2025-02-10 10:42:35 -06:00
useAutotrackingState(camera.name);
2025-05-27 10:26:00 -05:00
const { payload: transcriptionState, send: sendTranscription } =
useAudioTranscriptionState(camera.name);
2025-02-10 10:42:35 -06:00
2025-03-08 10:01:08 -06:00
// roles
const isAdmin = useIsAdmin();
2025-02-10 10:42:35 -06: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">
<div className="font-semibold">{t("manualRecording.started")}</div>
2025-10-15 11:09:28 -06:00
{!camera.record.enabled ||
(camera.record.alerts.retain.days == 0 && (
<div>{t("manualRecording.recordDisabledTips")}</div>
))}
2025-02-10 10:42:35 -06:00
</div>,
{
position: "top-center",
duration: 10000,
},
);
setActiveToastId(toastId);
}
} catch {
toast.error(t("manualRecording.failedToStart"), {
2025-02-10 10:42:35 -06:00
position: "top-center",
});
}
}, [camera, t]);
2025-02-10 10:42:35 -06: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);
toast.success(t("manualRecording.ended"), {
2025-02-10 10:42:35 -06:00
position: "top-center",
});
}
} catch {
toast.error(t("manualRecording.failedToEnd"), {
2025-02-10 10:42:35 -06:00
position: "top-center",
});
}
}, [activeToastId, t]);
2025-02-10 10:42:35 -06:00
2025-11-17 08:12:05 -06: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 {
2025-11-17 08:12:05 -06:00
// Silently ignore errors during unload
}
}, []);
2025-02-10 10:42:35 -06:00
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]);
2025-02-10 10:42:35 -06:00
useEffect(() => {
2025-11-17 08:12:05 -06: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 10:42:35 -06:00
// ensure manual event is stopped when component unmounts
return () => {
2025-11-17 08:12:05 -06:00
window.removeEventListener("beforeunload", handleBeforeUnload);
2025-02-10 10:42:35 -06:00
if (recordingEventIdRef.current) {
endEvent();
}
};
// mount/unmount only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
2024-04-02 06:45:16 -06:00
// desktop shows icons part of row
2024-05-18 10:54:46 -06:00
if (isDesktop || isTablet) {
2024-04-02 06:45:16 -06:00
return (
<>
2025-03-08 10:01:08 -06:00
{isAdmin && (
<>
<CameraFeatureToggle
className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"}
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
title={
2026-05-24 15:59:56 -05:00
enabledState == "ON" ? t("camera.turnOff") : t("camera.turnOn")
}
2025-03-08 10:01:08 -06:00
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={debug}
2025-03-08 10:01:08 -06:00
/>
<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")
}
2025-03-08 10:01:08 -06: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"}
title={
recordState == "ON"
? t("recording.disable")
2026-05-07 08:53:07 -05:00
: camera.record.enabled_in_config
? t("recording.enable")
: t("recording.disabledInConfig")
}
2025-03-08 10:01:08 -06:00
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
2026-05-07 08:53:07 -05:00
disabled={!cameraEnabled || !camera.record.enabled_in_config}
2025-03-08 10:01:08 -06:00
/>
<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")
}
2025-03-08 10:01:08 -06: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"}
title={
audioState == "ON"
? t("audioDetect.disable")
: t("audioDetect.enable")
}
2025-03-08 10:01:08 -06:00
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
)}
2025-05-27 10:26:00 -05: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 10:01:08 -06:00
{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")
}
2025-03-08 10:01:08 -06:00
onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
disabled={!cameraEnabled}
/>
)}
</>
)}
2025-02-10 10:42:35 -06: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}
title={t("manualRecording." + (isRecording ? "stop" : "start"))}
2025-02-10 10:42:35 -06:00
onClick={handleEventButtonClick}
disabled={!cameraEnabled || debug}
2025-02-10 10:42:35 -06: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-11 16:00:54 -07:00
{!fullscreen && (
2026-04-21 09:48:48 -05:00
<DropdownMenu>
2025-11-11 16:00:54 -07: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", {
ns: "components/dialog",
})}
2025-02-10 10:42:35 -06:00
</div>
2025-11-11 16:00:54 -07:00
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
2025-02-10 10:42:35 -06:00
</div>
2025-11-11 16:00:54 -07:00
</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>
<LiveStreamSelect
streams={camera.live.streams}
pinnedStream={pinnedStream}
playingStream={playingStream}
autoAvailable={autoAvailable}
autoReason={autoReason}
onSelect={onSelectStream}
2026-09-16 09:12:06 -05:00
disabled={debug || forceLowBandwidth}
/>
2025-11-11 16:00:54 -07:00
{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-11-11 16:00:54 -07: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>
</>
)}
</div>
)}
{preferredLiveMode == "jsmpeg" &&
2026-09-16 09:12:06 -05:00
userPreferredLiveMode != "jsmpeg" &&
!forceLowBandwidth &&
2025-11-11 16:00:54 -07:00
!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={onResetStream}
2025-11-11 16:00:54 -07:00
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
)}
</div>
)}
2026-09-16 09:12:06 -05:00
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="streaming-mode">{t("stream.mode")}</Label>
<StreamTechnologySelect
value={userPreferredLiveMode}
onValueChange={setUserPreferredLiveMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={debug || forceLowBandwidth}
/>
{debug ? (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>{t("stream.debug.technology")}</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t("stream.technology.description")}
</p>
)}
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Label
className="mx-0 cursor-pointer text-primary"
htmlFor="forcelowbandwidth"
>
{t("stream.lowBandwidth.force.label")}
</Label>
<Switch
className="ml-1"
id="forcelowbandwidth"
disabled={debug}
checked={forceLowBandwidth}
onCheckedChange={(checked) =>
setForceLowBandwidth(checked)
}
/>
</div>
<p className="text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc")}
</p>
</div>
)}
2025-11-11 16:00:54 -07:00
{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 10:42:35 -06: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-11 16:00:54 -07:00
htmlFor="showstats"
2025-02-10 10:42:35 -06:00
>
2025-11-11 16:00:54 -07:00
{t("streaming.showStats.label", {
ns: "components/dialog",
})}
2025-02-10 10:42:35 -06:00
</Label>
<Switch
className="ml-1"
2025-11-11 16:00:54 -07:00
id="showstats"
disabled={debug}
2025-11-11 16:00:54 -07:00
checked={showStats}
onCheckedChange={(checked) => setShowStats(checked)}
2025-02-10 10:42:35 -06:00
/>
</div>
<p className="text-sm text-muted-foreground">
2025-11-11 16:00:54 -07:00
{t("streaming.showStats.desc", {
ns: "components/dialog",
})}
2025-02-10 10:42:35 -06:00
</p>
</div>
2025-11-11 16:00:54 -07: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 10:42:35 -06:00
</div>
</div>
2025-11-11 16:00:54 -07:00
</DropdownMenuContent>
</DropdownMenu>
)}
2024-04-02 06:45:16 -06:00
</>
);
}
// mobile doesn't show settings in fullscreen view
if (fullscreen) {
return;
}
return (
<Drawer>
<DrawerTrigger>
<CameraFeatureToggle
2024-04-07 14:37:33 -06:00
className="p-2 landscape:size-9"
2024-04-02 06:45:16 -06:00
variant="primary"
Icon={FaCog}
isActive={false}
title={t("cameraSettings.title", { camera })}
2024-04-02 06:45:16 -06:00
/>
</DrawerTrigger>
2025-11-24 07:34:56 -06: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 10:01:08 -06:00
<FilterSwitch
2026-05-24 15:59:56 -05:00
label={t("cameraSettings.camera")}
2025-11-24 07:34:56 -06:00
isChecked={enabledState == "ON"}
2025-03-08 10:01:08 -06:00
onCheckedChange={() =>
2025-11-24 07:34:56 -06:00
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
2025-03-08 10:01:08 -06:00
}
/>
<FilterSwitch
2025-11-24 07:34:56 -06:00
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
2025-03-08 10:01:08 -06:00
onCheckedChange={() =>
2025-11-24 07:34:56 -06:00
sendDetect(detectState == "ON" ? "OFF" : "ON")
2025-03-08 10:01:08 -06:00
}
/>
2025-11-24 07:34:56 -06:00
{recordingEnabled && (
<FilterSwitch
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
2025-05-27 10:26:00 -05:00
<FilterSwitch
2025-11-24 07:34:56 -06:00
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
2025-05-27 10:26:00 -05:00
onCheckedChange={() =>
2025-11-24 07:34:56 -06:00
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
2025-05-27 10:26:00 -05:00
}
/>
2025-11-24 07:34:56 -06: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 10:01:08 -06:00
2025-11-24 07:34:56 -06: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>
2025-02-10 10:42:35 -06:00
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
2025-11-24 07:34:56 -06:00
<LuX className="size-4 text-danger" />
<div>
{t("streaming.restreaming.disabled", {
ns: "components/dialog",
})}
</div>
2026-04-21 09:48:48 -05:00
<Popover>
2025-11-24 07:34:56 -06: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>
</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>
<LiveStreamSelect
streams={camera.live.streams}
pinnedStream={pinnedStream}
playingStream={playingStream}
autoAvailable={autoAvailable}
autoReason={autoReason}
onSelect={onSelectStream}
2026-09-16 09:12:06 -05:00
disabled={debug || forceLowBandwidth}
/>
2025-11-24 07:34:56 -06:00
{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>
2026-04-21 09:48:48 -05:00
<Popover>
2025-11-24 07:34:56 -06: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 09:48:48 -05:00
<Popover>
2025-11-24 07:34:56 -06: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>
)}
2026-09-16 09:12:06 -05:00
{preferredLiveMode == "jsmpeg" &&
userPreferredLiveMode != "jsmpeg" &&
!forceLowBandwidth &&
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>
2025-11-24 07:34:56 -06:00
</div>
2026-09-16 09:12:06 -05:00
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
disabled={debug}
onClick={onResetStream}
2026-09-16 09:12:06 -05:00
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
)}
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="px-2">
<div className="mb-1 text-sm">{t("stream.mode")}</div>
<StreamTechnologySelect
value={userPreferredLiveMode}
onValueChange={setUserPreferredLiveMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={debug || forceLowBandwidth}
/>
<p className="mt-1 text-sm text-muted-foreground">
{t("stream.technology.description")}
</p>
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-2">
<FilterSwitch
label={t("stream.lowBandwidth.force.label")}
isChecked={forceLowBandwidth}
onCheckedChange={(checked) =>
setForceLowBandwidth(checked)
}
disabled={debug}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc")}
</p>
2025-02-10 10:42:35 -06:00
</div>
)}
2025-11-24 07:34:56 -06: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">
2025-02-10 10:42:35 -06:00
<Button
2025-11-24 07:34:56 -06:00
onClick={handleSnapshotClick}
disabled={!cameraEnabled || debug || isSnapshotLoading}
className="h-auto w-full whitespace-normal"
2025-02-10 10:42:35 -06:00
>
2025-11-24 07:34:56 -06:00
{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"))}
2025-02-10 10:42:35 -06:00
</Button>
</div>
2025-11-24 07:34:56 -06:00
<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>
</>
2025-02-10 10:42:35 -06:00
)}
2025-11-24 07:34:56 -06:00
<div className="mb-3 flex flex-col">
2025-02-10 10:42:35 -06:00
<FilterSwitch
2025-11-24 07:34:56 -06:00
label={t("streaming.debugView", { ns: "components/dialog" })}
isChecked={debug}
onCheckedChange={(checked) => setDebug(checked)}
2025-02-10 10:42:35 -06:00
/>
</div>
2025-11-24 07:34:56 -06:00
</div>
</>
2025-02-10 10:42:35 -06:00
</div>
2024-04-02 06:45:16 -06:00
</DrawerContent>
</Drawer>
);
}