使用 ChartJS 和 React 显示 WebRTC 统计 API 数据图表

WebRTC 是一种开源技术,可在Web浏览器中实现实时通信 (RTC)。它为软件开发人员提供了一套应用编程接口(API),以实现对等方之间的实时数据交换,例如音频通话、视频会议,甚至游戏流媒体。我们的产品之一 Anbox Cloud 就采用了这项技术,将在云中运行的 Android 应用程序的会话流式传输到 Anbox Cloud 网络控制面板。

WebRTC 基于点对点技术,中间不需要服务器,这带来了许多好处,如低延迟、更高的安全性和更好的稳定性。总的来说,WebRTC 的优势在于两个用户(对等方)只需要一个Web浏览器就能建立通信。

WebRTC 统计 API

在本篇文章中,我们要重点介绍的 API 之一是统计 API。它主要为开发人员提供有关两个对等设备之间当前连接的实时信息。我们将展示如何在用 TypeScript 编写的 React 应用程序中使用 ChartJS 显示这些统计数据。

WebRTC 示例

WebRTC 示例页面包含一系列示例,用于演示许多 API 如何工作。我们将首先使用这些示例检查 WebRTC API 的一些通用实现,然后以此为起点构建我们的 React 应用程序。

例如,演示 “从视频流到对等连接 “就是一个很好的起点。在这个示例中,我们模拟两个视频对等方在同一个网页上交换数据。左边的视频发送方发送视频和音频数据,右边的视频接收方使用 WebRTC 协议接收这些数据。

不过,这个示例并没有展示如何使用统计 API。为此,我们需要查看另一个名为 “约束与统计 “的示例。这个示例对硬件有一些要求,因为两个对等方要交换来自摄像头流的数据,所以它的通用性不如前一个简单的视频文件流示例。我们只能从这个示例中获取我们需要的东西,即:统计 API 的使用方式。

使用 ChartJS 和 React 显示 WebRTC 统计 API 数据图表

这种实时更新的文本数据负载对用户来说并不友好。那么用一些漂亮的图表来显示这些信息如何?这就是功能强大的 JavaScript 图表库 ChartJS 的用武之地。

因此,在本教程的下一步中,我们将在 React 应用程序中拼接这两个示例,然后将来自统计 API 的数据输入 ChartJS,这样我们就可以通过一些漂亮的图表来显示这些数据。

引导 React 应用程序

我们可以使用 create-react-app 工具来引导 React 应用程序并快速开始开发。然后,我们可以自定义项目,例如将应用程序捆绑程序切换为 Vite,以获得更好的开发体验,但这完全是可选项,在此不再详述。

因此,要在 TypeScript 中引导 React 应用程序,我们可以使用以下命令:

npx create-react-app my-app --template typescript

请注意,您可以在此软件仓库中找到本教程的完整代码。为了不使教程过于冗长和复杂,我们将省略一些步骤,例如依赖安装、App.css 文件内的样式设计、linter 配置等。如果您只想快速看到项目在您的机器上运行,请在本地克隆该 repo 并使用 npm start 启动项目,然后您可以跟随本教程的其余部分更好地理解代码中的内容。

添加带有发送视频和接收视频的组件

创建应用程序后,我们就可以开始添加组件了。但在此之前,先添加一些我们需要的类型。我们可以创建一个 types 文件夹,并将这两个文件放入其中:

  • stats.d.ts - 用于统计的类型,稍后将在项目中使用
export interface StatsDataPoint {
  timestamp: number;
  value: number;
}
export interface GraphStat {
dataPoints: StatsDataPoint[];
}
export type StatsHistory = Record<string, GraphStat>;
export interface GraphItem {
title: string;
unit?: string;
}
export interface Stats {
video: {
fps: number;
bytesReceived: number;
jitter: number;
decodeTime: number;
packetsReceived: number;
packetsLost: number;
};
audio: {
bytesReceived: number;
jitter: number;
totalSamplesReceived: number;
packetsReceived: number;
packetsLost: number;
};
}
  • video.d.ts– 视频文件类型:
declare module "*.webm" {
  const src: string;
  export default src;
}
declare module "*.mp4" {
const src: string;
export default src;
}

让我们创建一个 assets 文件夹,然后将 WebRTC 示例软件仓库中的 2 个视频文件(chrome.mp4 和 chrome.webm,但也可以使用其他视频文件)复制到该文件夹中。

