mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-19 01:17:06 +03:00
stream stats
This commit is contained in:
parent
c3a0ba0865
commit
af039f349d
@ -44,6 +44,8 @@ type LiveContextMenuProps = {
|
||||
setVolumeState: (volumeState: number) => void;
|
||||
muteAll: () => void;
|
||||
unmuteAll: () => void;
|
||||
statsState: boolean;
|
||||
toggleStats: () => void;
|
||||
resetPreferredLiveMode: () => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
@ -61,6 +63,8 @@ export default function LiveContextMenu({
|
||||
setVolumeState,
|
||||
muteAll,
|
||||
unmuteAll,
|
||||
statsState,
|
||||
toggleStats,
|
||||
resetPreferredLiveMode,
|
||||
children,
|
||||
}: LiveContextMenuProps) {
|
||||
@ -231,6 +235,17 @@ export default function LiveContextMenu({
|
||||
<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>
|
||||
{isRestreamed && cameraGroup && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@ -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";
|
||||
@ -13,6 +14,7 @@ type JSMpegPlayerProps = {
|
||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
playbackEnabled: boolean;
|
||||
useWebGL: boolean;
|
||||
setStats: (stats: PlayerStatsType) => void;
|
||||
onPlaying?: () => void;
|
||||
};
|
||||
|
||||
@ -24,6 +26,7 @@ export default function JSMpegPlayer({
|
||||
containerRef,
|
||||
playbackEnabled,
|
||||
useWebGL = false,
|
||||
setStats,
|
||||
onPlaying,
|
||||
}: JSMpegPlayerProps) {
|
||||
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
|
||||
@ -35,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),
|
||||
@ -113,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) {
|
||||
@ -133,13 +141,60 @@ export default function JSMpegPlayer({
|
||||
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;
|
||||
frameCount = 0;
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (statsIntervalRef.current) {
|
||||
clearInterval(statsIntervalRef.current);
|
||||
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
|
||||
|
||||
@ -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,6 +21,7 @@ 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;
|
||||
@ -38,6 +40,7 @@ type LivePlayerProps = {
|
||||
iOSCompatFullScreen?: boolean;
|
||||
pip?: boolean;
|
||||
autoLive?: boolean;
|
||||
showStats?: boolean;
|
||||
onClick?: () => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
@ -61,6 +64,7 @@ export default function LivePlayer({
|
||||
iOSCompatFullScreen = false,
|
||||
pip,
|
||||
autoLive = true,
|
||||
showStats = false,
|
||||
onClick,
|
||||
setFullResolution,
|
||||
onError,
|
||||
@ -68,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 } =
|
||||
@ -189,6 +205,8 @@ export default function LivePlayer({
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
getStats={showStats}
|
||||
setStats={setStats}
|
||||
audioEnabled={playAudio}
|
||||
volume={volume}
|
||||
microphoneEnabled={micEnabled}
|
||||
@ -209,6 +227,8 @@ export default function LivePlayer({
|
||||
audioEnabled={playAudio}
|
||||
volume={volume}
|
||||
playInBackground={playInBackground}
|
||||
getStats={showStats}
|
||||
setStats={setStats}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
@ -235,6 +255,7 @@ export default function LivePlayer({
|
||||
cameraActive || !showStillWithoutActivity || liveReady
|
||||
}
|
||||
useWebGL={useWebGL}
|
||||
setStats={setStats}
|
||||
containerRef={containerRef ?? internalContainerRef}
|
||||
onPlaying={playerIsPlaying}
|
||||
/>
|
||||
@ -364,6 +385,9 @@ export default function LivePlayer({
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
{showStats && (
|
||||
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
@ -18,6 +22,8 @@ type MSEPlayerProps = {
|
||||
volume?: number;
|
||||
playInBackground?: boolean;
|
||||
pip?: boolean;
|
||||
getStats: boolean;
|
||||
setStats: (stats: PlayerStatsType) => void;
|
||||
onPlaying?: () => void;
|
||||
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
@ -31,6 +37,8 @@ function MSEPlayer({
|
||||
volume,
|
||||
playInBackground = false,
|
||||
pip = false,
|
||||
getStats,
|
||||
setStats,
|
||||
onPlaying,
|
||||
setFullResolution,
|
||||
onError,
|
||||
@ -61,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);
|
||||
@ -320,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);
|
||||
@ -566,6 +577,68 @@ function MSEPlayer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playbackEnabled]);
|
||||
|
||||
// stats
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
let lastLoadedBytes = 0;
|
||||
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
web/src/components/player/PlayerStats.tsx
Normal file
100
web/src/components/player/PlayerStats.tsx
Normal 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 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 = {
|
||||
@ -11,6 +11,8 @@ type WebRtcPlayerProps = {
|
||||
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;
|
||||
};
|
||||
@ -24,6 +26,8 @@ export default function WebRtcPlayer({
|
||||
microphoneEnabled = false,
|
||||
iOSCompatFullScreen = false,
|
||||
pip = false,
|
||||
getStats,
|
||||
setStats,
|
||||
onPlaying,
|
||||
onError,
|
||||
}: WebRtcPlayerProps) {
|
||||
@ -227,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]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
||||
@ -34,4 +34,15 @@ 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;
|
||||
};
|
||||
|
||||
@ -21,7 +21,12 @@ import {
|
||||
} from "react-grid-layout";
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import "react-resizable/css/styles.css";
|
||||
import { AudioState, LivePlayerMode, VolumeState } 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";
|
||||
@ -363,10 +368,24 @@ export default function DraggableGridLayout({
|
||||
placeholder.h = layoutItem.h;
|
||||
};
|
||||
|
||||
// audio states
|
||||
// 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) {
|
||||
@ -552,6 +571,8 @@ export default function DraggableGridLayout({
|
||||
}
|
||||
audioState={audioStates[camera.name]}
|
||||
toggleAudio={() => toggleAudio(camera.name)}
|
||||
statsState={statsStates[camera.name]}
|
||||
toggleStats={() => toggleStats(camera.name)}
|
||||
volumeState={volumeStates[camera.name]}
|
||||
setVolumeState={(value) =>
|
||||
setVolumeStates({
|
||||
@ -584,6 +605,7 @@ export default function DraggableGridLayout({
|
||||
cameraConfig={camera}
|
||||
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
||||
playInBackground={false}
|
||||
showStats={statsStates[camera.name]}
|
||||
onClick={() => {
|
||||
!isEditMode && onSelectCamera(camera.name);
|
||||
}}
|
||||
@ -761,6 +783,8 @@ type GridLiveContextMenuProps = {
|
||||
supportsAudio: boolean;
|
||||
audioState: boolean;
|
||||
toggleAudio: () => void;
|
||||
statsState: boolean;
|
||||
toggleStats: () => void;
|
||||
volumeState?: number;
|
||||
setVolumeState: (volumeState: number) => void;
|
||||
muteAll: () => void;
|
||||
@ -788,6 +812,8 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
supportsAudio,
|
||||
audioState,
|
||||
toggleAudio,
|
||||
statsState,
|
||||
toggleStats,
|
||||
volumeState,
|
||||
setVolumeState,
|
||||
muteAll,
|
||||
@ -816,6 +842,8 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
supportsAudio={supportsAudio}
|
||||
audioState={audioState}
|
||||
toggleAudio={toggleAudio}
|
||||
statsState={statsState}
|
||||
toggleStats={toggleStats}
|
||||
volumeState={volumeState}
|
||||
setVolumeState={setVolumeState}
|
||||
muteAll={muteAll}
|
||||
|
||||
@ -239,6 +239,8 @@ export default function LiveCameraView({
|
||||
false,
|
||||
);
|
||||
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
|
||||
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
@ -502,6 +504,8 @@ export default function LiveCameraView({
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
playInBackground={playInBackground ?? false}
|
||||
setPlayInBackground={setPlayInBackground}
|
||||
showStats={showStats}
|
||||
setShowStats={setShowStats}
|
||||
isRestreamed={isRestreamed ?? false}
|
||||
setLowBandwidth={setLowBandwidth}
|
||||
supportsAudioOutput={supportsAudioOutput}
|
||||
@ -539,6 +543,7 @@ export default function LiveCameraView({
|
||||
cameraConfig={camera}
|
||||
playAudio={audio}
|
||||
playInBackground={playInBackground ?? false}
|
||||
showStats={showStats}
|
||||
micEnabled={mic}
|
||||
iOSCompatFullScreen={isIOS}
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
@ -833,6 +838,8 @@ type FrigateCameraFeaturesProps = {
|
||||
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;
|
||||
@ -849,6 +856,8 @@ function FrigateCameraFeatures({
|
||||
preferredLiveMode,
|
||||
playInBackground,
|
||||
setPlayInBackground,
|
||||
showStats,
|
||||
setShowStats,
|
||||
isRestreamed,
|
||||
setLowBandwidth,
|
||||
supportsAudioOutput,
|
||||
@ -1204,6 +1213,26 @@ function FrigateCameraFeatures({
|
||||
</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
|
||||
@ -1437,6 +1466,7 @@ function FrigateCameraFeatures({
|
||||
</div>
|
||||
{isRestreamed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label="Play in Background"
|
||||
isChecked={playInBackground}
|
||||
@ -1448,6 +1478,20 @@ function FrigateCameraFeatures({
|
||||
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">
|
||||
|
||||
@ -28,7 +28,12 @@ import DraggableGridLayout from "./DraggableGridLayout";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { LuLayoutDashboard } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AudioState, LivePlayerError, VolumeState } 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";
|
||||
@ -231,6 +236,14 @@ export default function LiveDashboardView({
|
||||
|
||||
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) => ({
|
||||
@ -394,6 +407,8 @@ export default function LiveDashboardView({
|
||||
}
|
||||
audioState={audioStates[camera.name]}
|
||||
toggleAudio={() => toggleAudio(camera.name)}
|
||||
statsState={statsStates[camera.name]}
|
||||
toggleStats={() => toggleStats(camera.name)}
|
||||
volumeState={volumeStates[camera.name] ?? 1}
|
||||
setVolumeState={(value) =>
|
||||
setVolumeStates({
|
||||
@ -418,6 +433,7 @@ export default function LiveDashboardView({
|
||||
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)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user