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 的使用方式。
这种实时更新的文本数据负载对用户来说并不友好。那么用一些漂亮的图表来显示这些信息如何?这就是功能强大的 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) => onIceCandidate(receiverPeer, e);
receiverPeer.ontrack = gotRemoteStream;
stream.getTracks().forEach((track) => 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>
<div className="video-container">
<h3>Receiver video</h3>
<video ref={receiverVideoRef} playsInline controls />
</div>
</div>
</>
);
现在让我们打开App.tsx并将其中的代码替换为以下内容:
const App: FC = () => {
return (
<div className="App">
<WebRTCStats />
</div>
);
};
导出默认应用程序;这样,我们就可以在页面中获得两个视频:
我们可以同时播放两段视频,并看到发送方视频中的数据流能被接收方视频正确显示。正确地显示在接收端视频中。到目前为止一切顺利。
为统计数据添加辅助函数、钩子和上下文
我们从统计 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]) => {
Object.entries(statSectionValue).forEach(([statKey, statValue]) => {
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) => dataPoint.timestamp > discardThreshold,
);
});
});
if (now > 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>
<div className="video-container">
<h3>Receiver video</h3>
<video ref={receiverVideoRef} playsInline controls />
</div>
</div>
<h2>Receiver Stats</h2>
{receiverPeer ? (
<StatsGraphs targetPeer={receiverPeer} isPlaying={isReceiverPlaying} />
) : (
<p>Receiver peer not connected.</p>
)}
</>
);
完成此操作后,应用程序就准备好了。当我们点击两个视频的播放按钮时,统计数据将在几秒钟后开始显示,并定期更新。通过我们传递给 ChartJS 的配置(StatsGraph组件中的graphOptions对象),我们还设置了自定义悬停行为,当我们将鼠标悬停在单个数据点上时,该行为将显示详细信息。
总结
在本教程中,我们了解了如何使用 WebRTC Statistics API 提供的数据显示一些漂亮的图表。希望您在阅读完本教程后,能更加熟悉 WebRTC、其统计 API、基于 JS 的数据可视化工具(如 ChartJS)以及如何将它们嵌入到用 TypeScript 编写的 React 应用程序中。
作者:Litrux
版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。