mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-01 19:30:33 +03:00
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* implement auto mode for single camera live view
* add transcoded live streams and stream ordering for auto mode
Cameras can now add lower quality live streams that go2rtc transcodes to H.264 on demand. `live.transcode` takes a source stream and a list of heights and bitrates, and each quality becomes a `{camera}_transcode_{height}p` stream. The config validator adds them to `live.streams` without moving any the user already placed, and drops them when transcoding is disabled or a height changes. `create_config.py` writes them into go2rtc's generated config at startup, and saving the config or deleting a camera syncs them through go2rtc's API, so no restart is needed. They use `#hardware`, so go2rtc picks a hardware encoder and falls back to the CPU when there isn't one.
The Live playback settings stream list can be reordered by drag, since its order is the auto ladder. Auto order sorts it by bitrate, measuring native streams through a new admin-only `/go2rtc/streams/{name}/bitrate` endpoint and using the configured bitrate for transcoded ones. A pure reorder wasn't saved before because RJSF, the settings form, and `update_yaml` all ignore map key order. Sections can now mark a map with `orderedMaps`, which sends the whole map with `replace_paths` so `config_set` rewrites it in order.
Transcoded streams aren't in `go2rtc.streams`, which only lists yaml streams, so the frontend treated them as not restreamed and fell back to jsmpeg. Every restream check now goes through `isRestreamedStream`.
Auto treated any stall with no bytes in the last 2 seconds as a dead camera and handed it to the error fallback, which went straight to jsmpeg. Heavy congestion can stop delivery completely, so congested viewers skipped every lower stream. Auto now declines only when stats show the camera offline, and a stall on a live camera steps down. The stream picker also has a Try highest quality button that sends auto back to the top stream.
* fixes
2047 lines
75 KiB
TypeScript
2047 lines
75 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 StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
|
|
import LiveStreamSelect from "@/components/player/LiveStreamSelect";
|
|
import { useAutoLiveStream } from "@/hooks/use-auto-live-stream";
|
|
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 { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
|
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
|
import {
|
|
LiveAutoReason,
|
|
LivePlayerError,
|
|
TwoWayTalkError,
|
|
LivePlayerMode,
|
|
LiveStreamMetadata,
|
|
VideoResolutionType,
|
|
WebRTCUnavailableReason,
|
|
} from "@/types/live";
|
|
import { RecordingStartingPoint } from "@/types/record";
|
|
import { 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 { 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 { isRestreamedStream } from "@/utils/liveTranscode";
|
|
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
|
|
|
|
// an absent saved stream means auto
|
|
const [pinnedStream, setPinnedStream, streamNameLoaded, clearPinnedStream] =
|
|
useUserPersistence<string>(`${camera.name}-stream`);
|
|
|
|
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],
|
|
);
|
|
|
|
const isRestreamed = useMemo(
|
|
() => isRestreamedStream(config, streamName),
|
|
[config, streamName],
|
|
);
|
|
|
|
// a pin to a stream no longer in config falls back to auto
|
|
|
|
useEffect(() => {
|
|
if (!streamNameLoaded || pinnedStream == undefined) return;
|
|
|
|
if (liveStreams.length > 0 && !liveStreams.includes(pinnedStream)) {
|
|
clearPinnedStream();
|
|
}
|
|
}, [streamNameLoaded, liveStreams, pinnedStream, clearPinnedStream]);
|
|
|
|
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
|
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
|
{
|
|
revalidateOnFocus: false,
|
|
revalidateOnReconnect: false,
|
|
revalidateIfStale: false,
|
|
dedupingInterval: 60000,
|
|
},
|
|
);
|
|
|
|
const webRTCAvailability = useWebRTCAvailableForStream(
|
|
cameraMetadata,
|
|
streamName,
|
|
);
|
|
const isWebRTCAvailable = webRTCAvailability.available;
|
|
|
|
// "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;
|
|
|
|
// Two-way talk is a sendonly backchannel, so playback audio that WebRTC
|
|
// can't carry doesn't rule it out, but the stream's video must connect
|
|
const talkAvailable =
|
|
isWebRTCAvailable || webRTCAvailability.reason === "audio-codec";
|
|
|
|
// 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";
|
|
|
|
// 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);
|
|
|
|
// the mic only connects through the WebRTC player, so it can't stay on
|
|
// for a stream that has lost it
|
|
useEffect(() => {
|
|
if (!talkAvailable) {
|
|
setMic(false);
|
|
}
|
|
}, [talkAvailable]);
|
|
|
|
const [pip, setPip] = useState(false);
|
|
|
|
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
|
|
`${camera.name}-background-play`,
|
|
false,
|
|
);
|
|
|
|
const [showStats, setShowStats] = 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 && talkAvailable) {
|
|
return "webrtc";
|
|
}
|
|
|
|
if (forceLowBandwidth || autoStream.atFloor) {
|
|
return "jsmpeg";
|
|
}
|
|
|
|
if (webRTC && isRestreamed && isWebRTCAvailable) {
|
|
return "webrtc";
|
|
}
|
|
|
|
if (webRTC && !isRestreamed) {
|
|
return "jsmpeg";
|
|
}
|
|
|
|
if (lowBandwidth) {
|
|
return "jsmpeg";
|
|
}
|
|
|
|
if (
|
|
!("MediaSource" in window || "ManagedMediaSource" in window) &&
|
|
isWebRTCAvailable
|
|
) {
|
|
return "webrtc";
|
|
}
|
|
|
|
return resolvedUserMode;
|
|
}, [
|
|
lowBandwidth,
|
|
forceLowBandwidth,
|
|
mic,
|
|
talkAvailable,
|
|
webRTC,
|
|
isRestreamed,
|
|
resolvedUserMode,
|
|
isWebRTCAvailable,
|
|
autoStream.atFloor,
|
|
]);
|
|
|
|
// 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]);
|
|
|
|
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 && talkAvailable) {
|
|
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) {
|
|
// auto answers congestion and codec failures with a lower stream
|
|
if (autoHandleError(e)) {
|
|
return;
|
|
}
|
|
|
|
// 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) {
|
|
setWebRTC(true);
|
|
} else {
|
|
setWebRTC(false);
|
|
setLowBandwidth(true);
|
|
}
|
|
}
|
|
},
|
|
[autoHandleError, preferredLiveMode, webRTCUsable],
|
|
);
|
|
|
|
const resetStream = useCallback(() => {
|
|
setLowBandwidth(false);
|
|
resetAutoStream();
|
|
}, [resetAutoStream]);
|
|
|
|
const handleMicrophoneError = useCallback(
|
|
(error: TwoWayTalkError) => {
|
|
setMic(false);
|
|
toast.error(t(`twoWayTalk.error.${error}`), { position: "top-center" });
|
|
},
|
|
[t],
|
|
);
|
|
|
|
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={
|
|
webRTCVerdictPending
|
|
? t("stream.technology.unavailable.checking", {
|
|
ns: "views/live",
|
|
})
|
|
: !talkAvailable
|
|
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
|
|
: mic
|
|
? t("twoWayTalk.disable", { ns: "views/live" })
|
|
: t("twoWayTalk.enable", { ns: "views/live" })
|
|
}
|
|
onClick={() => {
|
|
setMic(!mic);
|
|
if (!mic && !audio) {
|
|
setAudio(true);
|
|
}
|
|
}}
|
|
disabled={!cameraEnabled || debug || !talkAvailable}
|
|
/>
|
|
)}
|
|
{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}
|
|
pinnedStream={pinnedStream}
|
|
playingStream={streamName ?? ""}
|
|
autoAvailable={autoAvailable}
|
|
autoReason={autoStream.reason}
|
|
onSelectStream={selectStream}
|
|
userPreferredLiveMode={resolvedUserMode}
|
|
setUserPreferredLiveMode={setUserPreferredLiveMode}
|
|
forceLowBandwidth={forceLowBandwidth ?? false}
|
|
setForceLowBandwidth={setForceLowBandwidth}
|
|
preferredLiveMode={preferredLiveMode}
|
|
playInBackground={playInBackground ?? false}
|
|
setPlayInBackground={setPlayInBackground}
|
|
showStats={showStats}
|
|
setShowStats={setShowStats}
|
|
isRestreamed={isRestreamed ?? false}
|
|
isWebRTCAvailable={isWebRTCAvailable}
|
|
webRTCUnavailableReason={webRTCAvailability.reason}
|
|
onResetStream={resetStream}
|
|
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}
|
|
autoLive={preferencesLoaded}
|
|
useWebGL={true}
|
|
streamName={streamName ?? ""}
|
|
pip={pip}
|
|
containerRef={containerRef}
|
|
setFullResolution={setFullResolution}
|
|
onError={handleError}
|
|
onHealthSample={autoSelected ? onHealthSample : undefined}
|
|
streamAuto={autoSelected}
|
|
onMicrophoneError={handleMicrophoneError}
|
|
/>
|
|
</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;
|
|
pinnedStream: string | undefined;
|
|
playingStream: string;
|
|
autoAvailable: boolean;
|
|
autoReason?: LiveAutoReason;
|
|
onSelectStream: (stream: string | undefined) => void;
|
|
userPreferredLiveMode: LivePlayerMode;
|
|
setUserPreferredLiveMode: (value: LivePlayerMode | undefined) => void;
|
|
forceLowBandwidth: boolean;
|
|
setForceLowBandwidth: (value: boolean | undefined) => void;
|
|
preferredLiveMode: LivePlayerMode;
|
|
playInBackground: boolean;
|
|
setPlayInBackground: (value: boolean | undefined) => void;
|
|
showStats: boolean;
|
|
setShowStats: (value: boolean) => void;
|
|
isRestreamed: boolean;
|
|
isWebRTCAvailable: boolean;
|
|
webRTCUnavailableReason?: WebRTCUnavailableReason;
|
|
onResetStream: () => void;
|
|
supportsAudioOutput: boolean;
|
|
supports2WayTalk: boolean;
|
|
cameraEnabled: boolean;
|
|
debug: boolean;
|
|
setDebug: (debug: boolean) => void;
|
|
};
|
|
function FrigateCameraFeatures({
|
|
camera,
|
|
recordingEnabled,
|
|
audioDetectEnabled,
|
|
autotrackingEnabled,
|
|
transcriptionEnabled,
|
|
fullscreen,
|
|
pinnedStream,
|
|
playingStream,
|
|
autoAvailable,
|
|
autoReason,
|
|
onSelectStream,
|
|
userPreferredLiveMode,
|
|
setUserPreferredLiveMode,
|
|
forceLowBandwidth,
|
|
setForceLowBandwidth,
|
|
preferredLiveMode,
|
|
playInBackground,
|
|
setPlayInBackground,
|
|
showStats,
|
|
setShowStats,
|
|
isRestreamed,
|
|
isWebRTCAvailable,
|
|
webRTCUnavailableReason,
|
|
onResetStream,
|
|
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 {
|
|
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 {
|
|
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 {
|
|
// 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>
|
|
<LiveStreamSelect
|
|
streams={camera.live.streams}
|
|
pinnedStream={pinnedStream}
|
|
playingStream={playingStream}
|
|
autoAvailable={autoAvailable}
|
|
autoReason={autoReason}
|
|
onSelect={onSelectStream}
|
|
onRetry={onResetStream}
|
|
disabled={debug || forceLowBandwidth}
|
|
/>
|
|
|
|
{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" &&
|
|
userPreferredLiveMode != "jsmpeg" &&
|
|
!forceLowBandwidth &&
|
|
!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}
|
|
>
|
|
<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="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>
|
|
)}
|
|
|
|
{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>
|
|
<LiveStreamSelect
|
|
streams={camera.live.streams}
|
|
pinnedStream={pinnedStream}
|
|
playingStream={playingStream}
|
|
autoAvailable={autoAvailable}
|
|
autoReason={autoReason}
|
|
onSelect={onSelectStream}
|
|
onRetry={onResetStream}
|
|
disabled={debug || forceLowBandwidth}
|
|
/>
|
|
|
|
{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" &&
|
|
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>
|
|
</div>
|
|
<Button
|
|
className={`flex items-center gap-2.5 rounded-lg`}
|
|
aria-label={t("stream.lowBandwidth.resetStream")}
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={debug}
|
|
onClick={onResetStream}
|
|
>
|
|
<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>
|
|
</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>
|
|
);
|
|
}
|