Files
frigate/web/src/components/player/JSMpegPlayer.tsx
T

246 lines
7.1 KiB
TypeScript
Raw Normal View History

import { baseUrl } from "@/api/baseUrl";
import { useResizeObserver } from "@/hooks/resize-observer";
2024-06-19 06:09:49 -06:00
import { cn } from "@/lib/utils";
2025-02-10 10:42:35 -06:00
import { PlayerStatsType } from "@/types/live";
2024-02-28 15:23:56 -07:00
// @ts-expect-error we know this doesn't have types
import JSMpeg from "@cycjimmy/jsmpeg-player";
2024-06-29 10:02:30 -05:00
import React, { useEffect, useMemo, useRef, useState } from "react";
type JSMpegPlayerProps = {
2024-02-10 05:30:53 -07:00
className?: string;
camera: string;
width: number;
height: number;
2024-06-29 10:02:30 -05:00
containerRef: React.MutableRefObject<HTMLDivElement | null>;
playbackEnabled: boolean;
2025-02-10 10:42:35 -06:00
useWebGL: boolean;
setStats?: (stats: PlayerStatsType) => void;
2024-06-10 18:24:25 -05:00
onPlaying?: () => void;
};
export default function JSMpegPlayer({
camera,
width,
height,
2024-02-10 05:30:53 -07:00
className,
containerRef,
2024-06-29 10:02:30 -05:00
playbackEnabled,
2025-02-10 10:42:35 -06:00
useWebGL = false,
setStats,
2024-06-10 18:24:25 -05:00
onPlaying,
}: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
2024-06-29 10:02:30 -05:00
const videoRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const internalContainerRef = useRef<HTMLDivElement | null>(null);
const onPlayingRef = useRef(onPlaying);
2024-06-19 06:09:49 -06:00
const [showCanvas, setShowCanvas] = useState(false);
2024-06-29 18:45:28 -05:00
const [hasData, setHasData] = useState(false);
const hasDataRef = useRef(hasData);
2024-06-29 18:45:28 -05:00
const [dimensionsReady, setDimensionsReady] = useState(false);
2025-02-10 10:42:35 -06:00
const bytesReceivedRef = useRef(0);
const lastTimestampRef = useRef(Date.now());
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null);
const selectedContainerRef = useMemo(
2024-06-29 10:02:30 -05:00
() => (containerRef.current ? containerRef : internalContainerRef),
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[containerRef, containerRef.current, internalContainerRef],
);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(selectedContainerRef);
const stretch = true;
const aspectRatio = width / height;
const fitAspect = useMemo(
() => containerWidth / containerHeight,
[containerWidth, containerHeight],
);
const scaledHeight = useMemo(() => {
if (selectedContainerRef?.current && width && height) {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(
Math.min(
containerHeight,
selectedContainerRef.current?.clientHeight,
),
)
2024-05-29 14:18:51 -05:00
: aspectRatio >= fitAspect
? Math.floor(containerWidth / aspectRatio)
: Math.floor(containerWidth / aspectRatio) / 1.5;
const finalHeight = stretch
? scaledHeight
: Math.min(scaledHeight, height);
if (finalHeight > 0) {
return finalHeight;
}
}
2024-06-29 18:45:28 -05:00
return undefined;
}, [
aspectRatio,
containerWidth,
containerHeight,
fitAspect,
height,
width,
stretch,
selectedContainerRef,
]);
const scaledWidth = useMemo(() => {
if (aspectRatio && scaledHeight) {
return Math.ceil(scaledHeight * aspectRatio);
}
2024-06-29 18:45:28 -05:00
return undefined;
}, [scaledHeight, aspectRatio]);
2024-06-29 18:45:28 -05:00
useEffect(() => {
if (scaledWidth && scaledHeight) {
setDimensionsReady(true);
}
}, [scaledWidth, scaledHeight]);
useEffect(() => {
onPlayingRef.current = onPlaying;
}, [onPlaying]);
useEffect(() => {
2024-06-29 10:02:30 -05:00
if (!selectedContainerRef?.current || !url) {
return;
}
2024-06-29 10:02:30 -05:00
const videoWrapper = videoRef.current;
const canvas = canvasRef.current;
let videoElement: JSMpeg.VideoElement | null = null;
2025-02-10 10:42:35 -06:00
let frameCount = 0;
setHasData(false);
2024-06-29 10:02:30 -05:00
if (videoWrapper && playbackEnabled) {
// Delayed init to avoid issues with react strict mode
const initPlayer = setTimeout(() => {
videoElement = new JSMpeg.VideoElement(
videoWrapper,
url,
{ canvas: canvas },
{
protocols: [],
audio: false,
2025-02-10 10:42:35 -06:00
disableGl: !useWebGL,
disableWebAssembly: !useWebGL,
2024-06-29 10:02:30 -05:00
videoBufferSize: 1024 * 1024 * 4,
onVideoDecode: () => {
if (!hasDataRef.current) {
2024-06-29 18:45:28 -05:00
setHasData(true);
2024-06-29 10:02:30 -05:00
onPlayingRef.current?.();
}
2025-02-10 10:42:35 -06:00
frameCount++;
2024-06-29 10:02:30 -05:00
},
},
);
2025-02-10 10:42:35 -06:00
// 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
2025-09-01 19:23:44 -05:00
const bitrate = bytesReceivedRef.current / timeDiff / 1000; // in kBps
2025-02-10 10:42:35 -06:00
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;
}
};
2024-06-29 10:02:30 -05:00
}, 0);
return () => {
clearTimeout(initPlayer);
2025-02-10 10:42:35 -06:00
if (statsIntervalRef.current) {
clearInterval(statsIntervalRef.current);
statsIntervalRef.current = null;
}
2024-06-29 10:02:30 -05:00
if (videoElement) {
try {
// this causes issues in react strict mode
// https://stackoverflow.com/questions/76822128/issue-with-cycjimmy-jsmpeg-player-in-react-18-cannot-read-properties-of-null-o
videoElement.destroy();
// eslint-disable-next-line no-empty
} catch (e) {}
}
};
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playbackEnabled, url]);
2024-06-29 18:45:28 -05:00
useEffect(() => {
setShowCanvas(hasData && dimensionsReady);
}, [hasData, dimensionsReady]);
useEffect(() => {
hasDataRef.current = hasData;
}, [hasData]);
return (
2024-06-29 10:02:30 -05:00
<div className={cn(className, !containerRef.current && "size-full")}>
2024-06-29 18:45:28 -05:00
<div
className="internal-jsmpeg-container size-full"
ref={internalContainerRef}
>
<div
ref={videoRef}
className={cn(
"jsmpeg flex h-full w-auto items-center justify-center",
!showCanvas && "hidden",
)}
>
<canvas
2024-06-29 10:02:30 -05:00
ref={canvasRef}
2024-06-29 18:45:28 -05:00
className="rounded-lg md:rounded-2xl"
style={{
2024-06-29 18:45:28 -05:00
width: scaledWidth,
height: scaledHeight,
}}
></canvas>
</div>
</div>
</div>
);
}