然后,我们可以添加一个WebRTCStats 组件,它将成为我们的应用程序的总容器。

const WebRTCStats: FC = () => {
  return <></>;
};

导出默认 WebRTCStats;
现在让我们导入并调整上面第一个 WebRTC 示例中的一些代码、 这些代码将放在我们的功能组件的主体中:

interface HTMLVideoElementExtended extends HTMLVideoElement {
  captureStream?(): MediaStream;
  mozCaptureStream?(): MediaStream;
}
const WebRTCStats: FC = () => {
const [stream, setStream] = useState<MediaStream>();
const [senderPeer, setSenderPeer] = useState<RTCPeerConnection>();
const [receiverPeer, setReceiverPeer] = useState<RTCPeerConnection>();
const [isReceiverPlaying, setReceiverPlaying] = useState<boolean>(false);
const senderVideoRef = useRef<HTMLVideoElementExtended>(null);
const receiverVideoRef = useRef<HTMLVideoElementExtended>(null);
const offerOptions: RTCOfferOptions = {
offerToReceiveAudio: true,
offerToReceiveVideo: true,
};
const maybeCreateStream = () => {
if (!senderVideoRef.current) {
return;
}
const senderVideo = senderVideoRef.current;
if (senderVideo.captureStream) {
setStream(senderVideo.captureStream());
startPeerConnection();
} else if (senderVideo.mozCaptureStream) {
setStream(senderVideo.mozCaptureStream());
startPeerConnection();
} else {
console.log("captureStream() not supported");
}
};
const startPeerConnection = () => {
setSenderPeer(new RTCPeerConnection());
setReceiverPeer(new RTCPeerConnection());
};
useEffect(() => {
if (senderPeer && receiverPeer && stream) {
senderPeer.onicecandidate = (e) => onIceCandidate(senderPeer, e);
receiverPeer.onicecandidate = (e) =&gt; onIceCandidate(receiverPeer, e);
  receiverPeer.ontrack = gotRemoteStream;

  stream.getTracks().forEach((track) =&gt; senderPeer.addTrack(track, stream));

  void senderPeer.createOffer(
    onCreateOfferSuccess,
    onCreateSessionDescriptionError,
    offerOptions,
  );
}
}, [senderPeer, receiverPeer]);
const onIceCandidate = (
pc: RTCPeerConnection,
event: RTCPeerConnectionIceEvent,
) => {
if (event.candidate) {
if (senderPeer && receiverPeer) {
void (pc === senderPeer ? receiverPeer : senderPeer).addIceCandidate(
event.candidate,
);
}
}
};
const gotRemoteStream = (event: RTCTrackEvent) => {
const receiverVideo = receiverVideoRef.current;
if (!receiverVideo) {
return;
}
if (receiverVideo.srcObject !== event.streams[0]) {
receiverVideo.srcObject = event.streams[0];
}
};
const onCreateOfferSuccess = (desc: RTCSessionDescriptionInit) => {
if (!senderPeer || !receiverPeer) {
return;
}
void senderPeer.setLocalDescription(desc);
void receiverPeer.setRemoteDescription(desc);
void receiverPeer.createAnswer(
onCreateAnswerSuccess,
onCreateSessionDescriptionError,
);
};
const onCreateSessionDescriptionError = (error: DOMException) => {
console.log(Failed to create session description: ${error.message});
};
const onCreateAnswerSuccess = (desc: RTCSessionDescriptionInit) => {
if (!senderPeer || !receiverPeer) {
return;
}
void receiverPeer.setLocalDescription(desc);
void senderPeer.setRemoteDescription(desc);
};
useEffect(() => {
if (senderVideoRef.current) {
const senderVideo = senderVideoRef.current;
// Video tag capture must be set up after video tracks are enumerated.
senderVideo.oncanplay = maybeCreateStream;
if (senderVideo.readyState >= 3) {
// HAVE_FUTURE_DATA
// Video is already ready to play, call maybeCreateStream in case oncanplay
// fired before we registered the event handler.
maybeCreateStream();
}
if (receiverVideoRef.current) {
const receiverVideo = receiverVideoRef.current;
receiverVideo.onplay = () => setReceiverPlaying(true);
receiverVideo.onpause = () => setReceiverPlaying(false);
senderVideo.onended = () => {
receiverVideo.pause();
};
}
}
}, []);
return <></>;
};

