Compare commits

...

3 Commits

Author SHA1 Message Date
Josh Hawkins
8b85cd816e
Rename conflicting bash variables (#20276)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
In bash you cannot redeclare a readonly variable as local, even within a function scope
2025-09-29 19:51:53 -06:00
Nicolas Mowen
bebe99d9b8
Implement automatic go2rtc homekit config (#20275)
* Implement automatic go2rtc homekit config

* Update docs
2025-09-29 18:48:20 -05:00
Nicolas Mowen
a08fda62f8
Implement debug live view as part of live (#20270)
* Cleanup components

* integrate debug view

* Refactor menu handling

* Cleanup

* cleanup

* Improve ptz placement for debug view

* Cleanup

* Cleanup mobile

* Always show options

* Add info for stream picking being disabled

* Add to mobile too

* Fix ns

* Cleanup
2025-09-29 18:45:55 -05:00
6 changed files with 615 additions and 488 deletions

View File

@ -50,6 +50,38 @@ function set_libva_version() {
export LIBAVFORMAT_VERSION_MAJOR
}
function setup_homekit_config() {
local config_path="$1"
if [[ ! -f "${config_path}" ]]; then
echo "[INFO] Creating empty HomeKit config file..."
echo '{}' > "${config_path}"
fi
# Convert YAML to JSON for jq processing
local temp_json="/tmp/cache/homekit_config.json"
yq eval -o=json "${config_path}" > "${temp_json}" 2>/dev/null || {
echo "[WARNING] Failed to convert HomeKit config to JSON, skipping cleanup"
return 0
}
# Use jq to filter and keep only the homekit section
local cleaned_json="/tmp/cache/homekit_cleaned.json"
jq '
# Keep only the homekit section if it exists, otherwise empty object
if has("homekit") then {homekit: .homekit} else {homekit: {}} end
' "${temp_json}" > "${cleaned_json}" 2>/dev/null || echo '{"homekit": {}}' > "${cleaned_json}"
# Convert back to YAML and write to the config file
yq eval -P "${cleaned_json}" > "${config_path}" 2>/dev/null || {
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
echo '{"homekit": {}}' > "${config_path}"
}
# Clean up temp files
rm -f "${temp_json}" "${cleaned_json}"
}
set_libva_version
if [[ -f "/dev/shm/go2rtc.yaml" ]]; then
@ -70,6 +102,10 @@ else
echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually."
fi
# HomeKit configuration persistence setup
readonly homekit_config_path="/config/go2rtc_homekit.yml"
setup_homekit_config "${homekit_config_path}"
readonly config_path="/config"
if [[ -x "${config_path}/go2rtc" ]]; then
@ -82,5 +118,7 @@ fi
echo "[INFO] Starting go2rtc..."
# Replace the bash process with the go2rtc process, redirecting stderr to stdout
# Use HomeKit config as the primary config so writebacks go there
# The main config from Frigate will be loaded as a secondary config
exec 2>&1
exec "${binary_path}" -config=/dev/shm/go2rtc.yaml
exec "${binary_path}" -config="${homekit_config_path}" -config=/dev/shm/go2rtc.yaml

View File

@ -3,15 +3,13 @@ id: configuring_go2rtc
title: Configuring go2rtc
---
# Configuring go2rtc
Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect directly to your cameras. However, adding go2rtc to your configuration is required for the following features:
- WebRTC or MSE for live viewing with audio, higher resolutions and frame rates than the jsmpeg stream which is limited to the detect stream and does not support audio
- Live stream support for cameras in Home Assistant Integration
- RTSP relay for use with other consumers to reduce the number of connections to your camera streams
# Setup a go2rtc stream
## Setup a go2rtc stream
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#module-streams), not just rtsp.
@ -111,11 +109,11 @@ section.
:::
## Next steps
### Next steps
1. If the stream you added to go2rtc is also used by Frigate for the `record` or `detect` role, you can migrate your config to pull from the RTSP restream to reduce the number of connections to your camera as shown [here](/configuration/restream#reduce-connections-to-camera).
2. You can [set up WebRTC](/configuration/live#webrtc-extra-configuration) if your camera supports two-way talk. Note that WebRTC only supports specific audio formats and may require opening ports on your router.
## Important considerations
## Homekit Configuration
If you are configuring go2rtc to publish HomeKit camera streams, on pairing the configuration is written to the `/dev/shm/go2rtc.yaml` file inside the container. These changes must be manually copied across to the `go2rtc` section of your Frigate configuration in order to persist through restarts.
To add camera streams to Homekit Frigate must be configured in docker to use `host` networking mode. Once that is done, you can use the go2rtc WebUI (accessed via port 1984, which is disabled by default) to share export a camera to Homekit. Any changes made will automatically be saved to `/config/go2rtc_homekit.yml`.

View File

@ -124,6 +124,9 @@
"available": "Audio is available for this stream",
"unavailable": "Audio is not available for this stream"
},
"debug": {
"picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role."
},
"twoWayTalk": {
"tips": "Your device must support the feature and WebRTC must be configured for two-way talk.",
"available": "Two-way talk is available for this stream",

View File

@ -0,0 +1,324 @@
import { usePtzCommand } from "@/api/ws";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { CameraPtzInfo } from "@/types/ptz";
import React, { useCallback } from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { BsThreeDotsVertical } from "react-icons/bs";
import {
FaAngleDown,
FaAngleLeft,
FaAngleRight,
FaAngleUp,
} from "react-icons/fa";
import { TbViewfinder } from "react-icons/tb";
import {
MdCenterFocusStrong,
MdCenterFocusWeak,
MdZoomIn,
MdZoomOut,
} from "react-icons/md";
import useSWR from "swr";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import TooltipButton from "@/views/button/TooltipButton";
export default function PtzControlPanel({
className,
camera,
enabled,
clickOverlay,
setClickOverlay,
}: {
className?: string;
camera: string;
enabled: boolean;
clickOverlay: boolean;
setClickOverlay: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const { t } = useTranslation(["views/live"]);
const { data: ptz } = useSWR<CameraPtzInfo>(
enabled ? `${camera}/ptz/info` : null,
);
const { send: sendPtz } = usePtzCommand(camera);
const onStop = useCallback(
(e: React.SyntheticEvent) => {
e.preventDefault();
sendPtz("STOP");
},
[sendPtz],
);
useKeyboardListener(
[
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"+",
"-",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
],
(key, modifiers) => {
if (modifiers.repeat || !key) {
return;
}
if (["1", "2", "3", "4", "5", "6", "7", "8", "9"].includes(key)) {
const presetNumber = parseInt(key);
if (
ptz &&
(ptz.presets?.length ?? 0) > 0 &&
presetNumber <= ptz.presets.length
) {
sendPtz(`preset_${ptz.presets[presetNumber - 1]}`);
}
return;
}
if (!modifiers.down) {
sendPtz("STOP");
return;
}
switch (key) {
case "ArrowLeft":
sendPtz("MOVE_LEFT");
break;
case "ArrowRight":
sendPtz("MOVE_RIGHT");
break;
case "ArrowUp":
sendPtz("MOVE_UP");
break;
case "ArrowDown":
sendPtz("MOVE_DOWN");
break;
case "+":
sendPtz(modifiers.shift ? "FOCUS_IN" : "ZOOM_IN");
break;
case "-":
sendPtz(modifiers.shift ? "FOCUS_OUT" : "ZOOM_OUT");
break;
}
},
);
return (
<div
className={cn(
"absolute inset-x-2 bottom-[10%] flex select-none flex-wrap items-center justify-center gap-1 md:left-[50%] md:-translate-x-[50%] md:flex-nowrap",
className ?? "",
isMobile && "landscape:ml-12",
)}
>
{ptz?.features?.includes("pt") && (
<>
<TooltipButton
label={t("ptz.move.left.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_LEFT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("MOVE_LEFT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<FaAngleLeft />
</TooltipButton>
<TooltipButton
label={t("ptz.move.up.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_UP");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("MOVE_UP");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<FaAngleUp />
</TooltipButton>
<TooltipButton
label={t("ptz.move.down.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_DOWN");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("MOVE_DOWN");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<FaAngleDown />
</TooltipButton>
<TooltipButton
label={t("ptz.move.right.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_RIGHT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("MOVE_RIGHT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<FaAngleRight />
</TooltipButton>
</>
)}
{ptz?.features?.includes("zoom") && (
<>
<TooltipButton
label={t("ptz.zoom.in.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("ZOOM_IN");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("ZOOM_IN");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdZoomIn />
</TooltipButton>
<TooltipButton
label={t("ptz.zoom.out.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("ZOOM_OUT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("ZOOM_OUT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdZoomOut />
</TooltipButton>
</>
)}
{ptz?.features?.includes("focus") && (
<>
<TooltipButton
label={t("ptz.focus.in.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("FOCUS_IN");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("FOCUS_IN");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdCenterFocusStrong />
</TooltipButton>
<TooltipButton
label={t("ptz.focus.out.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("FOCUS_OUT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("FOCUS_OUT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdCenterFocusWeak />
</TooltipButton>
</>
)}
{ptz?.features?.includes("pt-r-fov") && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
aria-label={t("ptz.move.clickMove.label")}
onClick={() => setClickOverlay(!clickOverlay)}
>
<TbViewfinder />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{clickOverlay
? t("ptz.move.clickMove.disable")
: t("ptz.move.clickMove.enable")}
</p>
</TooltipContent>
</Tooltip>
)}
{(ptz?.presets?.length ?? 0) > 0 && (
<DropdownMenu modal={!isDesktop}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button aria-label={t("ptz.presets")}>
<BsThreeDotsVertical />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>{t("ptz.presets")}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
className="scrollbar-container max-h-[40dvh] overflow-y-auto"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{ptz?.presets.map((preset) => (
<DropdownMenuItem
key={preset}
aria-label={preset}
className="cursor-pointer"
onSelect={() => sendPtz(`preset_${preset}`)}
>
{preset}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@ -0,0 +1,52 @@
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ReactNode } from "react";
type TooltipButtonProps = {
label: string;
onClick?: () => void;
onMouseDown?: (e: React.MouseEvent) => void;
onMouseUp?: (e: React.MouseEvent) => void;
onTouchStart?: (e: React.TouchEvent) => void;
onTouchEnd?: (e: React.TouchEvent) => void;
children: ReactNode;
className?: string;
};
export default function TooltipButton({
label,
onClick,
onMouseDown,
onMouseUp,
onTouchStart,
onTouchEnd,
children,
className,
...props
}: TooltipButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={label}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
className={className}
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}

View File

@ -17,7 +17,6 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@ -25,11 +24,6 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
@ -38,10 +32,8 @@ import {
LiveStreamMetadata,
VideoResolutionType,
} from "@/types/live";
import { CameraPtzInfo } from "@/types/ptz";
import { RecordingStartingPoint } from "@/types/record";
import React, {
ReactNode,
useCallback,
useEffect,
useMemo,
@ -56,12 +48,7 @@ import {
isTablet,
useMobileOrientation,
} from "react-device-detect";
import { BsThreeDotsVertical } from "react-icons/bs";
import {
FaAngleDown,
FaAngleLeft,
FaAngleRight,
FaAngleUp,
FaCog,
FaCompress,
FaExpand,
@ -91,8 +78,6 @@ import {
LuX,
} from "react-icons/lu";
import {
MdCenterFocusStrong,
MdCenterFocusWeak,
MdClosedCaption,
MdClosedCaptionDisabled,
MdNoPhotography,
@ -100,8 +85,6 @@ import {
MdPersonOff,
MdPersonSearch,
MdPhotoCamera,
MdZoomIn,
MdZoomOut,
} from "react-icons/md";
import { Link, useNavigate } from "react-router-dom";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
@ -126,6 +109,8 @@ import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
import ObjectSettingsView from "../settings/ObjectSettingsView";
type LiveCameraViewProps = {
config?: FrigateConfig;
@ -287,6 +272,7 @@ export default function LiveCameraView({
);
const [showStats, setShowStats] = useState(false);
const [debug, setDebug] = useState(false);
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
width: 0,
@ -437,7 +423,11 @@ export default function LiveCameraView({
);
return (
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
<TransformWrapper
minScale={1.0}
wheel={{ smoothStep: 0.005 }}
disabled={debug}
>
<Toaster position="top-center" closeButton={true} />
<div
ref={mainRef}
@ -523,6 +513,7 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={fullscreen ? FaCompress : FaExpand}
isActive={fullscreen}
disabled={debug}
title={
fullscreen
? t("button.close", { ns: "common" })
@ -550,7 +541,7 @@ export default function LiveCameraView({
setPip(false);
}
}}
disabled={!cameraEnabled}
disabled={!cameraEnabled || debug}
/>
)}
{supports2WayTalk && (
@ -570,7 +561,7 @@ export default function LiveCameraView({
setAudio(true);
}
}}
disabled={!cameraEnabled}
disabled={!cameraEnabled || debug}
/>
)}
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
@ -585,7 +576,7 @@ export default function LiveCameraView({
: t("cameraAudio.enable", { ns: "views/live" })
}
onClick={() => setAudio(!audio)}
disabled={!cameraEnabled}
disabled={!cameraEnabled || debug}
/>
)}
<FrigateCameraFeatures
@ -609,10 +600,66 @@ export default function LiveCameraView({
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
cameraEnabled={cameraEnabled}
debug={debug}
setDebug={setDebug}
/>
</div>
</div>
<div id="player-container" className="size-full" ref={containerRef}>
{!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={`flex flex-col items-center justify-center ${growClassName}`}
ref={clickOverlayRef}
onClick={handleOverlayClick}
style={{
aspectRatio: constrainedAspectRatio,
}}
>
<LivePlayer
key={camera.name}
className={`${fullscreen ? "*:rounded-none" : ""}`}
windowVisible
showStillWithoutActivity={false}
cameraConfig={camera}
playAudio={audio}
playInBackground={playInBackground ?? false}
showStats={showStats}
micEnabled={mic}
iOSCompatFullScreen={isIOS}
preferredLiveMode={preferredLiveMode}
useWebGL={true}
streamName={streamName ?? ""}
pip={pip}
containerRef={containerRef}
setFullResolution={setFullResolution}
onError={handleError}
/>
</div>
</TransformComponent>
{camera?.audio?.enabled_in_config &&
audioTranscriptionState == "ON" &&
transcription != null && (
<div
ref={transcriptionRef}
className="text-md 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%",
@ -622,53 +669,16 @@ export default function LiveCameraView({
position: "relative",
width: "100%",
height: "100%",
padding: "8px",
}}
>
<div
className={`flex flex-col items-center justify-center ${growClassName}`}
ref={clickOverlayRef}
onClick={handleOverlayClick}
style={{
aspectRatio: constrainedAspectRatio,
}}
>
<LivePlayer
key={camera.name}
className={`${fullscreen ? "*:rounded-none" : ""}`}
windowVisible
showStillWithoutActivity={false}
cameraConfig={camera}
playAudio={audio}
playInBackground={playInBackground ?? false}
showStats={showStats}
micEnabled={mic}
iOSCompatFullScreen={isIOS}
preferredLiveMode={preferredLiveMode}
useWebGL={true}
streamName={streamName ?? ""}
pip={pip}
containerRef={containerRef}
setFullResolution={setFullResolution}
onError={handleError}
/>
</div>
<ObjectSettingsView selectedCamera={camera.name} />
</TransformComponent>
{camera?.audio?.enabled_in_config &&
audioTranscriptionState == "ON" &&
transcription != null && (
<div
ref={transcriptionRef}
className="text-md 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>
)}
</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}
@ -680,336 +690,6 @@ export default function LiveCameraView({
);
}
type TooltipButtonProps = {
label: string;
onClick?: () => void;
onMouseDown?: (e: React.MouseEvent) => void;
onMouseUp?: (e: React.MouseEvent) => void;
onTouchStart?: (e: React.TouchEvent) => void;
onTouchEnd?: (e: React.TouchEvent) => void;
children: ReactNode;
className?: string;
};
function TooltipButton({
label,
onClick,
onMouseDown,
onMouseUp,
onTouchStart,
onTouchEnd,
children,
className,
...props
}: TooltipButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={label}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
className={className}
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}
function PtzControlPanel({
camera,
enabled,
clickOverlay,
setClickOverlay,
}: {
camera: string;
enabled: boolean;
clickOverlay: boolean;
setClickOverlay: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const { t } = useTranslation(["views/live"]);
const { data: ptz } = useSWR<CameraPtzInfo>(
enabled ? `${camera}/ptz/info` : null,
);
const { send: sendPtz } = usePtzCommand(camera);
const onStop = useCallback(
(e: React.SyntheticEvent) => {
e.preventDefault();
sendPtz("STOP");
},
[sendPtz],
);
useKeyboardListener(
[
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"+",
"-",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
],
(key, modifiers) => {
if (modifiers.repeat || !key) {
return;
}
if (["1", "2", "3", "4", "5", "6", "7", "8", "9"].includes(key)) {
const presetNumber = parseInt(key);
if (
ptz &&
(ptz.presets?.length ?? 0) > 0 &&
presetNumber <= ptz.presets.length
) {
sendPtz(`preset_${ptz.presets[presetNumber - 1]}`);
}
return;
}
if (!modifiers.down) {
sendPtz("STOP");
return;
}
switch (key) {
case "ArrowLeft":
sendPtz("MOVE_LEFT");
break;
case "ArrowRight":
sendPtz("MOVE_RIGHT");
break;
case "ArrowUp":
sendPtz("MOVE_UP");
break;
case "ArrowDown":
sendPtz("MOVE_DOWN");
break;
case "+":
sendPtz(modifiers.shift ? "FOCUS_IN" : "ZOOM_IN");
break;
case "-":
sendPtz(modifiers.shift ? "FOCUS_OUT" : "ZOOM_OUT");
break;
}
},
);
return (
<div
className={cn(
"absolute inset-x-2 bottom-[10%] flex select-none flex-wrap items-center justify-center gap-1 md:left-[50%] md:-translate-x-[50%] md:flex-nowrap",
isMobile && "landscape:ml-12",
)}
>
{ptz?.features?.includes("pt") && (
<>
<TooltipButton
label={t("ptz.move.left.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_LEFT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("MOVE_LEFT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<FaAngleLeft />
</TooltipButton>
<TooltipButton
label={t("ptz.move.up.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_UP");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("MOVE_UP");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<FaAngleUp />
</TooltipButton>
<TooltipButton
label={t("ptz.move.down.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_DOWN");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("MOVE_DOWN");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<FaAngleDown />
</TooltipButton>
<TooltipButton
label={t("ptz.move.right.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_RIGHT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("MOVE_RIGHT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<FaAngleRight />
</TooltipButton>
</>
)}
{ptz?.features?.includes("zoom") && (
<>
<TooltipButton
label={t("ptz.zoom.in.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("ZOOM_IN");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("ZOOM_IN");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdZoomIn />
</TooltipButton>
<TooltipButton
label={t("ptz.zoom.out.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("ZOOM_OUT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("ZOOM_OUT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdZoomOut />
</TooltipButton>
</>
)}
{ptz?.features?.includes("focus") && (
<>
<TooltipButton
label={t("ptz.focus.in.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("FOCUS_IN");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("FOCUS_IN");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdCenterFocusStrong />
</TooltipButton>
<TooltipButton
label={t("ptz.focus.out.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("FOCUS_OUT");
}}
onTouchStart={(e) => {
e.preventDefault();
sendPtz("FOCUS_OUT");
}}
onMouseUp={onStop}
onTouchEnd={onStop}
>
<MdCenterFocusWeak />
</TooltipButton>
</>
)}
{ptz?.features?.includes("pt-r-fov") && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
aria-label={t("ptz.move.clickMove.label")}
onClick={() => setClickOverlay(!clickOverlay)}
>
<TbViewfinder />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{clickOverlay
? t("ptz.move.clickMove.disable")
: t("ptz.move.clickMove.enable")}
</p>
</TooltipContent>
</Tooltip>
)}
{(ptz?.presets?.length ?? 0) > 0 && (
<DropdownMenu modal={!isDesktop}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button aria-label={t("ptz.presets")}>
<BsThreeDotsVertical />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>{t("ptz.presets")}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
className="scrollbar-container max-h-[40dvh] overflow-y-auto"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{ptz?.presets.map((preset) => (
<DropdownMenuItem
key={preset}
aria-label={preset}
className="cursor-pointer"
onSelect={() => sendPtz(`preset_${preset}`)}
>
{preset}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
const { t } = useTranslation(["views/live", "views/events"]);
const rankMap = { all: 0, motion: 1, active_objects: 2 };
@ -1065,6 +745,8 @@ type FrigateCameraFeaturesProps = {
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
cameraEnabled: boolean;
debug: boolean;
setDebug: (debug: boolean) => void;
};
function FrigateCameraFeatures({
camera,
@ -1085,6 +767,8 @@ function FrigateCameraFeatures({
supportsAudioOutput,
supports2WayTalk,
cameraEnabled,
debug,
setDebug,
}: FrigateCameraFeaturesProps) {
const { t } = useTranslation(["views/live", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain();
@ -1196,10 +880,6 @@ function FrigateCameraFeatures({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// navigate for debug view
const navigate = useNavigate();
// desktop shows icons part of row
if (isDesktop || isTablet) {
return (
@ -1215,7 +895,7 @@ function FrigateCameraFeatures({
enabledState == "ON" ? t("camera.disable") : t("camera.enable")
}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={false}
disabled={debug}
/>
<CameraFeatureToggle
className="p-2 md:p-0"
@ -1321,9 +1001,8 @@ function FrigateCameraFeatures({
isActive={isRecording}
title={t("manualRecording." + (isRecording ? "stop" : "start"))}
onClick={handleEventButtonClick}
disabled={!cameraEnabled}
disabled={!cameraEnabled || debug}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<div
@ -1387,6 +1066,7 @@ function FrigateCameraFeatures({
</Label>
<Select
value={streamName}
disabled={debug}
onValueChange={(value) => {
setStreamName?.(value);
}}
@ -1416,48 +1096,60 @@ function FrigateCameraFeatures({
</SelectContent>
</Select>
{preferredLiveMode != "jsmpeg" && isRestreamed && (
{debug && (
<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>
</>
)}
<>
<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">
@ -1503,29 +1195,31 @@ function FrigateCameraFeatures({
</div>
)}
{preferredLiveMode == "jsmpeg" && 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" />
{preferredLiveMode == "jsmpeg" &&
!debug &&
isRestreamed && (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
{t("stream.lowBandwidth.tips")}
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
<p className="text-sm">
{t("stream.lowBandwidth.tips")}
</p>
</div>
</Button>
</div>
)}
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
)}
</div>
)}
{isRestreamed && (
@ -1540,6 +1234,7 @@ function FrigateCameraFeatures({
<Switch
className="ml-1"
id="backgroundplay"
disabled={debug}
checked={playInBackground}
onCheckedChange={(checked) =>
setPlayInBackground(checked)
@ -1564,6 +1259,7 @@ function FrigateCameraFeatures({
<Switch
className="ml-1"
id="showstats"
disabled={debug}
checked={showStats}
onCheckedChange={(checked) => setShowStats(checked)}
/>
@ -1574,17 +1270,22 @@ function FrigateCameraFeatures({
})}
</p>
</div>
<div
className="flex cursor-pointer flex-col gap-1"
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
}
>
<div className="flex items-center justify-between text-sm font-medium leading-none">
{t("streaming.debugView", {
ns: "components/dialog",
})}
<LuExternalLink className="ml-2 inline-flex size-5" />
<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>
@ -1724,6 +1425,7 @@ function FrigateCameraFeatures({
onValueChange={(value) => {
setStreamName?.(value);
}}
disabled={debug}
>
<SelectTrigger className="w-full">
<SelectValue>
@ -1749,7 +1451,17 @@ function FrigateCameraFeatures({
</SelectGroup>
</SelectContent>
</Select>
{preferredLiveMode != "jsmpeg" && isRestreamed && (
{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 ? (
<>
@ -1789,6 +1501,7 @@ function FrigateCameraFeatures({
</div>
)}
{preferredLiveMode != "jsmpeg" &&
!debug &&
isRestreamed &&
supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
@ -1835,7 +1548,6 @@ function FrigateCameraFeatures({
<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
@ -1843,6 +1555,7 @@ function FrigateCameraFeatures({
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
disabled={debug}
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
@ -1864,6 +1577,7 @@ function FrigateCameraFeatures({
"w-full",
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
)}
disabled={debug}
>
{t("manualRecording." + (isRecording ? "end" : "start"))}
</Button>
@ -1880,6 +1594,7 @@ function FrigateCameraFeatures({
onCheckedChange={(checked) => {
setPlayInBackground(checked);
}}
disabled={debug}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
{t("manualRecording.playInBackground.desc")}
@ -1892,6 +1607,7 @@ function FrigateCameraFeatures({
onCheckedChange={(checked) => {
setShowStats(checked);
}}
disabled={debug}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
{t("manualRecording.showStats.desc")}
@ -1899,16 +1615,12 @@ function FrigateCameraFeatures({
</div>
</>
)}
<div className="mb-3 flex flex-col gap-1 px-2">
<div className="flex items-center justify-between text-sm font-medium leading-none">
{t("manualRecording.debugView")}
<LuExternalLink
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
}
className="ml-2 inline-flex size-5 cursor-pointer"
/>
</div>
<div className="mb-3 flex flex-col">
<FilterSwitch
label={t("streaming.debugView", { ns: "components/dialog" })}
isChecked={debug}
onCheckedChange={(checked) => setDebug(checked)}
/>
</div>
</div>
</DrawerContent>