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
+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}