由于我们在这里使用的是 TypeScript,而不是 JavaScript,所以我们不得不做一些调整。除此之外,我们还可以在软件仓库中 video-pc 示例的 main.js 文件中找到相同的代码。

现在,我们应该添加 FC 应返回的元素:

return (
    <>
      <h1>WebRTC Stats + ChartJS React example</h1>
      <h2>WebRTC Peers</h2>
      <div className="peers-container">
        <div className="video-container">
          <h3>Sender video</h3>
          <video ref={senderVideoRef} playsInline controls>
            <source src={chromeWebm} type="video/webm" />
            <source src={chromeMp4} type="video/mp4" />
            <p>This browser does not support the video element.</p>
          </video>
        </div>
&lt;div className="video-container"&gt;
      &lt;h3&gt;Receiver video&lt;/h3&gt;
      &lt;video ref={receiverVideoRef} playsInline controls /&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/&gt;
);

现在让我们打开App.tsx并将其中的代码替换为以下内容:

const App: FC = () => { 
  return ( 
    <div className="App"> 
      <WebRTCStats /> 
    </div> 
  ); 
};
导出默认应用程序;这样,我们就可以在页面中获得两个视频:
使用 ChartJS 和 React 显示 WebRTC 统计 API 数据图表

我们可以同时播放两段视频,并看到发送方视频中的数据流能被接收方视频正确显示。正确地显示在接收端视频中。到目前为止一切顺利。

为统计数据添加辅助函数、钩子和上下文

我们从统计 API 收到统计数据后统计数据的方法。首先,我们可以添加一个新文件,其中包含一些
辅助函数:

export const STATS_SECTIONS = {
  video: "Video",
  audio: "Audio",
};
export const statSectionsMap = new Map(Object.entries(STATS_SECTIONS));
export const STATS_LABELS = {
bytesReceived: "Data received",
fps: "FPS",
decodeTime: "Decode time",
jitter: "Jitter",
packetsReceived: "Packets received",
packetsLost: "Packets lost",
totalSamplesReceived: "Total samples received",
};
export const statsLabelsMap = new Map(Object.entries(STATS_LABELS));
export const STATS_UNITS: Record<string, string> = {
mB: "MB",
ms: "ms",
fps: "FPS",
packets: "packets",
};
export const MB_STATS = ["video-datareceived", "audio-datareceived"];
export const MS_STATS = ["video-decodetime", "video-jitter", "audio-jitter"];
export const formatMb = (v: number) => {
return +(v / 1000 / 1000).toFixed(2);
};
export const formatMs = (v: number) => {
return +(v * 1000).toFixed(2);
};
export const formatValue = (value: number, unit?: string) => {
switch (unit) {
case STATS_UNITS.mB:
return formatMb(value);
case STATS_UNITS.ms:
return formatMs(value);
default:
return value;
}
};
export const getUnitFromGraphId = (graphId: string) => {
if (MB_STATS.includes(graphId)) {
return STATS_UNITS.mB;
} else if (MS_STATS.includes(graphId)) {
return STATS_UNITS.ms;
}
};
export const getFormattedValueFromGraphId = (
graphId: string,
value: number,
) => {
const unit = getUnitFromGraphId(graphId);
return formatValue(value, unit);
};
export const graphTitleToId = (title: string) => {
return title.toLowerCase().replace(/ /g, "");
};
export const sectionAndNameToId = (section: string, name: string) => {
return graphTitleToId(${section}-${name});
};

这些将在很多地方使用。我们还要创建一个可以在组件之间共享的 Context,以处理对用于收集和更新统计数据的数据对象的一些常用操作:

