stream stats

This commit is contained in:
Josh Hawkins 2024-12-28 07:35:04 -06:00
parent c3a0ba0865
commit af039f349d
10 changed files with 455 additions and 16 deletions

View File

@ -44,6 +44,8 @@ type LiveContextMenuProps = {
setVolumeState: (volumeState: number) => void; setVolumeState: (volumeState: number) => void;
muteAll: () => void; muteAll: () => void;
unmuteAll: () => void; unmuteAll: () => void;
statsState: boolean;
toggleStats: () => void;
resetPreferredLiveMode: () => void; resetPreferredLiveMode: () => void;
children?: ReactNode; children?: ReactNode;
}; };
@ -61,6 +63,8 @@ export default function LiveContextMenu({
setVolumeState, setVolumeState,
muteAll, muteAll,
unmuteAll, unmuteAll,
statsState,
toggleStats,
resetPreferredLiveMode, resetPreferredLiveMode,
children, children,
}: LiveContextMenuProps) { }: LiveContextMenuProps) {
@ -231,6 +235,17 @@ export default function LiveContextMenu({
<div className="text-primary">Unmute All Cameras</div> <div className="text-primary">Unmute All Cameras</div>
</div> </div>
</ContextMenuItem> </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 && ( {isRestreamed && cameraGroup && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />

View File

@ -1,6 +1,7 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PlayerStatsType } from "@/types/live";
// @ts-expect-error we know this doesn't have types // @ts-expect-error we know this doesn't have types
import JSMpeg from "@cycjimmy/jsmpeg-player"; import JSMpeg from "@cycjimmy/jsmpeg-player";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
@ -13,6 +14,7 @@ type JSMpegPlayerProps = {
containerRef: React.MutableRefObject<HTMLDivElement | null>; containerRef: React.MutableRefObject<HTMLDivElement | null>;
playbackEnabled: boolean; playbackEnabled: boolean;
useWebGL: boolean; useWebGL: boolean;
setStats: (stats: PlayerStatsType) => void;
onPlaying?: () => void; onPlaying?: () => void;
}; };
@ -24,6 +26,7 @@ export default function JSMpegPlayer({
containerRef, containerRef,
playbackEnabled, playbackEnabled,
useWebGL = false, useWebGL = false,
setStats,
onPlaying, onPlaying,
}: JSMpegPlayerProps) { }: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
@ -35,6 +38,9 @@ export default function JSMpegPlayer({
const [hasData, setHasData] = useState(false); const [hasData, setHasData] = useState(false);
const hasDataRef = useRef(hasData); const hasDataRef = useRef(hasData);
const [dimensionsReady, setDimensionsReady] = useState(false); const [dimensionsReady, setDimensionsReady] = useState(false);
const bytesReceivedRef = useRef(0);
const lastTimestampRef = useRef(Date.now());
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null);
const selectedContainerRef = useMemo( const selectedContainerRef = useMemo(
() => (containerRef.current ? containerRef : internalContainerRef), () => (containerRef.current ? containerRef : internalContainerRef),
@ -113,6 +119,8 @@ export default function JSMpegPlayer({
const canvas = canvasRef.current; const canvas = canvasRef.current;
let videoElement: JSMpeg.VideoElement | null = null; let videoElement: JSMpeg.VideoElement | null = null;
let frameCount = 0;
setHasData(false); setHasData(false);
if (videoWrapper && playbackEnabled) { if (videoWrapper && playbackEnabled) {
@ -133,13 +141,60 @@ export default function JSMpegPlayer({
setHasData(true); setHasData(true);
onPlayingRef.current?.(); 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); }, 0);
return () => { return () => {
clearTimeout(initPlayer); clearTimeout(initPlayer);
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current);
statsIntervalRef.current = null;
}
if (videoElement) { if (videoElement) {
try { try {
// this causes issues in react strict mode // this causes issues in react strict mode

View File

@ -11,6 +11,7 @@ import { useCameraActivity } from "@/hooks/use-camera-activity";
import { import {
LivePlayerError, LivePlayerError,
LivePlayerMode, LivePlayerMode,
PlayerStatsType,
VideoResolutionType, VideoResolutionType,
} from "@/types/live"; } from "@/types/live";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
@ -20,6 +21,7 @@ import { cn } from "@/lib/utils";
import { TbExclamationCircle } from "react-icons/tb"; import { TbExclamationCircle } from "react-icons/tb";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats";
type LivePlayerProps = { type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void; cameraRef?: (ref: HTMLDivElement | null) => void;
@ -38,6 +40,7 @@ type LivePlayerProps = {
iOSCompatFullScreen?: boolean; iOSCompatFullScreen?: boolean;
pip?: boolean; pip?: boolean;
autoLive?: boolean; autoLive?: boolean;
showStats?: boolean;
onClick?: () => void; onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
@ -61,6 +64,7 @@ export default function LivePlayer({
iOSCompatFullScreen = false, iOSCompatFullScreen = false,
pip, pip,
autoLive = true, autoLive = true,
showStats = false,
onClick, onClick,
setFullResolution, setFullResolution,
onError, onError,
@ -68,6 +72,18 @@ export default function LivePlayer({
}: LivePlayerProps) { }: LivePlayerProps) {
const internalContainerRef = useRef<HTMLDivElement | null>(null); 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 // camera activity
const { activeMotion, activeTracking, objects, offline } = const { activeMotion, activeTracking, objects, offline } =
@ -189,6 +205,8 @@ export default function LivePlayer({
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
camera={streamName} camera={streamName}
playbackEnabled={cameraActive || liveReady} playbackEnabled={cameraActive || liveReady}
getStats={showStats}
setStats={setStats}
audioEnabled={playAudio} audioEnabled={playAudio}
volume={volume} volume={volume}
microphoneEnabled={micEnabled} microphoneEnabled={micEnabled}
@ -209,6 +227,8 @@ export default function LivePlayer({
audioEnabled={playAudio} audioEnabled={playAudio}
volume={volume} volume={volume}
playInBackground={playInBackground} playInBackground={playInBackground}
getStats={showStats}
setStats={setStats}
onPlaying={playerIsPlaying} onPlaying={playerIsPlaying}
pip={pip} pip={pip}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
@ -235,6 +255,7 @@ export default function LivePlayer({
cameraActive || !showStillWithoutActivity || liveReady cameraActive || !showStillWithoutActivity || liveReady
} }
useWebGL={useWebGL} useWebGL={useWebGL}
setStats={setStats}
containerRef={containerRef ?? internalContainerRef} containerRef={containerRef ?? internalContainerRef}
onPlaying={playerIsPlaying} onPlaying={playerIsPlaying}
/> />
@ -364,6 +385,9 @@ export default function LivePlayer({
</Chip> </Chip>
)} )}
</div> </div>
{showStats && (
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
)}
</div> </div>
); );
} }

View File

@ -1,5 +1,9 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError, VideoResolutionType } from "@/types/live"; import {
LivePlayerError,
PlayerStatsType,
VideoResolutionType,
} from "@/types/live";
import { import {
SetStateAction, SetStateAction,
useCallback, useCallback,
@ -18,6 +22,8 @@ type MSEPlayerProps = {
volume?: number; volume?: number;
playInBackground?: boolean; playInBackground?: boolean;
pip?: boolean; pip?: boolean;
getStats: boolean;
setStats: (stats: PlayerStatsType) => void;
onPlaying?: () => void; onPlaying?: () => void;
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
@ -31,6 +37,8 @@ function MSEPlayer({
volume, volume,
playInBackground = false, playInBackground = false,
pip = false, pip = false,
getStats,
setStats,
onPlaying, onPlaying,
setFullResolution, setFullResolution,
onError, onError,
@ -61,6 +69,7 @@ function MSEPlayer({
const [connectTS, setConnectTS] = useState<number>(0); const [connectTS, setConnectTS] = useState<number>(0);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>(); const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const [errorCount, setErrorCount] = useState<number>(0); const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@ -320,6 +329,8 @@ function MSEPlayer({
let bufLen = 0; let bufLen = 0;
ondataRef.current = (data) => { ondataRef.current = (data) => {
totalBytesLoaded.current += data.byteLength;
if (sb?.updating || bufLen > 0) { if (sb?.updating || bufLen > 0) {
const b = new Uint8Array(data); const b = new Uint8Array(data);
buf.set(b, bufLen); buf.set(b, bufLen);
@ -566,6 +577,68 @@ function MSEPlayer({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled]); }, [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 ( return (
<video <video
ref={videoRef} ref={videoRef}

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 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>
</>
);
}

View File

@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError } from "@/types/live"; import { LivePlayerError, PlayerStatsType } from "@/types/live";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type WebRtcPlayerProps = { type WebRtcPlayerProps = {
@ -11,6 +11,8 @@ type WebRtcPlayerProps = {
microphoneEnabled?: boolean; microphoneEnabled?: boolean;
iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element
pip?: boolean; pip?: boolean;
getStats: boolean;
setStats: (stats: PlayerStatsType) => void;
onPlaying?: () => void; onPlaying?: () => void;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
}; };
@ -24,6 +26,8 @@ export default function WebRtcPlayer({
microphoneEnabled = false, microphoneEnabled = false,
iOSCompatFullScreen = false, iOSCompatFullScreen = false,
pip = false, pip = false,
getStats,
setStats,
onPlaying, onPlaying,
onError, onError,
}: WebRtcPlayerProps) { }: WebRtcPlayerProps) {
@ -227,6 +231,75 @@ export default function WebRtcPlayer({
onPlaying?.(); 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 ( return (
<video <video
ref={videoRef} ref={videoRef}

View File

@ -34,4 +34,15 @@ export type LiveStreamMetadata = {
export type LivePlayerError = "stalled" | "startup" | "mse-decode"; export type LivePlayerError = "stalled" | "startup" | "mse-decode";
export type AudioState = Record<string, boolean>; export type AudioState = Record<string, boolean>;
export type StatsState = Record<string, boolean>;
export type VolumeState = Record<string, number>; 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;
};

View File

@ -21,7 +21,12 @@ import {
} from "react-grid-layout"; } from "react-grid-layout";
import "react-grid-layout/css/styles.css"; import "react-grid-layout/css/styles.css";
import "react-resizable/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 { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
@ -363,10 +368,24 @@ export default function DraggableGridLayout({
placeholder.h = layoutItem.h; placeholder.h = layoutItem.h;
}; };
// audio states // audio and stats states
const [audioStates, setAudioStates] = useState<AudioState>({}); const [audioStates, setAudioStates] = useState<AudioState>({});
const [volumeStates, setVolumeStates] = useState<VolumeState>({}); 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(() => { useEffect(() => {
if (!allGroupsStreamingSettings) { if (!allGroupsStreamingSettings) {
@ -552,6 +571,8 @@ export default function DraggableGridLayout({
} }
audioState={audioStates[camera.name]} audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)} toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]} volumeState={volumeStates[camera.name]}
setVolumeState={(value) => setVolumeState={(value) =>
setVolumeStates({ setVolumeStates({
@ -584,6 +605,7 @@ export default function DraggableGridLayout({
cameraConfig={camera} cameraConfig={camera}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"} preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
playInBackground={false} playInBackground={false}
showStats={statsStates[camera.name]}
onClick={() => { onClick={() => {
!isEditMode && onSelectCamera(camera.name); !isEditMode && onSelectCamera(camera.name);
}} }}
@ -761,6 +783,8 @@ type GridLiveContextMenuProps = {
supportsAudio: boolean; supportsAudio: boolean;
audioState: boolean; audioState: boolean;
toggleAudio: () => void; toggleAudio: () => void;
statsState: boolean;
toggleStats: () => void;
volumeState?: number; volumeState?: number;
setVolumeState: (volumeState: number) => void; setVolumeState: (volumeState: number) => void;
muteAll: () => void; muteAll: () => void;
@ -788,6 +812,8 @@ const GridLiveContextMenu = React.forwardRef<
supportsAudio, supportsAudio,
audioState, audioState,
toggleAudio, toggleAudio,
statsState,
toggleStats,
volumeState, volumeState,
setVolumeState, setVolumeState,
muteAll, muteAll,
@ -816,6 +842,8 @@ const GridLiveContextMenu = React.forwardRef<
supportsAudio={supportsAudio} supportsAudio={supportsAudio}
audioState={audioState} audioState={audioState}
toggleAudio={toggleAudio} toggleAudio={toggleAudio}
statsState={statsState}
toggleStats={toggleStats}
volumeState={volumeState} volumeState={volumeState}
setVolumeState={setVolumeState} setVolumeState={setVolumeState}
muteAll={muteAll} muteAll={muteAll}

View File

@ -239,6 +239,8 @@ export default function LiveCameraView({
false, false,
); );
const [showStats, setShowStats] = useState(false);
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({ const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
width: 0, width: 0,
height: 0, height: 0,
@ -502,6 +504,8 @@ export default function LiveCameraView({
preferredLiveMode={preferredLiveMode} preferredLiveMode={preferredLiveMode}
playInBackground={playInBackground ?? false} playInBackground={playInBackground ?? false}
setPlayInBackground={setPlayInBackground} setPlayInBackground={setPlayInBackground}
showStats={showStats}
setShowStats={setShowStats}
isRestreamed={isRestreamed ?? false} isRestreamed={isRestreamed ?? false}
setLowBandwidth={setLowBandwidth} setLowBandwidth={setLowBandwidth}
supportsAudioOutput={supportsAudioOutput} supportsAudioOutput={supportsAudioOutput}
@ -539,6 +543,7 @@ export default function LiveCameraView({
cameraConfig={camera} cameraConfig={camera}
playAudio={audio} playAudio={audio}
playInBackground={playInBackground ?? false} playInBackground={playInBackground ?? false}
showStats={showStats}
micEnabled={mic} micEnabled={mic}
iOSCompatFullScreen={isIOS} iOSCompatFullScreen={isIOS}
preferredLiveMode={preferredLiveMode} preferredLiveMode={preferredLiveMode}
@ -833,6 +838,8 @@ type FrigateCameraFeaturesProps = {
preferredLiveMode: string; preferredLiveMode: string;
playInBackground: boolean; playInBackground: boolean;
setPlayInBackground: (value: boolean | undefined) => void; setPlayInBackground: (value: boolean | undefined) => void;
showStats: boolean;
setShowStats: (value: boolean) => void;
isRestreamed: boolean; isRestreamed: boolean;
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>; setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
supportsAudioOutput: boolean; supportsAudioOutput: boolean;
@ -849,6 +856,8 @@ function FrigateCameraFeatures({
preferredLiveMode, preferredLiveMode,
playInBackground, playInBackground,
setPlayInBackground, setPlayInBackground,
showStats,
setShowStats,
isRestreamed, isRestreamed,
setLowBandwidth, setLowBandwidth,
supportsAudioOutput, supportsAudioOutput,
@ -1204,6 +1213,26 @@ function FrigateCameraFeatures({
</p> </p>
</div> </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 flex-col gap-1">
<div className="flex items-center justify-between text-sm font-medium leading-none"> <div className="flex items-center justify-between text-sm font-medium leading-none">
Debug View Debug View
@ -1437,6 +1466,7 @@ function FrigateCameraFeatures({
</div> </div>
{isRestreamed && ( {isRestreamed && (
<> <>
<div className="flex flex-col gap-2">
<FilterSwitch <FilterSwitch
label="Play in Background" label="Play in Background"
isChecked={playInBackground} isChecked={playInBackground}
@ -1448,6 +1478,20 @@ function FrigateCameraFeatures({
Enable this option to continue streaming when the player is Enable this option to continue streaming when the player is
hidden. hidden.
</p> </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="mb-3 flex flex-col gap-1 px-2">

View File

@ -28,7 +28,12 @@ import DraggableGridLayout from "./DraggableGridLayout";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import { LuLayoutDashboard } from "react-icons/lu"; import { LuLayoutDashboard } from "react-icons/lu";
import { cn } from "@/lib/utils"; 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 { FaCompress, FaExpand } from "react-icons/fa";
import useCameraLiveMode from "@/hooks/use-camera-live-mode"; import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
@ -231,6 +236,14 @@ export default function LiveDashboardView({
const [audioStates, setAudioStates] = useState<AudioState>({}); const [audioStates, setAudioStates] = useState<AudioState>({});
const [volumeStates, setVolumeStates] = useState<VolumeState>({}); 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 => { const toggleAudio = (cameraName: string): void => {
setAudioStates((prev) => ({ setAudioStates((prev) => ({
@ -394,6 +407,8 @@ export default function LiveDashboardView({
} }
audioState={audioStates[camera.name]} audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)} toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name] ?? 1} volumeState={volumeStates[camera.name] ?? 1}
setVolumeState={(value) => setVolumeState={(value) =>
setVolumeStates({ setVolumeStates({
@ -418,6 +433,7 @@ export default function LiveDashboardView({
autoLive={autoLiveView} autoLive={autoLiveView}
useWebGL={false} useWebGL={false}
playInBackground={false} playInBackground={false}
showStats={statsStates[camera.name]}
streamName={Object.values(camera.live.streams)[0]} streamName={Object.values(camera.live.streams)[0]}
onClick={() => onSelectCamera(camera.name)} onClick={() => onSelectCamera(camera.name)}
onError={(e) => handleError(camera.name, e)} onError={(e) => handleError(camera.name, e)}