Skip to content

Commit dddb083

Browse files
committed
feat(webui/alone-player): add stats
1 parent b44e4dd commit dddb083

5 files changed

Lines changed: 255 additions & 2 deletions

File tree

web/alone-player/player.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,44 @@ video {
1212
video {
1313
display: block;
1414
}
15+
16+
:root {
17+
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
18+
line-height: 1.5;
19+
font-weight: 400;
20+
21+
color-scheme: light dark;
22+
color: rgba(255, 255, 255, 0.87);
23+
background-color: #242424;
24+
25+
font-synthesis: none;
26+
text-rendering: optimizeLegibility;
27+
-webkit-font-smoothing: antialiased;
28+
-moz-osx-font-smoothing: grayscale;
29+
}
30+
31+
.player-wrapper {
32+
position: relative;
33+
}
34+
35+
.stats-container {
36+
position: absolute;
37+
top: 2rem;
38+
left: 50%;
39+
transform: translateX(-50%);
40+
background: rgba(28, 28, 28, 0.8);
41+
border: 1px solid #3c3c3c;
42+
border-radius: 1rem;
43+
padding: 1rem;
44+
color: #fff;
45+
font-size: 13px;
46+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8);
47+
z-index: 999;
48+
}
49+
50+
@media (prefers-color-scheme: light) {
51+
:root {
52+
color: #213547;
53+
background-color: #ffffff;
54+
}
55+
}

web/alone-player/player.tsx

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { WHEPClient } from "@binbat/whip-whep/whep.js";
2-
import { createEffect, createSignal, onCleanup } from "solid-js";
2+
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js";
3+
import type { StatsNerds } from "./types";
34
import "./player.css";
5+
import Stats from "./stats";
46

