Improve live streaming (#16447)

* config file changes

* config migrator

* stream selection on single camera live view

* camera streaming settings dialog

* manage persistent group streaming settings

* apply streaming settings in camera groups

* add ability to clear all streaming settings from settings

* docs

* update reference config

* fixes

* clarify docs

* use first stream as default in dialog

* ensure still image is visible after switching stream type to none

* docs

* clarify docs

* add ability to continue playing stream in background

* fix props

* put stream selection inside dropdown on desktop

* add capabilities to live mode hook

* live context menu component

* resize observer: only return new dimensions if they've actually changed

* pass volume prop to players

* fix slider bug, https://github.com/shadcn-ui/ui/issues/1448

* update react-grid-layout

* prevent animated transitions on draggable grid layout

* add context menu to dashboards

* use provider

* streaming dialog from context menu

* docs

* add jsmpeg warning to context menu

* audio and two way talk indicators in single camera view

* add link to debug view

* don't use hook

* create manual events from live camera view

* maintain grow classes on grid items

* fix initial volume state on default dashboard

* fix pointer events causing context menu to end up underneath image on iOS

* mobile drawer tweaks

* stream stats

* show settings menu for non-restreamed cameras

* consistent settings icon

* tweaks

* optional stats to fix birdseye player

* add toaster to live camera view

* fix crash on initial save in streaming dialog

* don't require restreaming for context menu streaming settings

* add debug view to context menu

* stats fixes

* update docs

* always show stream info when restreamed

* update camera streaming dialog

* make note of no h265 support for webrtc

* docs clarity

* ensure docs show streams as a dict

* docs clarity

* fix css file

* tweaks
This commit is contained in:
Josh Hawkins
2025-02-10 09:42:35 -07:00
committed by GitHub
parent 2a28964e63
commit dd7820e4ee
31 changed files with 2681 additions and 219 deletions
@@ -40,9 +40,9 @@ export default function CameraFeatureToggle({
<div
onClick={onClick}
className={cn(
className,
"flex flex-col items-center justify-center",
variants[variant][isActive ? "active" : "inactive"],
className,
)}
>
<Icon
+103 -14
View File
@@ -1,4 +1,9 @@
import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
import {
AllGroupsStreamingSettings,
CameraGroupConfig,
FrigateConfig,
GroupStreamingSettings,
} from "@/types/frigateConfig";
import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
@@ -43,7 +48,6 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import axios from "axios";
import FilterSwitch from "./FilterSwitch";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import IconWrapper from "../ui/icon-wrapper";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -66,6 +70,11 @@ import {
MobilePageHeader,
MobilePageTitle,
} from "../mobile/MobilePage";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
type CameraGroupSelectorProps = {
className?: string;
@@ -607,6 +616,16 @@ export function CameraGroupEdit({
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
const [groupStreamingSettings, setGroupStreamingSettings] =
useState<GroupStreamingSettings>(
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
);
const [openCamera, setOpenCamera] = useState<string | null>();
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
const formSchema = z.object({
@@ -656,6 +675,16 @@ export function CameraGroupEdit({
setIsLoading(true);
// update streaming settings
const updatedSettings: AllGroupsStreamingSettings = {
...Object.fromEntries(
Object.entries(allGroupsStreamingSettings || {}).filter(
([key]) => key !== editingGroup?.[0],
),
),
[values.name]: groupStreamingSettings,
};
let renamingQuery = "";
if (editingGroup && editingGroup[0] !== values.name) {
renamingQuery = `camera_groups.${editingGroup[0]}&`;
@@ -679,7 +708,7 @@ export function CameraGroupEdit({
requires_restart: 0,
},
)
.then((res) => {
.then(async (res) => {
if (res.status === 200) {
toast.success(`Camera group (${values.name}) has been saved.`, {
position: "top-center",
@@ -688,6 +717,7 @@ export function CameraGroupEdit({
if (onSave) {
onSave();
}
setAllGroupsStreamingSettings(updatedSettings);
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
@@ -704,7 +734,16 @@ export function CameraGroupEdit({
setIsLoading(false);
});
},
[currentGroups, setIsLoading, onSave, updateConfig, editingGroup],
[
currentGroups,
setIsLoading,
onSave,
updateConfig,
editingGroup,
groupStreamingSettings,
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
],
);
const form = useForm<z.infer<typeof formSchema>>({
@@ -762,16 +801,66 @@ export function CameraGroupEdit({
),
].map((camera) => (
<FormControl key={camera}>
<FilterSwitch
isChecked={field.value && field.value.includes(camera)}
label={camera.replaceAll("_", " ")}
onCheckedChange={(checked) => {
const updatedCameras = checked
? [...(field.value || []), camera]
: (field.value || []).filter((c) => c !== camera);
form.setValue("cameras", updatedCameras);
}}
/>
<div className="flex items-center justify-between gap-1">
<Label
className="mx-2 w-full cursor-pointer capitalize text-primary"
htmlFor={camera.replaceAll("_", " ")}
>
{camera.replaceAll("_", " ")}
</Label>
<div className="flex items-center gap-x-2">
{camera !== "birdseye" && (
<Dialog
open={openCamera === camera}
onOpenChange={(isOpen) =>
setOpenCamera(isOpen ? camera : null)
}
>
<DialogTrigger asChild>
<Button
className="flex h-auto items-center gap-1"
aria-label="Camera streaming settings"
size="icon"
variant="ghost"
disabled={
!(field.value && field.value.includes(camera))
}
>
<LuIcons.LuSettings
className={cn(
field.value && field.value.includes(camera)
? "text-primary"
: "text-muted-foreground",
"size-5",
)}
/>
</Button>
</DialogTrigger>
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={
setGroupStreamingSettings
}
setIsDialogOpen={(isOpen) =>
setOpenCamera(isOpen ? camera : null)
}
/>
</Dialog>
)}
<Switch
id={camera.replaceAll("_", " ")}
checked={field.value && field.value.includes(camera)}
onCheckedChange={(checked) => {
const updatedCameras = checked
? [...(field.value || []), camera]
: (field.value || []).filter((c) => c !== camera);
form.setValue("cameras", updatedCameras);
}}
/>
</div>
</div>
</FormControl>
))}
</FormItem>
+302
View File
@@ -0,0 +1,302 @@
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
MdVolumeDown,
MdVolumeMute,
MdVolumeOff,
MdVolumeUp,
} from "react-icons/md";
import { Dialog } from "@/components/ui/dialog";
import { VolumeSlider } from "@/components/ui/slider";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import {
AllGroupsStreamingSettings,
GroupStreamingSettings,
} from "@/types/frigateConfig";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { IoIosWarning } from "react-icons/io";
import { cn } from "@/lib/utils";
import { useNavigate } from "react-router-dom";
type LiveContextMenuProps = {
className?: string;
camera: string;
streamName: string;
cameraGroup?: string;
preferredLiveMode: string;
isRestreamed: boolean;
supportsAudio: boolean;
audioState: boolean;
toggleAudio: () => void;
volumeState?: number;
setVolumeState: (volumeState: number) => void;
muteAll: () => void;
unmuteAll: () => void;
statsState: boolean;
toggleStats: () => void;
resetPreferredLiveMode: () => void;
children?: ReactNode;
};
export default function LiveContextMenu({
className,
camera,
streamName,
cameraGroup,
preferredLiveMode,
isRestreamed,
supportsAudio,
audioState,
toggleAudio,
volumeState,
setVolumeState,
muteAll,
unmuteAll,
statsState,
toggleStats,
resetPreferredLiveMode,
children,
}: LiveContextMenuProps) {
const [showSettings, setShowSettings] = useState(false);
// streaming settings
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
const [groupStreamingSettings, setGroupStreamingSettings] =
useState<GroupStreamingSettings>(
allGroupsStreamingSettings[cameraGroup ?? ""],
);
useEffect(() => {
if (cameraGroup) {
setGroupStreamingSettings(allGroupsStreamingSettings[cameraGroup]);
}
// set individual group when all groups changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allGroupsStreamingSettings]);
const onSave = useCallback(
(settings: GroupStreamingSettings) => {
if (!cameraGroup || !allGroupsStreamingSettings) {
return;
}
const updatedSettings: AllGroupsStreamingSettings = {
...Object.fromEntries(
Object.entries(allGroupsStreamingSettings || {}).filter(
([key]) => key !== cameraGroup,
),
),
[cameraGroup]: {
...Object.fromEntries(
Object.entries(settings).map(([cameraName, cameraSettings]) => [
cameraName,
cameraName === camera
? {
...cameraSettings,
playAudio: audioState ?? cameraSettings.playAudio ?? false,
volume: volumeState ?? cameraSettings.volume ?? 1,
}
: cameraSettings,
]),
),
// Add the current camera if it doesn't exist
...(!settings[camera]
? {
[camera]: {
streamName: streamName,
streamType: "smart",
compatibilityMode: false,
playAudio: audioState,
volume: volumeState ?? 1,
},
}
: {}),
},
};
setAllGroupsStreamingSettings?.(updatedSettings);
},
[
camera,
streamName,
cameraGroup,
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
audioState,
volumeState,
],
);
// ui
const audioControlsUsed = useRef(false);
const VolumeIcon = useMemo(() => {
if (!volumeState || volumeState == 0.0 || !audioState) {
return MdVolumeOff;
} else if (volumeState <= 0.33) {
return MdVolumeMute;
} else if (volumeState <= 0.67) {
return MdVolumeDown;
} else {
return MdVolumeUp;
}
// only update when specific fields change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeState, audioState]);
const handleVolumeIconClick = (e: React.MouseEvent) => {
e.stopPropagation();
audioControlsUsed.current = true;
toggleAudio();
};
const handleVolumeChange = (value: number[]) => {
audioControlsUsed.current = true;
setVolumeState(value[0]);
};
const handleOpenChange = (open: boolean) => {
if (!open && audioControlsUsed.current) {
onSave(groupStreamingSettings);
audioControlsUsed.current = false;
}
};
// navigate for debug view
const navigate = useNavigate();
return (
<div className={cn("w-full", className)}>
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
<div className="flex flex-col items-start gap-1 py-1 pl-2">
<div className="text-md capitalize text-primary-variant">
{camera.replaceAll("_", " ")}
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">Low-bandwidth mode</p>
</div>
)}
</div>
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
<>
<ContextMenuSeparator className="mb-1" />
<div className="p-2 text-sm">
<div className="flex w-full flex-col gap-1">
<p>Audio</p>
<div className="flex flex-row items-center gap-1">
<VolumeIcon
className="size-5"
onClick={handleVolumeIconClick}
/>
<VolumeSlider
disabled={!audioState}
className="my-3 ml-0.5 rounded-lg bg-background/60"
value={[volumeState ?? 0]}
min={0}
max={1}
step={0.02}
onValueChange={handleVolumeChange}
/>
</div>
</div>
</div>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={muteAll}
>
<div className="text-primary">Mute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={unmuteAll}
>
<div className="text-primary">Unmute All Cameras</div>
</div>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={toggleStats}
>
<div className="text-primary">
{statsState ? "Hide" : "Show"} Stream Stats
</div>
</div>
</ContextMenuItem>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => navigate(`/settings?page=debug&camera=${camera}`)}
>
<div className="text-primary">Debug View</div>
</div>
</ContextMenuItem>
{cameraGroup && cameraGroup !== "default" && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={() => setShowSettings(true)}
>
<div className="text-primary">Streaming Settings</div>
</div>
</ContextMenuItem>
</>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={resetPreferredLiveMode}
>
<div className="text-primary">Reset</div>
</div>
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
/>
</Dialog>
</div>
);
}
@@ -673,7 +673,8 @@ export function ObjectSnapshotTab({
</TransformComponent>
{search.data.type == "object" &&
search.plus_id !== "not_enabled" &&
search.end_time && (
search.end_time &&
search.label != "on_demand" && (
<Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className={cn("flex flex-col space-y-3")}>
@@ -58,6 +58,7 @@ export default function BirdseyeLivePlayer({
height={birdseyeConfig.height}
containerRef={containerRef}
playbackEnabled={true}
useWebGL={true}
/>
);
} else {
+59 -2
View File
@@ -1,6 +1,7 @@
import { baseUrl } from "@/api/baseUrl";
import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils";
import { PlayerStatsType } from "@/types/live";
// @ts-expect-error we know this doesn't have types
import JSMpeg from "@cycjimmy/jsmpeg-player";
import React, { useEffect, useMemo, useRef, useState } from "react";
@@ -12,6 +13,8 @@ type JSMpegPlayerProps = {
height: number;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
playbackEnabled: boolean;
useWebGL: boolean;
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
};
@@ -22,6 +25,8 @@ export default function JSMpegPlayer({
className,
containerRef,
playbackEnabled,
useWebGL = false,
setStats,
onPlaying,
}: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
@@ -33,6 +38,9 @@ export default function JSMpegPlayer({
const [hasData, setHasData] = useState(false);
const hasDataRef = useRef(hasData);
const [dimensionsReady, setDimensionsReady] = useState(false);
const bytesReceivedRef = useRef(0);
const lastTimestampRef = useRef(Date.now());
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null);
const selectedContainerRef = useMemo(
() => (containerRef.current ? containerRef : internalContainerRef),
@@ -111,6 +119,8 @@ export default function JSMpegPlayer({
const canvas = canvasRef.current;
let videoElement: JSMpeg.VideoElement | null = null;
let frameCount = 0;
setHasData(false);
if (videoWrapper && playbackEnabled) {
@@ -123,21 +133,68 @@ export default function JSMpegPlayer({
{
protocols: [],
audio: false,
disableGl: camera != "birdseye",
disableWebAssembly: camera != "birdseye",
disableGl: !useWebGL,
disableWebAssembly: !useWebGL,
videoBufferSize: 1024 * 1024 * 4,
onVideoDecode: () => {
if (!hasDataRef.current) {
setHasData(true);
onPlayingRef.current?.();
}
frameCount++;
},
},
);
// Set up WebSocket message handler
if (
videoElement.player &&
videoElement.player.source &&
videoElement.player.source.socket
) {
const socket = videoElement.player.source.socket;
socket.addEventListener("message", (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
bytesReceivedRef.current += event.data.byteLength;
}
});
}
// Update stats every second
statsIntervalRef.current = setInterval(() => {
const currentTimestamp = Date.now();
const timeDiff = (currentTimestamp - lastTimestampRef.current) / 1000; // in seconds
const bitrate = (bytesReceivedRef.current * 8) / timeDiff / 1000; // in kbps
setStats?.({
streamType: "jsmpeg",
bandwidth: Math.round(bitrate),
totalFrames: frameCount,
latency: undefined,
droppedFrames: undefined,
decodedFrames: undefined,
droppedFrameRate: undefined,
});
bytesReceivedRef.current = 0;
lastTimestampRef.current = currentTimestamp;
}, 1000);
return () => {
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current);
frameCount = 0;
statsIntervalRef.current = null;
}
};
}, 0);
return () => {
clearTimeout(initPlayer);
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current);
statsIntervalRef.current = null;
}
if (videoElement) {
try {
// this causes issues in react strict mode
+62 -4
View File
@@ -11,6 +11,7 @@ import { useCameraActivity } from "@/hooks/use-camera-activity";
import {
LivePlayerError,
LivePlayerMode,
PlayerStatsType,
VideoResolutionType,
} from "@/types/live";
import { getIconForLabel } from "@/utils/iconUtil";
@@ -20,20 +21,26 @@ import { cn } from "@/lib/utils";
import { TbExclamationCircle } from "react-icons/tb";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
className?: string;
cameraConfig: CameraConfig;
streamName: string;
preferredLiveMode: LivePlayerMode;
showStillWithoutActivity?: boolean;
useWebGL: boolean;
windowVisible?: boolean;
playAudio?: boolean;
volume?: number;
playInBackground: boolean;
micEnabled?: boolean; // only webrtc supports mic
iOSCompatFullScreen?: boolean;
pip?: boolean;
autoLive?: boolean;
showStats?: boolean;
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
@@ -45,14 +52,19 @@ export default function LivePlayer({
containerRef,
className,
cameraConfig,
streamName,
preferredLiveMode,
showStillWithoutActivity = true,
useWebGL = false,
windowVisible = true,
playAudio = false,
volume,
playInBackground = false,
micEnabled = false,
iOSCompatFullScreen = false,
pip,
autoLive = true,
showStats = false,
onClick,
setFullResolution,
onError,
@@ -60,6 +72,18 @@ export default function LivePlayer({
}: LivePlayerProps) {
const internalContainerRef = useRef<HTMLDivElement | null>(null);
// stats
const [stats, setStats] = useState<PlayerStatsType>({
streamType: "-",
bandwidth: 0, // in kbps
latency: undefined, // in seconds
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0, // percentage
});
// camera activity
const { activeMotion, activeTracking, objects, offline } =
@@ -144,6 +168,25 @@ export default function LivePlayer({
setLiveReady(false);
}, [preferredLiveMode]);
const [key, setKey] = useState(0);
const resetPlayer = () => {
setLiveReady(false);
setKey((prevKey) => prevKey + 1);
};
useEffect(() => {
if (streamName) {
resetPlayer();
}
}, [streamName]);
useEffect(() => {
if (showStillWithoutActivity && !autoLive) {
setLiveReady(false);
}
}, [showStillWithoutActivity, autoLive]);
const playerIsPlaying = useCallback(() => {
setLiveReady(true);
}, []);
@@ -153,15 +196,19 @@ export default function LivePlayer({
}
let player;
if (!autoLive) {
if (!autoLive || !streamName) {
player = null;
} else if (preferredLiveMode == "webrtc") {
player = (
<WebRtcPlayer
key={"webrtc_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.live.stream_name}
camera={streamName}
playbackEnabled={cameraActive || liveReady}
getStats={showStats}
setStats={setStats}
audioEnabled={playAudio}
volume={volume}
microphoneEnabled={micEnabled}
iOSCompatFullScreen={iOSCompatFullScreen}
onPlaying={playerIsPlaying}
@@ -173,10 +220,15 @@ export default function LivePlayer({
if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = (
<MSEPlayer
key={"mse_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.live.stream_name}
camera={streamName}
playbackEnabled={cameraActive || liveReady}
audioEnabled={playAudio}
volume={volume}
playInBackground={playInBackground}
getStats={showStats}
setStats={setStats}
onPlaying={playerIsPlaying}
pip={pip}
setFullResolution={setFullResolution}
@@ -194,6 +246,7 @@ export default function LivePlayer({
if (cameraActive || !showStillWithoutActivity || liveReady) {
player = (
<JSMpegPlayer
key={"jsmpeg_" + key}
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
camera={cameraConfig.name}
width={cameraConfig.detect.width}
@@ -201,6 +254,8 @@ export default function LivePlayer({
playbackEnabled={
cameraActive || !showStillWithoutActivity || liveReady
}
useWebGL={useWebGL}
setStats={setStats}
containerRef={containerRef ?? internalContainerRef}
onPlaying={playerIsPlaying}
/>
@@ -293,7 +348,7 @@ export default function LivePlayer({
)}
>
<AutoUpdatingCameraImage
className="size-full"
className="pointer-events-none size-full"
cameraClasses="relative size-full flex justify-center"
camera={cameraConfig.name}
showFps={false}
@@ -331,6 +386,9 @@ export default function LivePlayer({
</Chip>
)}
</div>
{showStats && (
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
)}
</div>
);
}
+101 -4
View File
@@ -1,5 +1,9 @@
import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError, VideoResolutionType } from "@/types/live";
import {
LivePlayerError,
PlayerStatsType,
VideoResolutionType,
} from "@/types/live";
import {
SetStateAction,
useCallback,
@@ -15,7 +19,11 @@ type MSEPlayerProps = {
className?: string;
playbackEnabled?: boolean;
audioEnabled?: boolean;
volume?: number;
playInBackground?: boolean;
pip?: boolean;
getStats?: boolean;
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
@@ -26,7 +34,11 @@ function MSEPlayer({
className,
playbackEnabled = true,
audioEnabled = false,
volume,
playInBackground = false,
pip = false,
getStats = false,
setStats,
onPlaying,
setFullResolution,
onError,
@@ -57,6 +69,7 @@ function MSEPlayer({
const [connectTS, setConnectTS] = useState<number>(0);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0);
const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -316,6 +329,8 @@ function MSEPlayer({
let bufLen = 0;
ondataRef.current = (data) => {
totalBytesLoaded.current += data.byteLength;
if (sb?.updating || bufLen > 0) {
const b = new Uint8Array(data);
buf.set(b, bufLen);
@@ -508,12 +523,22 @@ function MSEPlayer({
}
};
document.addEventListener("visibilitychange", listener);
if (!playInBackground) {
document.addEventListener("visibilitychange", listener);
}
return () => {
document.removeEventListener("visibilitychange", listener);
if (!playInBackground) {
document.removeEventListener("visibilitychange", listener);
}
};
}, [playbackEnabled, visibilityCheck, onConnect, onDisconnect]);
}, [
playbackEnabled,
visibilityCheck,
playInBackground,
onConnect,
onDisconnect,
]);
// control pip
@@ -525,6 +550,16 @@ function MSEPlayer({
videoRef.current.requestPictureInPicture();
}, [pip, videoRef]);
// control volume
useEffect(() => {
if (!videoRef.current || volume == undefined) {
return;
}
videoRef.current.volume = volume;
}, [volume, videoRef]);
// ensure we disconnect for slower connections
useEffect(() => {
@@ -542,6 +577,68 @@ function MSEPlayer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled]);
// stats
useEffect(() => {
const video = videoRef.current;
let lastLoadedBytes = totalBytesLoaded.current;
let lastTimestamp = Date.now();
if (!getStats) return;
const updateStats = () => {
if (video) {
const now = Date.now();
const bytesLoaded = totalBytesLoaded.current;
const timeElapsed = (now - lastTimestamp) / 1000; // seconds
const bandwidth = (bytesLoaded - lastLoadedBytes) / timeElapsed / 1024; // kbps
lastLoadedBytes = bytesLoaded;
lastTimestamp = now;
const latency =
video.seekable.length > 0
? Math.max(
0,
video.seekable.end(video.seekable.length - 1) -
video.currentTime,
)
: 0;
const videoQuality = video.getVideoPlaybackQuality();
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
const droppedFrameRate = totalVideoFrames
? (droppedVideoFrames / totalVideoFrames) * 100
: 0;
setStats?.({
streamType: "MSE",
bandwidth,
latency,
totalFrames: totalVideoFrames,
droppedFrames: droppedVideoFrames || undefined,
decodedFrames: totalVideoFrames - droppedVideoFrames,
droppedFrameRate,
});
}
};
const interval = setInterval(updateStats, 1000); // Update every second
return () => {
clearInterval(interval);
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
};
}, [setStats, getStats]);
return (
<video
ref={videoRef}
+100
View File
@@ -0,0 +1,100 @@
import { cn } from "@/lib/utils";
import { PlayerStatsType } from "@/types/live";
type PlayerStatsProps = {
stats: PlayerStatsType;
minimal: boolean;
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const fullStatsContent = (
<>
<p>
<span className="text-white/70">Stream Type:</span>{" "}
<span className="text-white">{stats.streamType}</span>
</p>
<p>
<span className="text-white/70">Bandwidth:</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
</p>
{stats.latency != undefined && (
<p>
<span className="text-white/70">Latency:</span>{" "}
<span
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
>
{stats.latency.toFixed(2)} seconds
</span>
</p>
)}
<p>
<span className="text-white/70">Total Frames:</span>{" "}
<span className="text-white">{stats.totalFrames}</span>
</p>
{stats.droppedFrames != undefined && (
<p>
<span className="text-white/70">Dropped Frames:</span>{" "}
<span className="text-white">{stats.droppedFrames}</span>
</p>
)}
{stats.decodedFrames != undefined && (
<p>
<span className="text-white/70">Decoded Frames:</span>{" "}
<span className="text-white">{stats.decodedFrames}</span>
</p>
)}
{stats.droppedFrameRate != undefined && (
<p>
<span className="text-white/70">Dropped Frame Rate:</span>{" "}
<span className="text-white">
{stats.droppedFrameRate.toFixed(2)}%
</span>
</p>
)}
</>
);
const minimalStatsContent = (
<div className="flex flex-row items-center justify-center gap-4">
<div className="flex flex-col items-center justify-start gap-1">
<span className="text-white/70">Type</span>
<span className="text-white">{stats.streamType}</span>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-white/70">Bandwidth</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
</div>
{stats.latency != undefined && (
<div className="hidden flex-col items-center gap-1 md:flex">
<span className="text-white/70">Latency</span>
<span
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
>
{stats.latency.toFixed(2)} sec
</span>
</div>
)}
{stats.droppedFrames != undefined && (
<div className="flex flex-col items-center justify-end gap-1">
<span className="text-white/70">Dropped</span>
<span className="text-white">{stats.droppedFrames} frames</span>
</div>
)}
</div>
);
return (
<>
<div
className={cn(
minimal
? "absolute bottom-0 left-0 max-h-[50%] w-full overflow-y-auto rounded-b-lg p-1 md:rounded-b-xl md:p-3"
: "absolute bottom-2 right-2 min-w-52 rounded-2xl p-4",
"z-50 flex flex-col gap-1 bg-black/70 text-[9px] duration-300 animate-in fade-in md:text-xs",
)}
>
{minimal ? minimalStatsContent : fullStatsContent}
</div>
</>
);
}
+86 -1
View File
@@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError } from "@/types/live";
import { LivePlayerError, PlayerStatsType } from "@/types/live";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type WebRtcPlayerProps = {
@@ -7,9 +7,12 @@ type WebRtcPlayerProps = {
camera: string;
playbackEnabled?: boolean;
audioEnabled?: boolean;
volume?: number;
microphoneEnabled?: boolean;
iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element
pip?: boolean;
getStats?: boolean;
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
};
@@ -19,9 +22,12 @@ export default function WebRtcPlayer({
camera,
playbackEnabled = true,
audioEnabled = false,
volume,
microphoneEnabled = false,
iOSCompatFullScreen = false,
pip = false,
getStats = false,
setStats,
onPlaying,
onError,
}: WebRtcPlayerProps) {
@@ -194,6 +200,16 @@ export default function WebRtcPlayer({
videoRef.current.requestPictureInPicture();
}, [pip, videoRef]);
// control volume
useEffect(() => {
if (!videoRef.current || volume == undefined) {
return;
}
videoRef.current.volume = volume;
}, [volume, videoRef]);
useEffect(() => {
videoLoadTimeoutRef.current = setTimeout(() => {
onError?.("stalled");
@@ -215,6 +231,75 @@ export default function WebRtcPlayer({
onPlaying?.();
};
// stats
useEffect(() => {
if (!pcRef.current || !getStats) return;
let lastBytesReceived = 0;
let lastTimestamp = 0;
const interval = setInterval(async () => {
if (pcRef.current && videoRef.current && !videoRef.current.paused) {
const report = await pcRef.current.getStats();
let bytesReceived = 0;
let timestamp = 0;
let roundTripTime = 0;
let framesReceived = 0;
let framesDropped = 0;
let framesDecoded = 0;
report.forEach((stat) => {
if (stat.type === "inbound-rtp" && stat.kind === "video") {
bytesReceived = stat.bytesReceived;
timestamp = stat.timestamp;
framesReceived = stat.framesReceived;
framesDropped = stat.framesDropped;
framesDecoded = stat.framesDecoded;
}
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
roundTripTime = stat.currentRoundTripTime;
}
});
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
const bitrate =
timeDiff > 0
? (bytesReceived - lastBytesReceived) / timeDiff / 1000
: 0; // in kbps
setStats?.({
streamType: "WebRTC",
bandwidth: Math.round(bitrate),
latency: roundTripTime,
totalFrames: framesReceived,
droppedFrames: framesDropped,
decodedFrames: framesDecoded,
droppedFrameRate:
framesReceived > 0 ? (framesDropped / framesReceived) * 100 : 0,
});
lastBytesReceived = bytesReceived;
lastTimestamp = timestamp;
}
}, 1000);
return () => {
clearInterval(interval);
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
droppedFrameRate: 0,
});
};
// we need to listen on the value of the ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pcRef, pcRef.current, getStats]);
return (
<video
ref={videoRef}
@@ -0,0 +1,371 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import { IoIosWarning } from "react-icons/io";
import { Button } from "@/components/ui/button";
import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
FrigateConfig,
GroupStreamingSettings,
StreamType,
} from "@/types/frigateConfig";
import ActivityIndicator from "../indicators/activity-indicator";
import useSWR from "swr";
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
import { LiveStreamMetadata } from "@/types/live";
type CameraStreamingDialogProps = {
camera: string;
groupStreamingSettings: GroupStreamingSettings;
setGroupStreamingSettings: React.Dispatch<
React.SetStateAction<GroupStreamingSettings>
>;
setIsDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: (settings: GroupStreamingSettings) => void;
};
export function CameraStreamingDialog({
camera,
groupStreamingSettings,
setGroupStreamingSettings,
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [isLoading, setIsLoading] = useState(false);
const [streamName, setStreamName] = useState(
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] || "",
);
const [streamType, setStreamType] = useState<StreamType>("smart");
const [compatibilityMode, setCompatibilityMode] = useState(false);
// metadata
const isRestreamed = useMemo(
() =>
config &&
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
[config, streamName],
);
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
},
);
const supportsAudioOutput = useMemo(() => {
if (!cameraMetadata) {
return false;
}
return (
cameraMetadata.producers.find(
(prod) =>
prod.medias &&
prod.medias.find((media) => media.includes("audio, recvonly")) !=
undefined,
) != undefined
);
}, [cameraMetadata]);
// handlers
useEffect(() => {
if (!config) {
return;
}
if (groupStreamingSettings && groupStreamingSettings[camera]) {
const cameraSettings = groupStreamingSettings[camera];
setStreamName(cameraSettings.streamName || "");
setStreamType(cameraSettings.streamType || "smart");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] ||
"",
);
setStreamType("smart");
setCompatibilityMode(false);
}
}, [groupStreamingSettings, camera, config]);
const handleSave = useCallback(() => {
setIsLoading(true);
const updatedSettings = {
...groupStreamingSettings,
[camera]: {
streamName,
streamType,
compatibilityMode,
playAudio: groupStreamingSettings?.[camera]?.playAudio ?? false,
volume: groupStreamingSettings?.[camera]?.volume ?? 1,
},
};
setGroupStreamingSettings(updatedSettings);
setIsDialogOpen(false);
setIsLoading(false);
onSave?.(updatedSettings);
}, [
groupStreamingSettings,
setGroupStreamingSettings,
camera,
streamName,
streamType,
compatibilityMode,
setIsDialogOpen,
onSave,
]);
const handleCancel = useCallback(() => {
if (!config) {
return;
}
if (groupStreamingSettings && groupStreamingSettings[camera]) {
const cameraSettings = groupStreamingSettings[camera];
setStreamName(cameraSettings.streamName || "");
setStreamType(cameraSettings.streamType || "smart");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] ||
"",
);
setStreamType("smart");
setCompatibilityMode(false);
}
setIsDialogOpen(false);
}, [groupStreamingSettings, camera, config, setIsDialogOpen]);
if (!config) {
return null;
}
return (
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="mb-4">
<DialogTitle className="capitalize">
{camera.replaceAll("_", " ")} Streaming Settings
</DialogTitle>
<DialogDescription>
Change the live streaming options for this camera group's dashboard.{" "}
<em>These settings are device/browser-specific.</em>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-8">
{!isRestreamed && (
<div className="flex flex-col gap-2">
<Label>Stream</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio for
this camera.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
{isRestreamed &&
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
<div className="flex flex-col items-start gap-2">
<Label htmlFor="stream" className="text-right">
Stream
</Label>
<Select value={streamName} onValueChange={setStreamName}>
<SelectTrigger className="">
<SelectValue placeholder="Choose a stream" />
</SelectTrigger>
<SelectContent>
{camera !== "birdseye" &&
Object.entries(config?.cameras[camera].live.streams).map(
([name, stream]) => (
<SelectItem key={stream} value={stream}>
{name}
</SelectItem>
),
)}
</SelectContent>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Audio must be output from your camera and configured
in go2rtc for this stream.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
</Select>
</div>
)}
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-method" className="text-right">
Streaming Method
</Label>
<Select
value={streamType}
onValueChange={(value) => setStreamType(value as StreamType)}
>
<SelectTrigger className="">
<SelectValue placeholder="Choose a streaming option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="no-streaming">No Streaming</SelectItem>
<SelectItem value="smart">
Smart Streaming (recommended)
</SelectItem>
<SelectItem value="continuous">Continuous Streaming</SelectItem>
</SelectContent>
</Select>
{streamType === "no-streaming" && (
<p className="text-sm text-muted-foreground">
Camera images will only update once per minute and no live
streaming will occur.
</p>
)}
{streamType === "smart" && (
<p className="text-sm text-muted-foreground">
Smart streaming will update your camera image once per minute when
no detectable activity is occurring to conserve bandwidth and
resources. When activity is detected, the image seamlessly
switches to a live stream.
</p>
)}
{streamType === "continuous" && (
<>
<p className="text-sm text-muted-foreground">
Camera image will always be a live stream when visible on the
dashboard, even if no activity is being detected.
</p>
<div className="flex items-center gap-2">
<IoIosWarning className="mr-2 size-5 text-danger" />
<div className="max-w-[85%] text-sm">
Continuous streaming may cause high bandwidth usage and
performance issues. Use with caution.
</div>
</div>
</>
)}
</div>
<div className="flex flex-col items-start gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="compatibility"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={compatibilityMode}
onCheckedChange={() => setCompatibilityMode(!compatibilityMode)}
/>
<Label
htmlFor="compatibility"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Compatibility mode
</Label>
</div>
<div className="flex flex-col gap-2 leading-none">
<p className="text-sm text-muted-foreground">
Enable this option only if your camera's live stream is displaying
color artifacts and has a diagonal line on the right side of the
image.
</p>
</div>
</div>
</div>
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Cancel"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="select"
aria-label="Save"
disabled={isLoading}
className="flex flex-1"
onClick={handleSave}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</DialogContent>
);
}
+6 -6
View File
@@ -18,7 +18,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full cursor-pointer border-2 border-primary bg-primary ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-primary ring-offset-background transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
@@ -36,9 +36,9 @@ const VolumeSlider = React.forwardRef<
{...props}
>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-white" />
<SliderPrimitive.Range className="absolute h-full bg-primary data-[disabled]:opacity-20" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full bg-white ring-white focus:ring-white disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full bg-primary ring-primary data-[disabled]:pointer-events-none data-[disabled]:bg-muted focus:ring-primary" />
</SliderPrimitive.Root>
));
VolumeSlider.displayName = SliderPrimitive.Root.displayName;
@@ -58,7 +58,7 @@ const NoThumbSlider = React.forwardRef<
<SliderPrimitive.Track className="relative h-full w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" />
<SliderPrimitive.Thumb className="block h-4 w-16 -translate-y-[50%] cursor-col-resize rounded-full bg-transparent ring-offset-transparent data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus-visible:outline-none focus-visible:ring-transparent" />
</SliderPrimitive.Root>
));
NoThumbSlider.displayName = SliderPrimitive.Root.displayName;
@@ -78,8 +78,8 @@ const DualThumbSlider = React.forwardRef<
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-selected/60">
<SliderPrimitive.Range className="absolute h-full bg-selected" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block size-3 cursor-col-resize rounded-full bg-selected transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50" />
<SliderPrimitive.Thumb className="block size-3 cursor-col-resize rounded-full bg-selected transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50" />
</SliderPrimitive.Root>
));
DualThumbSlider.displayName = SliderPrimitive.Root.displayName;
+6 -1
View File
@@ -5,6 +5,7 @@ import { ApiProvider } from "@/api";
import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
import { StreamingSettingsProvider } from "./streaming-settings-provider";
type TProvidersProps = {
children: ReactNode;
@@ -17,7 +18,11 @@ function providers({ children }: TProvidersProps) {
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<TooltipProvider>
<IconContext.Provider value={{ size: "20" }}>
<StatusBarMessagesProvider>{children}</StatusBarMessagesProvider>
<StatusBarMessagesProvider>
<StreamingSettingsProvider>
{children}
</StreamingSettingsProvider>
</StatusBarMessagesProvider>
</IconContext.Provider>
</TooltipProvider>
</ThemeProvider>
@@ -0,0 +1,68 @@
import {
createContext,
useState,
useEffect,
ReactNode,
useContext,
} from "react";
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
import { usePersistence } from "@/hooks/use-persistence";
type StreamingSettingsContextType = {
allGroupsStreamingSettings: AllGroupsStreamingSettings;
setAllGroupsStreamingSettings: (settings: AllGroupsStreamingSettings) => void;
isPersistedStreamingSettingsLoaded: boolean;
};
const StreamingSettingsContext =
createContext<StreamingSettingsContextType | null>(null);
export function StreamingSettingsProvider({
children,
}: {
children: ReactNode;
}) {
const [allGroupsStreamingSettings, setAllGroupsStreamingSettings] =
useState<AllGroupsStreamingSettings>({});
const [
persistedGroupStreamingSettings,
setPersistedGroupStreamingSettings,
isPersistedStreamingSettingsLoaded,
] = usePersistence<AllGroupsStreamingSettings>("streaming-settings");
useEffect(() => {
if (isPersistedStreamingSettingsLoaded) {
setAllGroupsStreamingSettings(persistedGroupStreamingSettings ?? {});
}
}, [isPersistedStreamingSettingsLoaded, persistedGroupStreamingSettings]);
useEffect(() => {
if (Object.keys(allGroupsStreamingSettings).length) {
setPersistedGroupStreamingSettings(allGroupsStreamingSettings);
}
}, [allGroupsStreamingSettings, setPersistedGroupStreamingSettings]);
return (
<StreamingSettingsContext.Provider
value={{
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
isPersistedStreamingSettingsLoaded,
}}
>
{children}
</StreamingSettingsContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useStreamingSettings() {
const context = useContext(StreamingSettingsContext);
if (!context) {
throw new Error(
"useStreamingSettings must be used within a StreamingSettingsProvider",
);
}
return context;
}
+9 -1
View File
@@ -17,7 +17,15 @@ export function useResizeObserver(...refs: RefType[]) {
() =>
new ResizeObserver((entries) => {
window.requestAnimationFrame(() => {
setDimensions(entries.map((entry) => entry.contentRect));
setDimensions((prevDimensions) => {
const newDimensions = entries.map((entry) => entry.contentRect);
if (
JSON.stringify(prevDimensions) !== JSON.stringify(newDimensions)
) {
return newDimensions;
}
return prevDimensions;
});
});
}),
[],
+68 -19
View File
@@ -1,16 +1,29 @@
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useState } from "react";
import useSWR from "swr";
import { LivePlayerMode } from "@/types/live";
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
export default function useCameraLiveMode(
cameras: CameraConfig[],
windowVisible: boolean,
) {
const { data: config } = useSWR<FrigateConfig>("config");
const { data: allStreamMetadata } = useSWR<{
[key: string]: LiveStreamMetadata;
}>(config ? "go2rtc/streams" : null, { revalidateOnFocus: false });
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
}>({});
const [isRestreamedStates, setIsRestreamedStates] = useState<{
[key: string]: boolean;
}>({});
const [supportsAudioOutputStates, setSupportsAudioOutputStates] = useState<{
[key: string]: {
supportsAudio: boolean;
cameraName: string;
};
}>({});
useEffect(() => {
if (!cameras) return;
@@ -18,26 +31,56 @@ export default function useCameraLiveMode(
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
const newPreferredLiveModes = cameras.reduce(
(acc, camera) => {
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(
camera.live.stream_name,
);
const newPreferredLiveModes: { [key: string]: LivePlayerMode } = {};
const newIsRestreamedStates: { [key: string]: boolean } = {};
const newSupportsAudioOutputStates: {
[key: string]: { supportsAudio: boolean; cameraName: string };
} = {};
if (!mseSupported) {
acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
return acc;
},
{} as { [key: string]: LivePlayerMode },
);
cameras.forEach((camera) => {
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(
Object.values(camera.live.streams)[0],
);
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
if (!mseSupported) {
newPreferredLiveModes[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
// check each stream for audio support
if (isRestreamed) {
Object.values(camera.live.streams).forEach((streamName) => {
const metadata = allStreamMetadata?.[streamName];
newSupportsAudioOutputStates[streamName] = {
supportsAudio: metadata
? metadata.producers.find(
(prod) =>
prod.medias &&
prod.medias.find((media) =>
media.includes("audio, recvonly"),
) !== undefined,
) !== undefined
: false,
cameraName: camera.name,
};
});
} else {
newSupportsAudioOutputStates[camera.name] = {
supportsAudio: false,
cameraName: camera.name,
};
}
});
setPreferredLiveModes(newPreferredLiveModes);
}, [cameras, config, windowVisible]);
setIsRestreamedStates(newIsRestreamedStates);
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
}, [cameras, config, windowVisible, allStreamMetadata]);
const resetPreferredLiveMode = useCallback(
(cameraName: string) => {
@@ -61,5 +104,11 @@ export default function useCameraLiveMode(
[config],
);
return { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode };
return {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
};
}
+5
View File
@@ -180,6 +180,11 @@ html {
opacity: 0.5 !important;
}
.react-grid-layout,
.react-grid-layout .react-grid-item {
transition: none !important;
}
.react-lazylog,
.react-lazylog-searchbar {
background-color: transparent !important;
+16
View File
@@ -37,6 +37,7 @@ import AuthenticationView from "@/views/settings/AuthenticationView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
import SearchSettingsView from "@/views/settings/SearchSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
const allSettingsViews = [
"UI settings",
@@ -119,6 +120,21 @@ export default function Settings() {
}
}, [tabsRef, pageToggle]);
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
setPage(page as SettingsType);
}
return true;
});
useSearchEffect("camera", (camera: string) => {
const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
}
return true;
});
useEffect(() => {
document.title = "Settings - Frigate";
}, []);
+27 -7
View File
@@ -87,7 +87,7 @@ export interface CameraConfig {
live: {
height: number;
quality: number;
stream_name: string;
streams: { [key: string]: string };
};
motion: {
contour_area: number;
@@ -175,10 +175,18 @@ export interface CameraConfig {
alerts: {
required_zones: string[];
labels: string[];
retain: {
days: number;
mode: string;
};
};
detections: {
required_zones: string[];
labels: string[];
retain: {
days: number;
mode: string;
};
};
};
rtmp: {
@@ -230,6 +238,24 @@ export type CameraGroupConfig = {
order: number;
};
export type StreamType = "no-streaming" | "smart" | "continuous";
export type CameraStreamingSettings = {
streamName: string;
streamType: StreamType;
compatibilityMode: boolean;
playAudio: boolean;
volume: number;
};
export type GroupStreamingSettings = {
[cameraName: string]: CameraStreamingSettings;
};
export type AllGroupsStreamingSettings = {
[groupName: string]: GroupStreamingSettings;
};
export interface FrigateConfig {
audio: {
enabled: boolean;
@@ -326,12 +352,6 @@ export interface FrigateConfig {
camera_groups: { [groupName: string]: CameraGroupConfig };
live: {
height: number;
quality: number;
stream_name: string;
};
logger: {
default: string;
logs: Record<string, string>;
+14
View File
@@ -32,3 +32,17 @@ export type LiveStreamMetadata = {
};
export type LivePlayerError = "stalled" | "startup" | "mse-decode";
export type AudioState = Record<string, boolean>;
export type StatsState = Record<string, boolean>;
export type VolumeState = Record<string, number>;
export type PlayerStatsType = {
streamType: string;
bandwidth: number;
latency: number | undefined;
totalFrames: number;
droppedFrames: number | undefined;
decodedFrames: number | undefined;
droppedFrameRate: number | undefined;
};
+257 -61
View File
@@ -1,5 +1,6 @@
import { usePersistence } from "@/hooks/use-persistence";
import {
AllGroupsStreamingSettings,
BirdseyeConfig,
CameraConfig,
FrigateConfig,
@@ -20,7 +21,12 @@ import {
} from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import { LivePlayerError, LivePlayerMode } from "@/types/live";
import {
AudioState,
LivePlayerMode,
StatsState,
VolumeState,
} from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Skeleton } from "@/components/ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer";
@@ -42,6 +48,8 @@ import {
} from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
type DraggableGridLayoutProps = {
cameras: CameraConfig[];
@@ -76,8 +84,26 @@ export default function DraggableGridLayout({
// preferred live modes per camera
const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
useCameraLiveMode(cameras, windowVisible);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const [globalAutoLive] = usePersistence("autoLiveView", true);
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
const currentGroupStreamingSettings = useMemo(() => {
if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) {
return allGroupsStreamingSettings[cameraGroup];
}
}, [allGroupsStreamingSettings, cameraGroup]);
// grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
@@ -342,6 +368,105 @@ export default function DraggableGridLayout({
placeholder.h = layoutItem.h;
};
// audio and stats states
const [audioStates, setAudioStates] = useState<AudioState>({});
const [volumeStates, setVolumeStates] = useState<VolumeState>({});
const [statsStates, setStatsStates] = useState<StatsState>(() => {
const initialStates: StatsState = {};
cameras.forEach((camera) => {
initialStates[camera.name] = false;
});
return initialStates;
});
const toggleStats = (cameraName: string): void => {
setStatsStates((prev) => ({
...prev,
[cameraName]: !prev[cameraName],
}));
};
useEffect(() => {
if (!allGroupsStreamingSettings) {
return;
}
const initialAudioStates: AudioState = {};
const initialVolumeStates: VolumeState = {};
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
});
});
setAudioStates(initialAudioStates);
setVolumeStates(initialVolumeStates);
}, [allGroupsStreamingSettings]);
const toggleAudio = (cameraName: string) => {
setAudioStates((prev) => ({
...prev,
[cameraName]: !prev[cameraName],
}));
};
const onSaveMuting = useCallback(
(playAudio: boolean) => {
if (!cameraGroup || !allGroupsStreamingSettings) {
return;
}
const existingGroupSettings =
allGroupsStreamingSettings[cameraGroup] || {};
const updatedSettings: AllGroupsStreamingSettings = {
...Object.fromEntries(
Object.entries(allGroupsStreamingSettings || {}).filter(
([key]) => key !== cameraGroup,
),
),
[cameraGroup]: {
...existingGroupSettings,
...Object.fromEntries(
Object.entries(existingGroupSettings).map(
([cameraName, settings]) => [
cameraName,
{
...settings,
playAudio: playAudio,
},
],
),
),
},
};
setAllGroupsStreamingSettings?.(updatedSettings);
},
[cameraGroup, allGroupsStreamingSettings, setAllGroupsStreamingSettings],
);
const muteAll = () => {
const updatedStates: AudioState = {};
cameras.forEach((camera) => {
updatedStates[camera.name] = false;
});
setAudioStates(updatedStates);
onSaveMuting(false);
};
const unmuteAll = () => {
const updatedStates: AudioState = {};
cameras.forEach((camera) => {
updatedStates[camera.name] = true;
});
setAudioStates(updatedStates);
onSaveMuting(true);
};
return (
<>
<Toaster position="top-center" closeButton={true} />
@@ -364,7 +489,7 @@ export default function DraggableGridLayout({
</div>
) : (
<div
className="no-scrollbar my-2 overflow-x-hidden px-2 pb-8"
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
ref={gridContainerRef}
>
<EditGroupDialog
@@ -420,40 +545,87 @@ export default function DraggableGridLayout({
} else {
grow = "aspect-video";
}
const streamName =
currentGroupStreamingSettings?.[camera.name]?.streamName ||
Object.values(camera.live.streams)[0];
const autoLive =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"no-streaming";
const showStillWithoutActivity =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"continuous";
const useWebGL =
currentGroupStreamingSettings?.[camera.name]
?.compatibilityMode || false;
return (
<LivePlayerGridItem
<GridLiveContextMenu
className={grow}
key={camera.name}
cameraRef={cameraRef}
className={cn(
"rounded-lg bg-black md:rounded-2xl",
grow,
isEditMode &&
showCircles &&
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
camera={camera.name}
streamName={streamName}
cameraGroup={cameraGroup}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
onClick={() => {
!isEditMode && onSelectCamera(camera.name);
}}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[streamName].supportsAudio
}
audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]}
setVolumeState={(value) =>
setVolumeStates({
[camera.name]: value,
})
}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
>
<LivePlayer
key={camera.name}
streamName={streamName}
autoLive={autoLive ?? globalAutoLive}
showStillWithoutActivity={showStillWithoutActivity ?? true}
useWebGL={useWebGL}
cameraRef={cameraRef}
className={cn(
"rounded-lg bg-black md:rounded-2xl",
grow,
isEditMode &&
showCircles &&
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
playInBackground={false}
showStats={statsStates[camera.name]}
onClick={() => {
!isEditMode && onSelectCamera(camera.name);
}}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]}
/>
{isEditMode && showCircles && <CornerCircles />}
</LivePlayerGridItem>
</GridLiveContextMenu>
);
})}
</ResponsiveGridLayout>
@@ -596,41 +768,57 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
},
);
type LivePlayerGridItemProps = {
type GridLiveContextMenuProps = {
className?: string;
style?: React.CSSProperties;
className: string;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
children?: React.ReactNode;
cameraRef: (node: HTMLElement | null) => void;
windowVisible: boolean;
cameraConfig: CameraConfig;
preferredLiveMode: LivePlayerMode;
onClick: () => void;
onError: (e: LivePlayerError) => void;
onResetLiveMode: () => void;
camera: string;
streamName: string;
cameraGroup: string;
preferredLiveMode: string;
isRestreamed: boolean;
supportsAudio: boolean;
audioState: boolean;
toggleAudio: () => void;
statsState: boolean;
toggleStats: () => void;
volumeState?: number;
setVolumeState: (volumeState: number) => void;
muteAll: () => void;
unmuteAll: () => void;
resetPreferredLiveMode: () => void;
};
const LivePlayerGridItem = React.forwardRef<
const GridLiveContextMenu = React.forwardRef<
HTMLDivElement,
LivePlayerGridItemProps
GridLiveContextMenuProps
>(
(
{
style,
className,
style,
onMouseDown,
onMouseUp,
onTouchEnd,
children,
cameraRef,
windowVisible,
cameraConfig,
camera,
streamName,
cameraGroup,
preferredLiveMode,
onClick,
onError,
onResetLiveMode,
isRestreamed,
supportsAudio,
audioState,
toggleAudio,
statsState,
toggleStats,
volumeState,
setVolumeState,
muteAll,
unmuteAll,
resetPreferredLiveMode,
...props
},
ref,
@@ -644,18 +832,26 @@ const LivePlayerGridItem = React.forwardRef<
onTouchEnd={onTouchEnd}
{...props}
>
<LivePlayer
cameraRef={cameraRef}
<LiveContextMenu
className={className}
windowVisible={windowVisible}
cameraConfig={cameraConfig}
camera={camera}
streamName={streamName}
cameraGroup={cameraGroup}
preferredLiveMode={preferredLiveMode}
onClick={onClick}
onError={onError}
onResetLiveMode={onResetLiveMode}
containerRef={ref as React.RefObject<HTMLDivElement>}
/>
{children}
isRestreamed={isRestreamed}
supportsAudio={supportsAudio}
audioState={audioState}
toggleAudio={toggleAudio}
statsState={statsState}
toggleStats={toggleStats}
volumeState={volumeState}
setVolumeState={setVolumeState}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={resetPreferredLiveMode}
>
{children}
</LiveContextMenu>
</div>
);
},
+737 -46
View File
@@ -17,6 +17,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
@@ -62,29 +67,52 @@ import {
FaMicrophoneSlash,
} from "react-icons/fa";
import { GiSpeaker, GiSpeakerOff } from "react-icons/gi";
import { TbViewfinder, TbViewfinderOff } from "react-icons/tb";
import { IoMdArrowRoundBack } from "react-icons/io";
import {
TbRecordMail,
TbRecordMailOff,
TbViewfinder,
TbViewfinderOff,
} from "react-icons/tb";
import { IoIosWarning, IoMdArrowRoundBack } from "react-icons/io";
import {
LuCheck,
LuEar,
LuEarOff,
LuExternalLink,
LuHistory,
LuInfo,
LuPictureInPicture,
LuVideo,
LuVideoOff,
LuX,
} from "react-icons/lu";
import {
MdNoPhotography,
MdOutlineRestartAlt,
MdPersonOff,
MdPersonSearch,
MdPhotoCamera,
MdZoomIn,
MdZoomOut,
} from "react-icons/md";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import useSWR from "swr";
import { cn } from "@/lib/utils";
import { useSessionPersistence } from "@/hooks/use-session-persistence";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { usePersistence } from "@/hooks/use-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";
type LiveCameraViewProps = {
config?: FrigateConfig;
@@ -109,17 +137,20 @@ export default function LiveCameraView({
// supported features
const [streamName, setStreamName] = usePersistence<string>(
`${camera.name}-stream`,
Object.values(camera.live.streams)[0],
);
const isRestreamed = useMemo(
() =>
config &&
Object.keys(config.go2rtc.streams || {}).includes(
camera.live.stream_name,
),
[camera, config],
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
[config, streamName],
);
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${camera.live.stream_name}` : null,
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
},
@@ -209,6 +240,13 @@ export default function LiveCameraView({
const [pip, setPip] = useState(false);
const [lowBandwidth, setLowBandwidth] = useState(false);
const [playInBackground, setPlayInBackground] = usePersistence<boolean>(
`${camera.name}-background-play`,
false,
);
const [showStats, setShowStats] = useState(false);
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
width: 0,
height: 0,
@@ -337,6 +375,7 @@ export default function LiveCameraView({
return (
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
<Toaster position="top-center" closeButton={true} />
<div
ref={mainRef}
className={
@@ -460,13 +499,24 @@ export default function LiveCameraView({
/>
)}
<FrigateCameraFeatures
camera={camera.name}
camera={camera}
recordingEnabled={camera.record.enabled_in_config}
audioDetectEnabled={camera.audio.enabled_in_config}
autotrackingEnabled={
camera.onvif.autotracking.enabled_in_config
}
fullscreen={fullscreen}
streamName={streamName ?? ""}
setStreamName={setStreamName}
preferredLiveMode={preferredLiveMode}
playInBackground={playInBackground ?? false}
setPlayInBackground={setPlayInBackground}
showStats={showStats}
setShowStats={setShowStats}
isRestreamed={isRestreamed ?? false}
setLowBandwidth={setLowBandwidth}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
/>
</div>
</TooltipProvider>
@@ -499,9 +549,13 @@ export default function LiveCameraView({
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}
@@ -816,12 +870,49 @@ function PtzControlPanel({
);
}
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
const rankMap = { all: 0, motion: 1, active_objects: 2 };
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
const mode = retain?.mode;
return mode && mode in rankMap ? (mode as keyof typeof rankMap) : "all";
};
const recordRetainMode = getValidMode(camera.record.retain);
const alertsRetainMode = getValidMode(camera.review.alerts.retain);
const effectiveRetainMode =
rankMap[alertsRetainMode] < rankMap[recordRetainMode]
? recordRetainMode
: alertsRetainMode;
const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts";
return effectiveRetainMode !== "all" ? (
<div>
Your {source} recording retention configuration is set to{" "}
<code>mode: {effectiveRetainMode}</code>, so this on-demand recording will
only keep segments with {effectiveRetainMode.replaceAll("_", " ")}.
</div>
) : null;
}
type FrigateCameraFeaturesProps = {
camera: string;
camera: CameraConfig;
recordingEnabled: boolean;
audioDetectEnabled: boolean;
autotrackingEnabled: boolean;
fullscreen: boolean;
streamName: string;
setStreamName?: (value: string | undefined) => void;
preferredLiveMode: string;
playInBackground: boolean;
setPlayInBackground: (value: boolean | undefined) => void;
showStats: boolean;
setShowStats: (value: boolean) => void;
isRestreamed: boolean;
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
};
function FrigateCameraFeatures({
camera,
@@ -829,14 +920,124 @@ function FrigateCameraFeatures({
audioDetectEnabled,
autotrackingEnabled,
fullscreen,
streamName,
setStreamName,
preferredLiveMode,
playInBackground,
setPlayInBackground,
showStats,
setShowStats,
isRestreamed,
setLowBandwidth,
supportsAudioOutput,
supports2WayTalk,
}: FrigateCameraFeaturesProps) {
const { payload: detectState, send: sendDetect } = useDetectState(camera);
const { payload: recordState, send: sendRecord } = useRecordingsState(camera);
const { payload: snapshotState, send: sendSnapshot } =
useSnapshotsState(camera);
const { payload: audioState, send: sendAudio } = useAudioState(camera);
const { payload: detectState, send: sendDetect } = useDetectState(
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);
useAutotrackingState(camera.name);
// 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">
Started manual on-demand recording.
</div>
{!camera.record.enabled || camera.record.retain.days == 0 ? (
<div>
Since recording is disabled or restricted in the config for this
camera, only a snapshot will be saved.
</div>
) : (
<OnDemandRetentionMessage camera={camera} />
)}
</div>,
{
position: "top-center",
duration: 10000,
},
);
setActiveToastId(toastId);
}
} catch (error) {
toast.error("Failed to start manual on-demand recording.", {
position: "top-center",
});
}
}, [camera]);
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("Ended manual on-demand recording.", {
position: "top-center",
});
}
} catch (error) {
toast.error("Failed to end manual on-demand recording.", {
position: "top-center",
});
}
}, [activeToastId]);
const handleEventButtonClick = useCallback(() => {
if (isRecording) {
endEvent();
} else {
createEvent();
}
}, [createEvent, endEvent, isRecording]);
useEffect(() => {
// ensure manual event is stopped when component unmounts
return () => {
if (recordingEventIdRef.current) {
endEvent();
}
};
// mount/unmount only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// navigate for debug view
const navigate = useNavigate();
// desktop shows icons part of row
if (isDesktop || isTablet) {
@@ -888,6 +1089,264 @@ function FrigateCameraFeatures({
}
/>
)}
<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={`${isRecording ? "Stop" : "Start"} on-demand recording`}
onClick={handleEventButtonClick}
/>
<DropdownMenu modal={false}>
<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>Stream</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio
for this camera.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<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">Stream</Label>
<Select
value={streamName}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(camera.live.streams).map(
([stream, name]) => (
<SelectItem
key={stream}
className="cursor-pointer"
value={name}
>
{stream}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
{preferredLiveMode != "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Audio must be output from your camera and
configured in go2rtc for this stream.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode != "jsmpeg" &&
isRestreamed &&
supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>
Two-way talk is available for this stream
</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>
Two-way talk is unavailable for this stream
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Your device must suppport the feature and
WebRTC must be configured for two-way talk.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</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" />
<p className="text-sm">
Live view is in low-bandwidth mode due to buffering
or stream errors.
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Reset the stream"
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
Reset stream
</div>
</Button>
</div>
)}
</div>
)}
{isRestreamed && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Label
className="mx-0 cursor-pointer text-primary"
htmlFor="backgroundplay"
>
Play in background
</Label>
<Switch
className="ml-1"
id="backgroundplay"
checked={playInBackground}
onCheckedChange={(checked) =>
setPlayInBackground(checked)
}
/>
</div>
<p className="text-sm text-muted-foreground">
Enable this option to continue streaming when the player is
hidden.
</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"
>
Show stream stats
</Label>
<Switch
className="ml-1"
id="showstats"
checked={showStats}
onCheckedChange={(checked) => setShowStats(checked)}
/>
</div>
<p className="text-sm text-muted-foreground">
Enable this option to show stream statistics as an overlay on
the camera feed.
</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm font-medium leading-none">
Debug View
<LuExternalLink
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
}
className="ml-2 inline-flex size-5 cursor-pointer"
/>
</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}
@@ -908,44 +1367,276 @@ function FrigateCameraFeatures({
title={`${camera} Settings`}
/>
</DrawerTrigger>
<DrawerContent className="flex flex-col gap-3 rounded-2xl px-2 py-4">
<FilterSwitch
label="Object Detection"
isChecked={detectState == "ON"}
onCheckedChange={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
/>
{recordingEnabled && (
<DrawerContent className="rounded-2xl px-2 py-4">
<div className="mt-2 flex flex-col gap-2">
<FilterSwitch
label="Recording"
isChecked={recordState == "ON"}
label="Object Detection"
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
sendDetect(detectState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch
label="Snapshots"
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
{audioDetectEnabled && (
{recordingEnabled && (
<FilterSwitch
label="Recording"
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch
label="Audio Detection"
isChecked={audioState == "ON"}
onCheckedChange={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
/>
)}
{autotrackingEnabled && (
<FilterSwitch
label="Autotracking"
isChecked={autotrackingState == "ON"}
label="Snapshots"
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
}
/>
)}
{audioDetectEnabled && (
<FilterSwitch
label="Audio Detection"
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
}
/>
)}
{autotrackingEnabled && (
<FilterSwitch
label="Autotracking"
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
/>
)}
</div>
<div className="mt-3 flex flex-col gap-5">
{!isRestreamed && (
<div className="flex flex-col gap-2 p-2">
<Label>Stream</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio for
this camera.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<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">Stream</div>
<Select
value={streamName}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(camera.live.streams).map(
([stream, name]) => (
<SelectItem
key={stream}
className="cursor-pointer"
value={name}
>
{stream}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
{preferredLiveMode != "jsmpeg" && 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>Audio is available for this stream</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-52 text-xs">
Audio must be output from your camera and configured
in go2rtc for this stream.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode != "jsmpeg" &&
isRestreamed &&
supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>Two-way talk is available for this stream</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Two-way talk is unavailable for this stream</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-52 text-xs">
Your device must suppport the feature and WebRTC
must be configured for two-way talk.
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="mt-2 flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
Live view is in low-bandwidth mode due to buffering or
stream errors.
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Reset the stream"
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">Reset stream</div>
</Button>
</div>
)}
</div>
)}
<div className="flex flex-col gap-1 px-2">
<div className="mb-1 text-sm font-medium leading-none">
On-Demand Recording
</div>
<Button
onClick={handleEventButtonClick}
className={cn(
"w-full",
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
)}
>
{isRecording ? "End" : "Start"} on-demand recording
</Button>
<p className="text-sm text-muted-foreground">
Start a manual event based on this camera's recording retention
settings.
</p>
</div>
{isRestreamed && (
<>
<div className="flex flex-col gap-2">
<FilterSwitch
label="Play in Background"
isChecked={playInBackground}
onCheckedChange={(checked) => {
setPlayInBackground(checked);
}}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
Enable this option to continue streaming when the player is
hidden.
</p>
</div>
<div className="flex flex-col gap-2">
<FilterSwitch
label="Show Stats"
isChecked={showStats}
onCheckedChange={(checked) => {
setShowStats(checked);
}}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
Enable this option to show stream statistics as an overlay on
the camera feed.
</p>
</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">
Debug View
<LuExternalLink
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
}
className="ml-2 inline-flex size-5 cursor-pointer"
/>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
);
+99 -16
View File
@@ -28,10 +28,16 @@ import DraggableGridLayout from "./DraggableGridLayout";
import { IoClose } from "react-icons/io5";
import { LuLayoutDashboard } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { LivePlayerError } from "@/types/live";
import {
AudioState,
LivePlayerError,
StatsState,
VolumeState,
} from "@/types/live";
import { FaCompress, FaExpand } from "react-icons/fa";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { useResizeObserver } from "@/hooks/resize-observer";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
type LiveDashboardViewProps = {
cameras: CameraConfig[];
@@ -184,8 +190,13 @@ export default function LiveDashboardView({
};
}, []);
const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
useCameraLiveMode(cameras, windowVisible);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const cameraRef = useCallback(
(node: HTMLElement | null) => {
@@ -221,9 +232,45 @@ export default function LiveDashboardView({
[setPreferredLiveModes],
);
// audio states
const [audioStates, setAudioStates] = useState<AudioState>({});
const [volumeStates, setVolumeStates] = useState<VolumeState>({});
const [statsStates, setStatsStates] = useState<StatsState>({});
const toggleStats = (cameraName: string): void => {
setStatsStates((prev) => ({
...prev,
[cameraName]: !prev[cameraName],
}));
};
const toggleAudio = (cameraName: string): void => {
setAudioStates((prev) => ({
...prev,
[cameraName]: !prev[cameraName],
}));
};
const muteAll = (): void => {
const updatedStates: Record<string, boolean> = {};
visibleCameras.forEach((cameraName) => {
updatedStates[cameraName] = false;
});
setAudioStates(updatedStates);
};
const unmuteAll = (): void => {
const updatedStates: Record<string, boolean> = {};
visibleCameras.forEach((cameraName) => {
updatedStates[cameraName] = true;
});
setAudioStates(updatedStates);
};
return (
<div
className="scrollbar-container size-full overflow-y-auto px-1 pt-2 md:p-2"
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
ref={containerRef}
>
{isMobile && (
@@ -346,20 +393,56 @@ export default function LiveDashboardView({
grow = "aspect-video";
}
return (
<LivePlayer
cameraRef={cameraRef}
<LiveContextMenu
className={grow}
key={camera.name}
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
camera={camera.name}
cameraGroup={cameraGroup}
streamName={Object.values(camera.live.streams)?.[0]}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
autoLive={autoLiveView}
onClick={() => onSelectCamera(camera.name)}
onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
/>
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[
Object.values(camera.live.streams)?.[0]
]?.supportsAudio ?? false
}
audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name] ?? 1}
setVolumeState={(value) =>
setVolumeStates({
[camera.name]: value,
})
}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
>
<LivePlayer
cameraRef={cameraRef}
key={camera.name}
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
autoLive={autoLiveView}
useWebGL={false}
playInBackground={false}
showStats={statsStates[camera.name]}
streamName={Object.values(camera.live.streams)[0]}
onClick={() => onSelectCamera(camera.name)}
onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name] ?? false}
volume={volumeStates[camera.name]}
/>
</LiveContextMenu>
);
})}
</div>
+46 -5
View File
@@ -46,6 +46,25 @@ export default function UiSettingsView() {
});
}, [config]);
const clearStreamingSettings = useCallback(async () => {
if (!config) {
return [];
}
await delData(`streaming-settings`)
.then(() => {
toast.success(`Cleared streaming settings for all camera groups.`, {
position: "top-center",
});
})
.catch((error) => {
toast.error(
`Failed to clear camera groups streaming settings: ${error.response.data.message}`,
{ position: "top-center" },
);
});
}, [config]);
useEffect(() => {
document.title = "General Settings - Frigate";
}, []);
@@ -84,11 +103,15 @@ export default function UiSettingsView() {
Automatic Live View
</Label>
</div>
<div className="my-2 text-sm text-muted-foreground">
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
Automatically switch to a camera's live view when activity is
detected. Disabling this option causes static camera images on
the Live dashboard to only update once per minute.
the your dashboards to only update once per minute.{" "}
<em>
This is a global setting but can be overridden on each
camera <strong>in camera groups only</strong>.
</em>
</p>
</div>
</div>
@@ -103,7 +126,7 @@ export default function UiSettingsView() {
Play Alert Videos
</Label>
</div>
<div className="my-2 text-sm text-muted-foreground">
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
By default, recent alerts on the Live dashboard play as small
looping videos. Disable this option to only show a static
@@ -114,10 +137,10 @@ export default function UiSettingsView() {
</div>
<div className="my-3 flex w-full flex-col space-y-6">
<div className="mt-2 space-y-6">
<div className="mt-2 space-y-3">
<div className="space-y-0.5">
<div className="text-md">Stored Layouts</div>
<div className="my-2 text-sm text-muted-foreground">
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
The layout of cameras in a camera group can be
dragged/resized. The positions are stored in your browser's
@@ -133,6 +156,24 @@ export default function UiSettingsView() {
</Button>
</div>
<div className="mt-2 space-y-3">
<div className="space-y-0.5">
<div className="text-md">Camera Group Streaming Settings</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
Streaming settings for each camera group are stored in your
browser's local storage.
</p>
</div>
</div>
<Button
aria-label="Clear all group streaming settings"
onClick={clearStreamingSettings}
>
Clear All Streaming Settings
</Button>
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">