export const STATS_TIME_WINDOW = 5 * 60 * 1000; // ms (5 minutes)
export const STATS_UPDATE_INTERVAL = 3000; // ms
interface StatsContextProps {
statsHistory: StatsHistory;
updateStats: (newStats: Stats) => void;
resetStatsHistory: () => void;
}
const initialState: StatsContextProps = {
statsHistory: {},
updateStats: () => undefined,
resetStatsHistory: () => undefined,
};
export const StatsContext = createContext<StatsContextProps>(initialState);
interface StatsProviderProps {
children: ReactNode;
}
const StatsProvider: FC<StatsProviderProps> = ({ children }) => {
const [statsHistory, setStatsHistory] = useState<StatsHistory>({});
const [statsVersion, setStatsVersion] = useState(0);
const resetStatsHistory = () => {
setStatsHistory({});
};
const updateStats = (newStats: Stats) => {
const now = Date.now();
const discardThreshold = now - STATS_TIME_WINDOW;
Object.entries(newStats).forEach(([statSectionKey, statSectionValue]) =&gt; {
  Object.entries(statSectionValue).forEach(([statKey, statValue]) =&gt; {
    const section = statSectionsMap.get(statSectionKey) ?? "";
    const name = statsLabelsMap.get(statKey) ?? "";
    if (!section || !name) {
      return;
    }
    const graphId = sectionAndNameToId(section, name);

    if (!(graphId in statsHistory)) {
      statsHistory[graphId] = {
        dataPoints: [],
      };
    }

    statsHistory[graphId].dataPoints.push({
      timestamp: now,
      value: getFormattedValueFromGraphId(graphId, statValue as number),
    });

    statsHistory[graphId].dataPoints = statsHistory[
      graphId
    ].dataPoints.filter(
      (dataPoint) =&gt; dataPoint.timestamp &gt; discardThreshold,
    );
  });
});

if (now &gt; statsVersion + STATS_UPDATE_INTERVAL) {
  setStatsVersion(now);
}
};
return (
<StatsContext.Provider
value={{
statsHistory,
updateStats,
resetStatsHistory,
}}
>
{children}
</StatsContext.Provider>
);
};
导出默认的 StatsProvider;然后我们只需使用钩子提供对 Context 返回值的访问:
export default function useStats() {
  return useContext(StatsContext);
}

我们现在可以将StatsProvider包装在App.tsx文件中的WebRTCStats组件周围:

const App: FC = () => { 
  return ( 
    <div className="App"> 
      <StatsProvider> 
        <WebRTCStats /> 
      </StatsProvider> 
    </div> 
  ); 
};

在功能方面,我们的应用程序尚未发生任何变化,因为我们刚刚建立了一种解析统计数据的机制。现在我们需要添加一个在 Context 中调用updateStats函数的组件,以添加我们从统计 API 轮询的新值。

为统计数据添加封装组件

interface Props {
  targetPeer: RTCPeerConnection;
  isPlaying: boolean;
}
const StatsGraphs: FC<Props> = ({ targetPeer, isPlaying }) => {
const { updateStats } = useStats();
setInterval(() => {
if (isPlaying) {
targetPeer.getStats(null).then(onStatsUpdate, (err) => console.log(err));
}
}, STATS_UPDATE_INTERVAL);
const onStatsUpdate = (statsReport: RTCStatsReport) => {
const stats = {} as Stats;
statsReport.forEach((report) => {
if (report.type !== "inbound-rtp") {
return;
}
if (report.kind === "video") {
stats.video = {
fps: report.framesPerSecond,
bytesReceived: report.bytesReceived,
jitter: report.jitter,
decodeTime: report.totalDecodeTime,
packetsReceived: report.packetsReceived,
packetsLost: report.packetsLost,
};
}
if (report.kind === "audio") {
stats.audio = {
bytesReceived: report.bytesReceived,
jitter: report.jitter,
totalSamplesReceived: report.totalSamplesReceived,
packetsReceived: report.packetsReceived,
packetsLost: report.packetsLost,
};
}
});
updateStats(stats);
};
return <></>;
};

导出默认的统计图;您可以看到,我们创建了一个函数,该函数仅从RTCStatsReport地图中
提取我们关心的数据。我们使用之前创建的Stats类型解析此数据,然后将其发送到我们的 Context 函数以添加我们刚刚收到的最新统计数据集。

为图形添加组件并将所有组件串联起来

