@livepeer-frameworks/player-react 0.0.4 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/types/components/PlayerControls.d.ts +2 -0
- package/dist/types/components/StatsPanel.d.ts +2 -14
- package/dist/types/hooks/useMetaTrack.d.ts +1 -1
- package/dist/types/hooks/usePlayerController.d.ts +2 -0
- package/dist/types/hooks/useStreamState.d.ts +1 -1
- package/dist/types/hooks/useTelemetry.d.ts +1 -1
- package/dist/types/hooks/useViewerEndpoints.d.ts +2 -2
- package/dist/types/types.d.ts +1 -1
- package/dist/types/ui/button.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/DevModePanel.tsx +249 -170
- package/src/components/Icons.tsx +105 -25
- package/src/components/IdleScreen.tsx +262 -142
- package/src/components/LoadingScreen.tsx +171 -153
- package/src/components/LogoOverlay.tsx +3 -6
- package/src/components/Player.tsx +86 -74
- package/src/components/PlayerControls.tsx +351 -263
- package/src/components/PlayerErrorBoundary.tsx +6 -13
- package/src/components/SeekBar.tsx +96 -88
- package/src/components/SkipIndicator.tsx +2 -12
- package/src/components/SpeedIndicator.tsx +2 -11
- package/src/components/StatsPanel.tsx +65 -34
- package/src/components/StreamStateOverlay.tsx +105 -49
- package/src/components/SubtitleRenderer.tsx +29 -29
- package/src/components/ThumbnailOverlay.tsx +5 -6
- package/src/components/TitleOverlay.tsx +2 -8
- package/src/components/players/DashJsPlayer.tsx +13 -11
- package/src/components/players/HlsJsPlayer.tsx +13 -11
- package/src/components/players/MewsWsPlayer/index.tsx +13 -11
- package/src/components/players/MistPlayer.tsx +13 -11
- package/src/components/players/MistWebRTCPlayer/index.tsx +19 -10
- package/src/components/players/NativePlayer.tsx +10 -12
- package/src/components/players/VideoJsPlayer.tsx +13 -11
- package/src/context/PlayerContext.tsx +4 -8
- package/src/context/index.ts +3 -3
- package/src/hooks/useMetaTrack.ts +28 -28
- package/src/hooks/usePlaybackQuality.ts +3 -3
- package/src/hooks/usePlayerController.ts +186 -140
- package/src/hooks/usePlayerSelection.ts +6 -6
- package/src/hooks/useStreamState.ts +53 -58
- package/src/hooks/useTelemetry.ts +19 -4
- package/src/hooks/useViewerEndpoints.ts +40 -30
- package/src/index.tsx +36 -28
- package/src/types.ts +9 -9
- package/src/ui/badge.tsx +6 -5
- package/src/ui/button.tsx +9 -8
- package/src/ui/context-menu.tsx +42 -61
- package/src/ui/select.tsx +13 -7
- package/src/ui/slider.tsx +18 -29
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { Component, ErrorInfo, ReactNode } from
|
|
2
|
-
import { Button } from
|
|
1
|
+
import React, { Component, ErrorInfo, ReactNode } from "react";
|
|
2
|
+
import { Button } from "../ui/button";
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
children: ReactNode;
|
|
@@ -28,7 +28,7 @@ class PlayerErrorBoundary extends Component<Props, State> {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
31
|
-
console.error(
|
|
31
|
+
console.error("[PlayerErrorBoundary] Caught error:", error, errorInfo);
|
|
32
32
|
this.props.onError?.(error, errorInfo);
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -45,18 +45,11 @@ class PlayerErrorBoundary extends Component<Props, State> {
|
|
|
45
45
|
|
|
46
46
|
return (
|
|
47
47
|
<div className="fw-player-error flex min-h-[280px] flex-col items-center justify-center gap-4 rounded-xl bg-slate-950 p-6 text-center text-white">
|
|
48
|
-
<div className="text-lg font-semibold text-red-400">
|
|
49
|
-
Playback Error
|
|
50
|
-
</div>
|
|
48
|
+
<div className="text-lg font-semibold text-red-400">Playback Error</div>
|
|
51
49
|
<p className="max-w-sm text-sm text-slate-400">
|
|
52
|
-
{this.state.error?.message ||
|
|
50
|
+
{this.state.error?.message || "An unexpected error occurred while loading the player."}
|
|
53
51
|
</p>
|
|
54
|
-
<Button
|
|
55
|
-
type="button"
|
|
56
|
-
variant="secondary"
|
|
57
|
-
onClick={this.handleRetry}
|
|
58
|
-
className="mt-2"
|
|
59
|
-
>
|
|
52
|
+
<Button type="button" variant="secondary" onClick={this.handleRetry} className="mt-2">
|
|
60
53
|
Try Again
|
|
61
54
|
</Button>
|
|
62
55
|
</div>
|
|
@@ -127,97 +127,109 @@ const SeekBar: React.FC<SeekBarProps> = ({
|
|
|
127
127
|
|
|
128
128
|
// Calculate time from mouse position
|
|
129
129
|
// For live: maps position to time within DVR window
|
|
130
|
-
const getTimeFromPosition = useCallback(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
const getTimeFromPosition = useCallback(
|
|
131
|
+
(clientX: number): number => {
|
|
132
|
+
if (!trackRef.current) return 0;
|
|
133
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
134
|
+
const x = clientX - rect.left;
|
|
135
|
+
const percent = Math.min(1, Math.max(0, x / rect.width));
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
// Live with valid seekable window
|
|
138
|
+
if (isLive && Number.isFinite(seekableWindow) && seekableWindow > 0) {
|
|
139
|
+
return seekableStart + percent * seekableWindow;
|
|
140
|
+
}
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
// VOD with finite duration
|
|
143
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
144
|
+
return percent * duration;
|
|
145
|
+
}
|
|
145
146
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
// Fallback: If we have liveEdge, use it even if not marked as live
|
|
148
|
+
// This handles cases where duration is Infinity but we have valid seekable data
|
|
149
|
+
if (liveEdge !== undefined && Number.isFinite(liveEdge) && liveEdge > 0) {
|
|
150
|
+
const start = Number.isFinite(seekableStart) ? seekableStart : 0;
|
|
151
|
+
const window = liveEdge - start;
|
|
152
|
+
if (window > 0) {
|
|
153
|
+
return start + percent * window;
|
|
154
|
+
}
|
|
153
155
|
}
|
|
154
|
-
}
|
|
155
156
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// Last resort: use currentTime as a baseline
|
|
158
|
+
return percent * (currentTime || 1);
|
|
159
|
+
},
|
|
160
|
+
[duration, isLive, seekableStart, seekableWindow, liveEdge, currentTime]
|
|
161
|
+
);
|
|
159
162
|
|
|
160
163
|
// Handle mouse move for hover preview
|
|
161
|
-
const handleMouseMove = useCallback(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
164
|
+
const handleMouseMove = useCallback(
|
|
165
|
+
(e: React.MouseEvent) => {
|
|
166
|
+
if (!trackRef.current || disabled) return;
|
|
167
|
+
const rect = trackRef.current.getBoundingClientRect();
|
|
168
|
+
const x = e.clientX - rect.left;
|
|
169
|
+
const percent = Math.min(1, Math.max(0, x / rect.width));
|
|
170
|
+
setHoverPosition(percent * 100);
|
|
171
|
+
setHoverTime(getTimeFromPosition(e.clientX));
|
|
172
|
+
},
|
|
173
|
+
[disabled, getTimeFromPosition]
|
|
174
|
+
);
|
|
169
175
|
|
|
170
176
|
// Handle click to seek
|
|
171
|
-
const handleClick = useCallback(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
const handleClick = useCallback(
|
|
178
|
+
(e: React.MouseEvent) => {
|
|
179
|
+
if (disabled) return;
|
|
180
|
+
if (!isLive && !Number.isFinite(duration)) return;
|
|
181
|
+
const time = getTimeFromPosition(e.clientX);
|
|
182
|
+
onSeek?.(time);
|
|
183
|
+
setDragTime(null);
|
|
184
|
+
dragTimeRef.current = null;
|
|
185
|
+
},
|
|
186
|
+
[disabled, duration, isLive, getTimeFromPosition, onSeek]
|
|
187
|
+
);
|
|
179
188
|
|
|
180
189
|
// Handle drag start
|
|
181
|
-
const handleMouseDown = useCallback(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
190
|
+
const handleMouseDown = useCallback(
|
|
191
|
+
(e: React.MouseEvent) => {
|
|
192
|
+
if (disabled) return;
|
|
193
|
+
if (!isLive && !Number.isFinite(duration)) return;
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
setIsDragging(true);
|
|
196
|
+
|
|
197
|
+
const handleDragMove = (moveEvent: MouseEvent) => {
|
|
198
|
+
const time = getTimeFromPosition(moveEvent.clientX);
|
|
199
|
+
if (commitOnRelease) {
|
|
200
|
+
setDragTime(time);
|
|
201
|
+
dragTimeRef.current = time;
|
|
202
|
+
} else {
|
|
203
|
+
onSeek?.(time);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
186
206
|
|
|
187
|
-
|
|
188
|
-
|
|
207
|
+
const handleDragEnd = () => {
|
|
208
|
+
setIsDragging(false);
|
|
209
|
+
document.removeEventListener("mousemove", handleDragMove);
|
|
210
|
+
document.removeEventListener("mouseup", handleDragEnd);
|
|
211
|
+
const pending = dragTimeRef.current;
|
|
212
|
+
if (commitOnRelease && pending !== null) {
|
|
213
|
+
onSeek?.(pending);
|
|
214
|
+
setDragTime(null);
|
|
215
|
+
dragTimeRef.current = null;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
document.addEventListener("mousemove", handleDragMove);
|
|
220
|
+
document.addEventListener("mouseup", handleDragEnd);
|
|
221
|
+
|
|
222
|
+
// Initial seek
|
|
223
|
+
const time = getTimeFromPosition(e.clientX);
|
|
189
224
|
if (commitOnRelease) {
|
|
190
225
|
setDragTime(time);
|
|
191
226
|
dragTimeRef.current = time;
|
|
192
227
|
} else {
|
|
193
228
|
onSeek?.(time);
|
|
194
229
|
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
setIsDragging(false);
|
|
199
|
-
document.removeEventListener("mousemove", handleDragMove);
|
|
200
|
-
document.removeEventListener("mouseup", handleDragEnd);
|
|
201
|
-
const pending = dragTimeRef.current;
|
|
202
|
-
if (commitOnRelease && pending !== null) {
|
|
203
|
-
onSeek?.(pending);
|
|
204
|
-
setDragTime(null);
|
|
205
|
-
dragTimeRef.current = null;
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
document.addEventListener("mousemove", handleDragMove);
|
|
210
|
-
document.addEventListener("mouseup", handleDragEnd);
|
|
211
|
-
|
|
212
|
-
// Initial seek
|
|
213
|
-
const time = getTimeFromPosition(e.clientX);
|
|
214
|
-
if (commitOnRelease) {
|
|
215
|
-
setDragTime(time);
|
|
216
|
-
dragTimeRef.current = time;
|
|
217
|
-
} else {
|
|
218
|
-
onSeek?.(time);
|
|
219
|
-
}
|
|
220
|
-
}, [disabled, duration, isLive, getTimeFromPosition, onSeek, commitOnRelease]);
|
|
230
|
+
},
|
|
231
|
+
[disabled, duration, isLive, getTimeFromPosition, onSeek, commitOnRelease]
|
|
232
|
+
);
|
|
221
233
|
|
|
222
234
|
const showThumb = isHovering || isDragging;
|
|
223
235
|
const canShowTooltip = isLive ? seekableWindow > 0 : Number.isFinite(duration);
|
|
@@ -231,23 +243,25 @@ const SeekBar: React.FC<SeekBarProps> = ({
|
|
|
231
243
|
className
|
|
232
244
|
)}
|
|
233
245
|
onMouseEnter={() => !disabled && setIsHovering(true)}
|
|
234
|
-
onMouseLeave={() => {
|
|
246
|
+
onMouseLeave={() => {
|
|
247
|
+
setIsHovering(false);
|
|
248
|
+
setIsDragging(false);
|
|
249
|
+
}}
|
|
235
250
|
onMouseMove={handleMouseMove}
|
|
236
251
|
onClick={handleClick}
|
|
237
252
|
onMouseDown={handleMouseDown}
|
|
238
253
|
role="slider"
|
|
239
254
|
aria-label="Seek"
|
|
240
255
|
aria-valuemin={isLive ? seekableStart : 0}
|
|
241
|
-
aria-valuemax={isLive ? effectiveLiveEdge :
|
|
256
|
+
aria-valuemax={isLive ? effectiveLiveEdge : duration || 100}
|
|
242
257
|
aria-valuenow={displayTime}
|
|
243
|
-
aria-valuetext={
|
|
258
|
+
aria-valuetext={
|
|
259
|
+
isLive ? formatLiveTime(displayTime, effectiveLiveEdge) : formatTime(displayTime)
|
|
260
|
+
}
|
|
244
261
|
tabIndex={disabled ? -1 : 0}
|
|
245
262
|
>
|
|
246
263
|
{/* Track background */}
|
|
247
|
-
<div className={cn(
|
|
248
|
-
"fw-seek-track",
|
|
249
|
-
isDragging && "fw-seek-track--active"
|
|
250
|
-
)}>
|
|
264
|
+
<div className={cn("fw-seek-track", isDragging && "fw-seek-track--active")}>
|
|
251
265
|
{/* Buffered segments - show actual buffered ranges */}
|
|
252
266
|
{bufferedSegments.map((segment, index) => (
|
|
253
267
|
<div
|
|
@@ -260,10 +274,7 @@ const SeekBar: React.FC<SeekBarProps> = ({
|
|
|
260
274
|
/>
|
|
261
275
|
))}
|
|
262
276
|
{/* Playback progress */}
|
|
263
|
-
<div
|
|
264
|
-
className="fw-seek-progress"
|
|
265
|
-
style={{ width: `${progressPercent}%` }}
|
|
266
|
-
/>
|
|
277
|
+
<div className="fw-seek-progress" style={{ width: `${progressPercent}%` }} />
|
|
267
278
|
</div>
|
|
268
279
|
|
|
269
280
|
{/* Thumb */}
|
|
@@ -277,10 +288,7 @@ const SeekBar: React.FC<SeekBarProps> = ({
|
|
|
277
288
|
|
|
278
289
|
{/* Hover time tooltip */}
|
|
279
290
|
{isHovering && !isDragging && canShowTooltip && (
|
|
280
|
-
<div
|
|
281
|
-
className="fw-seek-tooltip"
|
|
282
|
-
style={{ left: `${hoverPosition}%` }}
|
|
283
|
-
>
|
|
291
|
+
<div className="fw-seek-tooltip" style={{ left: `${hoverPosition}%` }}>
|
|
284
292
|
{isLive ? formatLiveTime(hoverTime, effectiveLiveEdge) : formatTime(hoverTime)}
|
|
285
293
|
</div>
|
|
286
294
|
)}
|
|
@@ -89,23 +89,13 @@ const SkipIndicator: React.FC<SkipIndicatorProps> = ({
|
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
const RewindIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
92
|
-
<svg
|
|
93
|
-
viewBox="0 0 24 24"
|
|
94
|
-
fill="currentColor"
|
|
95
|
-
className={className}
|
|
96
|
-
aria-hidden="true"
|
|
97
|
-
>
|
|
92
|
+
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
98
93
|
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
|
|
99
94
|
</svg>
|
|
100
95
|
);
|
|
101
96
|
|
|
102
97
|
const FastForwardIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
103
|
-
<svg
|
|
104
|
-
viewBox="0 0 24 24"
|
|
105
|
-
fill="currentColor"
|
|
106
|
-
className={className}
|
|
107
|
-
aria-hidden="true"
|
|
108
|
-
>
|
|
98
|
+
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
109
99
|
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
|
110
100
|
</svg>
|
|
111
101
|
);
|
|
@@ -11,11 +11,7 @@ interface SpeedIndicatorProps {
|
|
|
11
11
|
* Speed indicator overlay that appears when holding for fast-forward.
|
|
12
12
|
* Shows the current playback speed (e.g., "2x") in a pill overlay.
|
|
13
13
|
*/
|
|
14
|
-
const SpeedIndicator: React.FC<SpeedIndicatorProps> = ({
|
|
15
|
-
isVisible,
|
|
16
|
-
speed,
|
|
17
|
-
className,
|
|
18
|
-
}) => {
|
|
14
|
+
const SpeedIndicator: React.FC<SpeedIndicatorProps> = ({ isVisible, speed, className }) => {
|
|
19
15
|
return (
|
|
20
16
|
<div
|
|
21
17
|
className={cn(
|
|
@@ -44,12 +40,7 @@ const SpeedIndicator: React.FC<SpeedIndicatorProps> = ({
|
|
|
44
40
|
|
|
45
41
|
// Simple fast-forward icon
|
|
46
42
|
const FastForwardIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
47
|
-
<svg
|
|
48
|
-
viewBox="0 0 24 24"
|
|
49
|
-
fill="currentColor"
|
|
50
|
-
className={className}
|
|
51
|
-
aria-hidden="true"
|
|
52
|
-
>
|
|
43
|
+
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
53
44
|
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
|
|
54
45
|
</svg>
|
|
55
46
|
);
|
|
@@ -1,24 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
type: string;
|
|
9
|
-
codec: string;
|
|
10
|
-
width?: number;
|
|
11
|
-
height?: number;
|
|
12
|
-
bps?: number;
|
|
13
|
-
channels?: number;
|
|
14
|
-
}>;
|
|
15
|
-
}
|
|
2
|
+
import {
|
|
3
|
+
cn,
|
|
4
|
+
type ContentMetadata,
|
|
5
|
+
type PlaybackQuality,
|
|
6
|
+
type StreamState,
|
|
7
|
+
} from "@livepeer-frameworks/player-core";
|
|
16
8
|
|
|
17
9
|
interface StatsPanelProps {
|
|
18
10
|
isOpen: boolean;
|
|
19
11
|
onClose: () => void;
|
|
20
12
|
metadata?: ContentMetadata | null;
|
|
21
|
-
streamState?:
|
|
13
|
+
streamState?: StreamState | null;
|
|
22
14
|
quality?: PlaybackQuality | null;
|
|
23
15
|
videoElement?: HTMLVideoElement | null;
|
|
24
16
|
protocol?: string;
|
|
@@ -46,35 +38,62 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
|
46
38
|
// Video element stats
|
|
47
39
|
const video = videoElement;
|
|
48
40
|
const currentRes = video ? `${video.videoWidth}x${video.videoHeight}` : "—";
|
|
49
|
-
const buffered =
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
const buffered =
|
|
42
|
+
video && video.buffered.length > 0
|
|
43
|
+
? (video.buffered.end(video.buffered.length - 1) - video.currentTime).toFixed(1)
|
|
44
|
+
: "—";
|
|
52
45
|
const playbackRate = video?.playbackRate?.toFixed(2) ?? "1.00";
|
|
53
46
|
|
|
54
47
|
// Quality monitor stats
|
|
55
48
|
const qualityScore = quality?.score?.toFixed(0) ?? "—";
|
|
56
|
-
const bitrateKbps = quality?.bitrate
|
|
57
|
-
? `${(quality.bitrate / 1000).toFixed(0)} kbps`
|
|
58
|
-
: "—";
|
|
49
|
+
const bitrateKbps = quality?.bitrate ? `${(quality.bitrate / 1000).toFixed(0)} kbps` : "—";
|
|
59
50
|
const frameDropRate = quality?.frameDropRate?.toFixed(1) ?? "—";
|
|
60
51
|
const stallCount = quality?.stallCount ?? 0;
|
|
61
52
|
const latency = quality?.latency ? `${Math.round(quality.latency)} ms` : "—";
|
|
62
53
|
|
|
63
54
|
// Stream state stats
|
|
64
|
-
const viewers =
|
|
55
|
+
const viewers = metadata?.viewers ?? "—";
|
|
65
56
|
const streamStatus = streamState?.status ?? metadata?.status ?? "—";
|
|
66
57
|
|
|
58
|
+
const mistInfo = metadata?.mist ?? streamState?.streamInfo;
|
|
59
|
+
|
|
60
|
+
const deriveTracksFromMist = () => {
|
|
61
|
+
const mistTracks = mistInfo?.meta?.tracks;
|
|
62
|
+
if (!mistTracks) return undefined;
|
|
63
|
+
return Object.values(mistTracks).map((t) => ({
|
|
64
|
+
type: t.type,
|
|
65
|
+
codec: t.codec,
|
|
66
|
+
width: t.width,
|
|
67
|
+
height: t.height,
|
|
68
|
+
bitrate: typeof t.bps === "number" ? Math.round(t.bps) : undefined,
|
|
69
|
+
fps: typeof t.fpks === "number" ? t.fpks / 1000 : undefined,
|
|
70
|
+
channels: t.channels,
|
|
71
|
+
sampleRate: t.rate,
|
|
72
|
+
}));
|
|
73
|
+
};
|
|
74
|
+
|
|
67
75
|
// Format track info from metadata
|
|
68
76
|
const formatTracks = () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
const tracks = metadata?.tracks ?? deriveTracksFromMist();
|
|
78
|
+
if (!tracks?.length) return "—";
|
|
79
|
+
return tracks
|
|
80
|
+
.map((t) => {
|
|
81
|
+
if (t.type === "video") {
|
|
82
|
+
const resolution = t.width && t.height ? `${t.width}x${t.height}` : "?";
|
|
83
|
+
const bitrate = t.bitrate ? `${Math.round(t.bitrate / 1000)}kbps` : "?";
|
|
84
|
+
return `${t.codec ?? "?"} ${resolution}@${bitrate}`;
|
|
85
|
+
}
|
|
86
|
+
const channels = t.channels ? `${t.channels}ch` : "?";
|
|
87
|
+
return `${t.codec ?? "?"} ${channels}`;
|
|
88
|
+
})
|
|
89
|
+
.join(", ");
|
|
76
90
|
};
|
|
77
91
|
|
|
92
|
+
const mistType = mistInfo?.type ?? "—";
|
|
93
|
+
const mistBufferWindow = mistInfo?.meta?.buffer_window;
|
|
94
|
+
const mistLastMs = mistInfo?.lastms;
|
|
95
|
+
const mistUnixOffset = mistInfo?.unixoffset;
|
|
96
|
+
|
|
78
97
|
const stats = [
|
|
79
98
|
{ label: "Resolution", value: currentRes },
|
|
80
99
|
{ label: "Buffer", value: `${buffered}s` },
|
|
@@ -90,6 +109,13 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
|
90
109
|
{ label: "Viewers", value: String(viewers) },
|
|
91
110
|
{ label: "Status", value: streamStatus },
|
|
92
111
|
{ label: "Tracks", value: formatTracks() },
|
|
112
|
+
{ label: "Mist Type", value: mistType },
|
|
113
|
+
{
|
|
114
|
+
label: "Mist Buffer Window",
|
|
115
|
+
value: mistBufferWindow != null ? String(mistBufferWindow) : "—",
|
|
116
|
+
},
|
|
117
|
+
{ label: "Mist Lastms", value: mistLastMs != null ? String(mistLastMs) : "—" },
|
|
118
|
+
{ label: "Mist Unixoffset", value: mistUnixOffset != null ? String(mistUnixOffset) : "—" },
|
|
93
119
|
];
|
|
94
120
|
|
|
95
121
|
// Add metadata fields if available
|
|
@@ -115,20 +141,25 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
|
115
141
|
"max-w-[320px] max-h-[80%] overflow-auto",
|
|
116
142
|
"shadow-lg"
|
|
117
143
|
)}
|
|
118
|
-
style={{ backgroundColor:
|
|
144
|
+
style={{ backgroundColor: "#000000" }} // Inline fallback for opaque background
|
|
119
145
|
>
|
|
120
146
|
{/* Header */}
|
|
121
147
|
<div className="flex items-center justify-between px-3 py-2 border-b border-white/10">
|
|
122
|
-
<span className="text-white/70 text-[10px] uppercase tracking-wider">
|
|
123
|
-
Stats Overlay
|
|
124
|
-
</span>
|
|
148
|
+
<span className="text-white/70 text-[10px] uppercase tracking-wider">Stats Overlay</span>
|
|
125
149
|
<button
|
|
126
150
|
type="button"
|
|
127
151
|
onClick={onClose}
|
|
128
152
|
className="text-white/50 hover:text-white transition-colors p-1 -mr-1"
|
|
129
153
|
aria-label="Close stats panel"
|
|
130
154
|
>
|
|
131
|
-
<svg
|
|
155
|
+
<svg
|
|
156
|
+
width="12"
|
|
157
|
+
height="12"
|
|
158
|
+
viewBox="0 0 12 12"
|
|
159
|
+
fill="none"
|
|
160
|
+
stroke="currentColor"
|
|
161
|
+
strokeWidth="1.5"
|
|
162
|
+
>
|
|
132
163
|
<path d="M2 2l8 8M10 2l-8 8" />
|
|
133
164
|
</svg>
|
|
134
165
|
</button>
|