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
This commit is contained in:
Nicolas Mowen 2025-09-29 17:45:55 -06:00 committed by GitHub
parent 9fdce80729
commit a08fda62f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 572 additions and 481 deletions

View File

@ -124,6 +124,9 @@
"available": "Audio is available for this stream", "available": "Audio is available for this stream",
"unavailable": "Audio is not 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": { "twoWayTalk": {
"tips": "Your device must support the feature and WebRTC must be configured for two-way talk.", "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", "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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@ -25,11 +24,6 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
@ -38,10 +32,8 @@ import {
LiveStreamMetadata, LiveStreamMetadata,
VideoResolutionType, VideoResolutionType,
} from "@/types/live"; } from "@/types/live";
import { CameraPtzInfo } from "@/types/ptz";
import { RecordingStartingPoint } from "@/types/record"; import { RecordingStartingPoint } from "@/types/record";
import React, { import React, {
ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@ -56,12 +48,7 @@ import {
isTablet, isTablet,
useMobileOrientation, useMobileOrientation,
} from "react-device-detect"; } from "react-device-detect";
import { BsThreeDotsVertical } from "react-icons/bs";
import { import {
FaAngleDown,
FaAngleLeft,
FaAngleRight,
FaAngleUp,
FaCog, FaCog,
FaCompress, FaCompress,
FaExpand, FaExpand,
@ -91,8 +78,6 @@ import {
LuX, LuX,
} from "react-icons/lu"; } from "react-icons/lu";
import { import {
MdCenterFocusStrong,
MdCenterFocusWeak,
MdClosedCaption, MdClosedCaption,
MdClosedCaptionDisabled, MdClosedCaptionDisabled,
MdNoPhotography, MdNoPhotography,
@ -100,8 +85,6 @@ import {
MdPersonOff, MdPersonOff,
MdPersonSearch, MdPersonSearch,
MdPhotoCamera, MdPhotoCamera,
MdZoomIn,
MdZoomOut,
} from "react-icons/md"; } from "react-icons/md";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; 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 { useIsAdmin } from "@/hooks/use-is-admin";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import PtzControlPanel from "@/components/overlay/PtzControlPanel";
import ObjectSettingsView from "../settings/ObjectSettingsView";
type LiveCameraViewProps = { type LiveCameraViewProps = {
config?: FrigateConfig; config?: FrigateConfig;
@ -287,6 +272,7 @@ export default function LiveCameraView({
); );
const [showStats, setShowStats] = useState(false); const [showStats, setShowStats] = useState(false);
const [debug, setDebug] = useState(false);
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({ const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
width: 0, width: 0,
@ -437,7 +423,11 @@ export default function LiveCameraView({
); );
return ( 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} /> <Toaster position="top-center" closeButton={true} />
<div <div
ref={mainRef} ref={mainRef}
@ -523,6 +513,7 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"} variant={fullscreen ? "overlay" : "primary"}
Icon={fullscreen ? FaCompress : FaExpand} Icon={fullscreen ? FaCompress : FaExpand}
isActive={fullscreen} isActive={fullscreen}
disabled={debug}
title={ title={
fullscreen fullscreen
? t("button.close", { ns: "common" }) ? t("button.close", { ns: "common" })
@ -550,7 +541,7 @@ export default function LiveCameraView({
setPip(false); setPip(false);
} }
}} }}
disabled={!cameraEnabled} disabled={!cameraEnabled || debug}
/> />
)} )}
{supports2WayTalk && ( {supports2WayTalk && (
@ -570,7 +561,7 @@ export default function LiveCameraView({
setAudio(true); setAudio(true);
} }
}} }}
disabled={!cameraEnabled} disabled={!cameraEnabled || debug}
/> />
)} )}
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && ( {supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
@ -585,7 +576,7 @@ export default function LiveCameraView({
: t("cameraAudio.enable", { ns: "views/live" }) : t("cameraAudio.enable", { ns: "views/live" })
} }
onClick={() => setAudio(!audio)} onClick={() => setAudio(!audio)}
disabled={!cameraEnabled} disabled={!cameraEnabled || debug}
/> />
)} )}
<FrigateCameraFeatures <FrigateCameraFeatures
@ -609,10 +600,66 @@ export default function LiveCameraView({
supportsAudioOutput={supportsAudioOutput} supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk} supports2WayTalk={supports2WayTalk}
cameraEnabled={cameraEnabled} cameraEnabled={cameraEnabled}
debug={debug}
setDebug={setDebug}
/> />
</div> </div>
</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 <TransformComponent
wrapperStyle={{ wrapperStyle={{
width: "100%", width: "100%",
@ -622,53 +669,16 @@ export default function LiveCameraView({
position: "relative", position: "relative",
width: "100%", width: "100%",
height: "100%", height: "100%",
padding: "8px",
}} }}
> >
<div <ObjectSettingsView selectedCamera={camera.name} />
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> </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> </div>
{camera.onvif.host != "" && ( {camera.onvif.host != "" && (
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<PtzControlPanel <PtzControlPanel
className={debug && isMobile ? "bottom-auto top-[25%]" : ""}
camera={camera.name} camera={camera.name}
enabled={cameraEnabled} enabled={cameraEnabled}
clickOverlay={clickOverlay} 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 }) { function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
const { t } = useTranslation(["views/live", "views/events"]); const { t } = useTranslation(["views/live", "views/events"]);
const rankMap = { all: 0, motion: 1, active_objects: 2 }; const rankMap = { all: 0, motion: 1, active_objects: 2 };
@ -1065,6 +745,8 @@ type FrigateCameraFeaturesProps = {
supportsAudioOutput: boolean; supportsAudioOutput: boolean;
supports2WayTalk: boolean; supports2WayTalk: boolean;
cameraEnabled: boolean; cameraEnabled: boolean;
debug: boolean;
setDebug: (debug: boolean) => void;
}; };
function FrigateCameraFeatures({ function FrigateCameraFeatures({
camera, camera,
@ -1085,6 +767,8 @@ function FrigateCameraFeatures({
supportsAudioOutput, supportsAudioOutput,
supports2WayTalk, supports2WayTalk,
cameraEnabled, cameraEnabled,
debug,
setDebug,
}: FrigateCameraFeaturesProps) { }: FrigateCameraFeaturesProps) {
const { t } = useTranslation(["views/live", "components/dialog"]); const { t } = useTranslation(["views/live", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
@ -1196,10 +880,6 @@ function FrigateCameraFeatures({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// navigate for debug view
const navigate = useNavigate();
// desktop shows icons part of row // desktop shows icons part of row
if (isDesktop || isTablet) { if (isDesktop || isTablet) {
return ( return (
@ -1215,7 +895,7 @@ function FrigateCameraFeatures({
enabledState == "ON" ? t("camera.disable") : t("camera.enable") enabledState == "ON" ? t("camera.disable") : t("camera.enable")
} }
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")} onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={false} disabled={debug}
/> />
<CameraFeatureToggle <CameraFeatureToggle
className="p-2 md:p-0" className="p-2 md:p-0"
@ -1321,9 +1001,8 @@ function FrigateCameraFeatures({
isActive={isRecording} isActive={isRecording}
title={t("manualRecording." + (isRecording ? "stop" : "start"))} title={t("manualRecording." + (isRecording ? "stop" : "start"))}
onClick={handleEventButtonClick} onClick={handleEventButtonClick}
disabled={!cameraEnabled} disabled={!cameraEnabled || debug}
/> />
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<div <div
@ -1387,6 +1066,7 @@ function FrigateCameraFeatures({
</Label> </Label>
<Select <Select
value={streamName} value={streamName}
disabled={debug}
onValueChange={(value) => { onValueChange={(value) => {
setStreamName?.(value); setStreamName?.(value);
}} }}
@ -1416,48 +1096,60 @@ function FrigateCameraFeatures({
</SelectContent> </SelectContent>
</Select> </Select>
{preferredLiveMode != "jsmpeg" && isRestreamed && ( {debug && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground"> <div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? ( <>
<> <LuX className="size-8 text-danger" />
<LuCheck className="size-4 text-success" /> <div>{t("stream.debug.picker")}</div>
<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> </div>
)} )}
{preferredLiveMode != "jsmpeg" && {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 && isRestreamed &&
supportsAudioOutput && ( supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground"> <div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
@ -1503,29 +1195,31 @@ function FrigateCameraFeatures({
</div> </div>
)} )}
{preferredLiveMode == "jsmpeg" && isRestreamed && ( {preferredLiveMode == "jsmpeg" &&
<div className="flex flex-col items-center gap-3"> !debug &&
<div className="flex flex-row items-center gap-2"> isRestreamed && (
<IoIosWarning className="mr-1 size-8 text-danger" /> <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"> <p className="text-sm">
{t("stream.lowBandwidth.tips")} {t("stream.lowBandwidth.tips")}
</p> </p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div> </div>
</Button> <Button
</div> 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> </div>
)} )}
{isRestreamed && ( {isRestreamed && (
@ -1540,6 +1234,7 @@ function FrigateCameraFeatures({
<Switch <Switch
className="ml-1" className="ml-1"
id="backgroundplay" id="backgroundplay"
disabled={debug}
checked={playInBackground} checked={playInBackground}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setPlayInBackground(checked) setPlayInBackground(checked)
@ -1564,6 +1259,7 @@ function FrigateCameraFeatures({
<Switch <Switch
className="ml-1" className="ml-1"
id="showstats" id="showstats"
disabled={debug}
checked={showStats} checked={showStats}
onCheckedChange={(checked) => setShowStats(checked)} onCheckedChange={(checked) => setShowStats(checked)}
/> />
@ -1574,17 +1270,22 @@ function FrigateCameraFeatures({
})} })}
</p> </p>
</div> </div>
<div <div className="flex flex-col gap-1">
className="flex cursor-pointer flex-col gap-1" <div className="flex items-center justify-between">
onClick={() => <Label
navigate(`/settings?page=debug&camera=${camera.name}`) className="mx-0 cursor-pointer text-primary"
} htmlFor="debug"
> >
<div className="flex items-center justify-between text-sm font-medium leading-none"> {t("streaming.debugView", {
{t("streaming.debugView", { ns: "components/dialog",
ns: "components/dialog", })}
})} </Label>
<LuExternalLink className="ml-2 inline-flex size-5" /> <Switch
className="ml-1"
id="debug"
checked={debug}
onCheckedChange={(checked) => setDebug(checked)}
/>
</div> </div>
</div> </div>
</div> </div>
@ -1724,6 +1425,7 @@ function FrigateCameraFeatures({
onValueChange={(value) => { onValueChange={(value) => {
setStreamName?.(value); setStreamName?.(value);
}} }}
disabled={debug}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue> <SelectValue>
@ -1749,7 +1451,17 @@ function FrigateCameraFeatures({
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </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"> <div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? ( {supportsAudioOutput ? (
<> <>
@ -1789,6 +1501,7 @@ function FrigateCameraFeatures({
</div> </div>
)} )}
{preferredLiveMode != "jsmpeg" && {preferredLiveMode != "jsmpeg" &&
!debug &&
isRestreamed && isRestreamed &&
supportsAudioOutput && ( supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground"> <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="mt-2 flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" /> <IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">{t("stream.lowBandwidth.tips")}</p> <p className="text-sm">{t("stream.lowBandwidth.tips")}</p>
</div> </div>
<Button <Button
@ -1843,6 +1555,7 @@ function FrigateCameraFeatures({
aria-label={t("stream.lowBandwidth.resetStream")} aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline" variant="outline"
size="sm" size="sm"
disabled={debug}
onClick={() => setLowBandwidth(false)} onClick={() => setLowBandwidth(false)}
> >
<MdOutlineRestartAlt className="size-5 text-primary-variant" /> <MdOutlineRestartAlt className="size-5 text-primary-variant" />
@ -1864,6 +1577,7 @@ function FrigateCameraFeatures({
"w-full", "w-full",
isRecording && "animate-pulse bg-red-500 hover:bg-red-600", isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
)} )}
disabled={debug}
> >
{t("manualRecording." + (isRecording ? "end" : "start"))} {t("manualRecording." + (isRecording ? "end" : "start"))}
</Button> </Button>
@ -1880,6 +1594,7 @@ function FrigateCameraFeatures({
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setPlayInBackground(checked); setPlayInBackground(checked);
}} }}
disabled={debug}
/> />
<p className="mx-2 -mt-2 text-sm text-muted-foreground"> <p className="mx-2 -mt-2 text-sm text-muted-foreground">
{t("manualRecording.playInBackground.desc")} {t("manualRecording.playInBackground.desc")}
@ -1892,6 +1607,7 @@ function FrigateCameraFeatures({
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setShowStats(checked); setShowStats(checked);
}} }}
disabled={debug}
/> />
<p className="mx-2 -mt-2 text-sm text-muted-foreground"> <p className="mx-2 -mt-2 text-sm text-muted-foreground">
{t("manualRecording.showStats.desc")} {t("manualRecording.showStats.desc")}
@ -1899,16 +1615,12 @@ function FrigateCameraFeatures({
</div> </div>
</> </>
)} )}
<div className="mb-3 flex flex-col gap-1 px-2"> <div className="mb-3 flex flex-col">
<div className="flex items-center justify-between text-sm font-medium leading-none"> <FilterSwitch
{t("manualRecording.debugView")} label={t("streaming.debugView", { ns: "components/dialog" })}
<LuExternalLink isChecked={debug}
onClick={() => onCheckedChange={(checked) => setDebug(checked)}
navigate(`/settings?page=debug&camera=${camera.name}`) />
}
className="ml-2 inline-flex size-5 cursor-pointer"
/>
</div>
</div> </div>
</div> </div>
</DrawerContent> </DrawerContent>