interface Props {
  item: GraphItem;
}
const StatsGraph: FC<Props> = ({ item }: Props) => {
const { statsHistory } = useStats();
const chartRef: MutableRefObject<ChartJS<
"line",
number[],
number | undefined
> | null> = useRef(null);
const graphId = graphTitleToId(item.title);
if (!(graphId in statsHistory)) {
return null;
}
const dataPoints = statsHistory[graphId].dataPoints;
const values = dataPoints.map((dataPoint) => dataPoint.value);
const now = Date.now();
const graphOptions = {
animation: {
duration: 0,
},
borderColor: "rgb(15, 149, 161)",
interaction: {
intersect: false,
mode: "index",
},
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: (context: TooltipItem<"line">) => {
return ${item.title}: ${context.parsed.y}${ item.unit ? ${item.unit}: "" };
},
},
displayColors: false,
},
},
responsive: true,
scales: {
x: {
min: now - STATS_TIME_WINDOW,
max: now,
ticks: {
autoSkipPadding: 15,
maxRotation: 0,
},
time: {
unit: "minute",
},
type: "time",
},
y: {
ticks: {
callback: (value: number, _index: number, ticks: Tick[]) => {
if (value < 0) {
return null;
}
if (values.every(isInteger)) {
          return isInteger(value) || ticks.length === 1
            ? Math.round(value)
            : null;
        }

        return Math.round((value + Number.EPSILON) * 100) / 100;
      },
    },
    title: {
      display: Boolean(item.unit),
      text: item.unit,
    },
  },
},
};
ChartJS.register(TimeScale, LinearScale, PointElement, LineElement, Tooltip);
return (
<div>
{item.title}
<div className="graph-wrapper">
<Line
ref={chartRef}
className="graph-canvas"
options={graphOptions}
datasetIdKey={item.title}
data={{
labels: dataPoints.map((dataPoint) => dataPoint.timestamp),
datasets: [
{
data: values,
pointRadius: 1,
},
],
}}
/>
</div>
</div>
);
};

导出默认的统计图;需要注意的是,我们使用GraphItem标题来构建键 ( graphId ),以从存储在 Context 中的statsHistory对象中提取数据。记住这一点,我们可以添加一个返回到父级StatsGraphs组件,如下所示:

return (
    <>
      {isPlaying ? (
        <>
          <h3>Video</h3>
          <div className="graphs-row">
            <StatsGraph
              item={{ title: "Video - Data received", unit: STATS_UNITS.mB }}
            />
            <StatsGraph
              item={{ title: "Video - Jitter", unit: STATS_UNITS.ms }}
            />
            <StatsGraph
              item={{ title: "Video - FPS", unit: STATS_UNITS.fps }}
            />
          </div>
          <h3>Audio</h3>
          <div className="graphs-row">
            <StatsGraph
              item={{ title: "Audio - Data received", unit: STATS_UNITS.mB }}
            />
            <StatsGraph
              item={{ title: "Audio - Jitter", unit: STATS_UNITS.ms }}
            />
            <StatsGraph item={{ title: "Audio - Total samples received" }} />
          </div>
        </>
      ) : (
        <>Hit the play button on both videos to see some stats graphs</>
      )}
    </>
  );

现在让我们修改 WebRTCStats 组件的返回块,使其同时包含StatsGraphs 组件:

return (
    <>
      <h1>WebRTC Stats + ChartJS React example</h1>
      <h2>WebRTC Peers</h2>
      <div className="peers-container">
        <div className="video-container">
          <h3>Sender video</h3>
          <video ref={senderVideoRef} playsInline controls>
            <source src={chromeWebm} type="video/webm" />
            <source src={chromeMp4} type="video/mp4" />
            <p>This browser does not support the video element.</p>
          </video>
        </div>
&lt;div className="video-container"&gt;
      &lt;h3&gt;Receiver video&lt;/h3&gt;
      &lt;video ref={receiverVideoRef} playsInline controls /&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;h2&gt;Receiver Stats&lt;/h2&gt;
  {receiverPeer ? (
    &lt;StatsGraphs targetPeer={receiverPeer} isPlaying={isReceiverPlaying} /&gt;
  ) : (
    &lt;p&gt;Receiver peer not connected.&lt;/p&gt;
  )}
&lt;/&gt;
);

完成此操作后,应用程序就准备好了。当我们点击两个视频的播放按钮时,统计数据将在几秒钟后开始显示,并定期更新。通过我们传递给 ChartJS 的配置(StatsGraph组件中的graphOptions对象),我们还设置了自定义悬停行为,当我们将鼠标悬停在单个数据点上时,该行为将显示详细信息。

使用 ChartJS 和 React 显示 WebRTC 统计 API 数据图表

总结

在本教程中,我们了解了如何使用 WebRTC Statistics API 提供的数据显示一些漂亮的图表。希望您在阅读完本教程后,能更加熟悉 WebRTC、其统计 API、基于 JS 的数据可视化工具(如 ChartJS)以及如何将它们嵌入到用 TypeScript 编写的 React 应用程序中。

作者:Litrux

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(1)

相关推荐

发表回复

登录后才能评论