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"; type JSMpegPlayerProps = { className?: string; camera: string; width: number; height: number; containerRef: React.MutableRefObject; playbackEnabled: boolean; useWebGL: boolean; setStats?: (stats: PlayerStatsType) => void; onPlaying?: () => void; fit?: "contain" | "cover"; }; export default function JSMpegPlayer({ camera, width, height, className, containerRef, playbackEnabled, useWebGL = false, setStats, onPlaying, fit = "contain", }: JSMpegPlayerProps) { const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; const videoRef = useRef(null); const canvasRef = useRef(null); const internalContainerRef = useRef(null); const onPlayingRef = useRef(onPlaying); const [showCanvas, setShowCanvas] = useState(false); 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(null); const selectedContainerRef = useMemo( () => (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 scaledDimensions = useMemo(() => { if (!width || !height || !containerWidth || !containerHeight) { return { width: undefined, height: undefined }; } if (fit == "cover") { if (aspectRatio < fitAspect) { const coverWidth = Math.ceil(containerWidth); return { width: coverWidth, height: Math.ceil(coverWidth / aspectRatio), }; } const coverHeight = Math.ceil(containerHeight); return { width: Math.ceil(coverHeight * aspectRatio), height: coverHeight, }; } if (selectedContainerRef?.current) { const scaledHeight = aspectRatio < (fitAspect ?? 0) ? Math.floor( Math.min( containerHeight, selectedContainerRef.current?.clientHeight, ), ) : aspectRatio >= fitAspect ? Math.floor(containerWidth / aspectRatio) : Math.floor(containerWidth / aspectRatio) / 1.5; const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height); if (finalHeight > 0) { return { width: Math.ceil(finalHeight * aspectRatio), height: finalHeight, }; } } return { width: undefined, height: undefined }; }, [ aspectRatio, containerWidth, containerHeight, fitAspect, fit, height, width, stretch, selectedContainerRef, ]); useEffect(() => { if (scaledDimensions.width && scaledDimensions.height) { setDimensionsReady(true); } }, [scaledDimensions]); useEffect(() => { onPlayingRef.current = onPlaying; }, [onPlaying]); useEffect(() => { if (!selectedContainerRef?.current || !url) { return; } const videoWrapper = videoRef.current; const canvas = canvasRef.current; let videoElement: JSMpeg.VideoElement | null = null; let socket: WebSocket | null = null; let socketMessageHandler: ((event: MessageEvent) => void) | null = null; let frameCount = 0; setHasData(false); 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, 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 ) { socket = videoElement.player.source.socket as WebSocket; socketMessageHandler = (event: MessageEvent) => { if (event.data instanceof ArrayBuffer) { bytesReceivedRef.current += event.data.byteLength; } }; socket.addEventListener("message", socketMessageHandler); } // Update stats every second statsIntervalRef.current = setInterval(() => { const currentTimestamp = Date.now(); const timeDiff = (currentTimestamp - lastTimestampRef.current) / 1000; // in seconds const bitrate = bytesReceivedRef.current / 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 { videoElement.player?.destroy(); // eslint-disable-next-line no-empty } catch (e) {} if (videoWrapper) { videoWrapper.innerHTML = ""; // @ts-expect-error playerInstance is set by jsmpeg videoWrapper.playerInstance = null; } } if (socket) { if (socketMessageHandler) { socket.removeEventListener("message", socketMessageHandler); } socket = null; socketMessageHandler = null; } }; } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [playbackEnabled, url]); useEffect(() => { setShowCanvas(hasData && dimensionsReady); }, [hasData, dimensionsReady]); useEffect(() => { hasDataRef.current = hasData; }, [hasData]); return (
); }