57
export default () => {
68
const [streamId, setStreamId] = createSignal("");
@@ -10,10 +12,14 @@ export default () => {
1012
const [reconnect, setReconnect] = createSignal(0);
1113
const [token, setToken] = createSignal("");
1214

15+
const [statsNerds, setStatsNerds] = createSignal<StatsNerds | null>(null);
16+
1317
let videoRef: HTMLVideoElement | undefined;
1418
let peerConnectionRef: RTCPeerConnection | null = null;
1519
let whepClientRef: WHEPClient | null = null;
1620

21+
let statsInterval: ReturnType<typeof setInterval> | null = null;
22+
1723
createEffect(() => {
1824
const params = new URLSearchParams(location.search);
1925
setStreamId(params.get("id") ?? "");
@@ -99,17 +105,88 @@ export default () => {
99105

100106
onCleanup(() => {
101107
handleStop();
108+
statsInterval && clearInterval(statsInterval);
109+
});
110+
111+
onMount(() => {
112+
videoRef?.addEventListener("contextmenu", startSyncStats);
113+
});
114+
115+
onCleanup(() => {
116+
videoRef?.removeEventListener("contextmenu", startSyncStats);
102117
});
103118

119+
function startSyncStats() {
120+
statsInterval = setInterval(async () => {
121+
if (!peerConnectionRef) return;
122+
123+
let tmpStats: StatsNerds = {
124+
bytesReceived: 0,
125+
bytesSent: 0,
126+
currentRoundTripTime: 0,
127+
};
128+
129+
const stats = await peerConnectionRef?.getStats();
130+
stats.forEach((report) => {
131+
if (report.type === "transport") {
132+
tmpStats.bytesReceived = report.bytesReceived ?? 0;
133+
tmpStats.bytesSent = report.bytesSent ?? 0;
134+
} else if (report.type === "codec") {
135+
const [kind, codec] = report.mimeType
136+
.toLowerCase()
137+
.split("/");
138+
if (kind === "video") {
139+
tmpStats.vcodec = `${codec}@${report.sdpFmtpLine ?? ""}`;
140+
} else if (kind === "audio") {
141+
tmpStats.acodec = `${codec}@${report.sdpFmtpLine ?? ""}`;
142+
} else {
143+
console.log("Unknown mimeType", report.mimeType);
144+
}
145+
} else if (
146+
report.type === "candidate-pair" &&
147+
report.nominated
148+
) {
149+
tmpStats.currentRoundTripTime =
150+
report.currentRoundTripTime ?? 0;
151+
}
152+
153+
if (report.type === "inbound-rtp" && report.kind === "video") {
154+
tmpStats.frameWidth = report.frameWidth;
155+
tmpStats.frameHeight = report.frameHeight;
156+
tmpStats.framesPerSecond = report.framesPerSecond;
157+
}
158+
159+
if (report.type === "inbound-rtp" && report.kind === "audio") {
160+
tmpStats.audioLevel = report.audioLevel;
161+
}
162+
});
163+
setStatsNerds(tmpStats);
164+
}, 1000);
165+
}
166+
167+
function stopSyncStats() {
168+
if (statsInterval) {
169+
clearInterval(statsInterval);
170+
}
171+
setStatsNerds(null);
172+
}
173+
104174
return (
105-
<div id="player">
175+
<div id="player" class="player-wrapper">
106176
<video
107177
ref={videoRef}
108178
autoplay={autoPlay()}
109179
muted={muted()}
110180
controls={controls()}
111181
onClick={handleVideoClick}
112182
/>
183+
<Show when={statsNerds()}>
184+
{(stats) => (
185+
<div class="stats-container" id="stats">
186+
<Stats stats={stats()} onClose={stopSyncStats} />
187+
</div>
188+
)}
189+
</Show>
113190
</div>
114191
);
115192
};

web/alone-player/stats.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.title {
2+
display: flex;
3+
justify-content: space-between;
4+
}
5+
6+
.btn {
7+
cursor: pointer;
8+
}
9+
10+
.stats {
11+
display: grid;
12+
grid-template-columns: max-content 1fr;
13+
gap: 0.4rem 1rem;
14+
min-width: 300px;
15+
max-width: 600px;
16+
color: #fff;
17+
font-family: Roboto, Arial, sans-serif;
18+
font-size: 13px;
19+
}
20+
21+
.stats dt {
22+
text-align: right;
23+
color: #aaa;
24+
white-space: nowrap;
25+
}
26+
27+
.stats dd {
28+
text-align: left;
29+
margin: 0;
30+
color: #fff;
31+
word-break: break-all;
32+
}

web/alone-player/stats.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { StatsNerds } from "./types";
2+
import type { Component } from "solid-js";
3+
import "./stats.css";
4+
5+
interface Props {
6+
stats: StatsNerds;
7+
onClose: () => void;
8+
}
9+
10+
const StatsForNerds: Component<Props> = (props) => {
11+
const formatBytes = (bytes: number): string => {
12+
if (bytes === 0) return "0 B";
13+
const k = 1024;
14+
const sizes = ["B", "KB", "MB", "GB"];
15+
const i = Math.floor(Math.log(bytes) / Math.log(k));
16+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
17+
};
18+
19+
const convertSecondsToMilliseconds = (seconds: number): number => {
20+
return seconds ? Math.round(seconds * 1000) : 0;
21+
};
22+
23+
return (
24+
<>
25+
<div class="title">
26+
<div>Stats for nerds</div>
27+
<span class="btn" onClick={() => props.onClose()}>
28+
[X]
29+
</span>
30+
</div>
31+
32+
<dl class="stats">
33+
<dt>Received: </dt>
34+
<dd>{formatBytes(props.stats.bytesReceived)}</dd>
35+
36+
<dt>Sent: </dt>
37+
<dd>{formatBytes(props.stats.bytesSent)}</dd>
38+
39+
<dt>Round Trip Time: </dt>
40+
<dd>
41+
{convertSecondsToMilliseconds(
42+
props.stats.currentRoundTripTime,
43+
)}
44+
ms
45+
</dd>
46+
47+
<dt>Video Codec: </dt>
48+
<dd>{props.stats.vcodec ?? "-"}</dd>
49+
50+
<dt>Audio Codec: </dt>
51+
<dd>{props.stats.acodec ?? "-"}</dd>
52+
53+
<dt>Video Resolution: </dt>
54+
<dd>{`${props.stats.frameWidth}x${props.stats.frameHeight}@${props.stats.framesPerSecond}`}</dd>
55+
56+
<dt>Audio volume: </dt>
57+
<dd>
58+
{props.stats.muted
59+
? "muted"
60+
: props.stats.audioLevel?.toFixed(2)}
61+
</dd>
62+
</dl>
63+
</>
64+
);
65+
};
66+
67+
export default StatsForNerds;

web/alone-player/types.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
type StatsNerds = {
2+
// transport
3+
bytesReceived: number;
4+
bytesSent: number;
5+
6+
// candidate-pair && nominated
7+
currentRoundTripTime: number;
8+
9+
// codec && video
10+
// id: "CIT01_45_level-idx=5;profile=0;tier=0"
11+
// mimeType: "video/AV1"
12+
// payloadType: 45
13+
// sdpFmtpLine: "level-idx=5;profile=0;tier=0"
14+
vcodec?: string;
15+
// codec && audio
16+
// mimeType: "audio/opus"
17+
// payloadType: 111
18+
// sdpFmtpLine: "minptime=10;useinbandfec=1"
19+
acodec?: string;
20+
21+
// inbound-rtp && video
22+
frameWidth?: number;
23+
frameHeight?: number;
24+
framesPerSecond?: number;
25+
26+
// inbound-rtp && audio
27+
// The <video> muted is stop decode audio
28+
// Because, muted `audioLevel` always `0`
29+
muted?: boolean;
30+
audioLevel?: number;
31+
32+
// outbound-rtp && video
33+
// outbound-rtp && audio
34+
};
35+
36+
export type { StatsNerds };

0 commit comments

Comments
 (0)