@livepeer-frameworks/player-react 0.0.3
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/dist/cjs/index.js +2 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types/components/DevModePanel.d.ts +47 -0
- package/dist/types/components/DvdLogo.d.ts +4 -0
- package/dist/types/components/Icons.d.ts +33 -0
- package/dist/types/components/IdleScreen.d.ts +16 -0
- package/dist/types/components/LoadingScreen.d.ts +6 -0
- package/dist/types/components/LogoOverlay.d.ts +11 -0
- package/dist/types/components/Player.d.ts +11 -0
- package/dist/types/components/PlayerControls.d.ts +60 -0
- package/dist/types/components/PlayerErrorBoundary.d.ts +23 -0
- package/dist/types/components/SeekBar.d.ts +33 -0
- package/dist/types/components/SkipIndicator.d.ts +14 -0
- package/dist/types/components/SpeedIndicator.d.ts +12 -0
- package/dist/types/components/StatsPanel.d.ts +31 -0
- package/dist/types/components/StreamStateOverlay.d.ts +24 -0
- package/dist/types/components/SubtitleRenderer.d.ts +69 -0
- package/dist/types/components/ThumbnailOverlay.d.ts +4 -0
- package/dist/types/components/TitleOverlay.d.ts +13 -0
- package/dist/types/components/players/DashJsPlayer.d.ts +18 -0
- package/dist/types/components/players/HlsJsPlayer.d.ts +18 -0
- package/dist/types/components/players/MewsWsPlayer/index.d.ts +18 -0
- package/dist/types/components/players/MistPlayer.d.ts +20 -0
- package/dist/types/components/players/MistWebRTCPlayer/index.d.ts +20 -0
- package/dist/types/components/players/NativePlayer.d.ts +19 -0
- package/dist/types/components/players/VideoJsPlayer.d.ts +18 -0
- package/dist/types/context/PlayerContext.d.ts +40 -0
- package/dist/types/context/index.d.ts +5 -0
- package/dist/types/hooks/useMetaTrack.d.ts +54 -0
- package/dist/types/hooks/usePlaybackQuality.d.ts +42 -0
- package/dist/types/hooks/usePlayerController.d.ts +163 -0
- package/dist/types/hooks/usePlayerSelection.d.ts +47 -0
- package/dist/types/hooks/useStreamState.d.ts +27 -0
- package/dist/types/hooks/useTelemetry.d.ts +57 -0
- package/dist/types/hooks/useViewerEndpoints.d.ts +14 -0
- package/dist/types/index.d.ts +33 -0
- package/dist/types/types.d.ts +94 -0
- package/dist/types/ui/badge.d.ts +9 -0
- package/dist/types/ui/button.d.ts +11 -0
- package/dist/types/ui/context-menu.d.ts +27 -0
- package/dist/types/ui/select.d.ts +10 -0
- package/dist/types/ui/slider.d.ts +13 -0
- package/package.json +71 -0
- package/src/assets/logomark.svg +56 -0
- package/src/components/DevModePanel.tsx +822 -0
- package/src/components/DvdLogo.tsx +201 -0
- package/src/components/Icons.tsx +282 -0
- package/src/components/IdleScreen.tsx +664 -0
- package/src/components/LoadingScreen.tsx +710 -0
- package/src/components/LogoOverlay.tsx +75 -0
- package/src/components/Player.tsx +419 -0
- package/src/components/PlayerControls.tsx +820 -0
- package/src/components/PlayerErrorBoundary.tsx +70 -0
- package/src/components/SeekBar.tsx +291 -0
- package/src/components/SkipIndicator.tsx +113 -0
- package/src/components/SpeedIndicator.tsx +57 -0
- package/src/components/StatsPanel.tsx +150 -0
- package/src/components/StreamStateOverlay.tsx +200 -0
- package/src/components/SubtitleRenderer.tsx +235 -0
- package/src/components/ThumbnailOverlay.tsx +90 -0
- package/src/components/TitleOverlay.tsx +48 -0
- package/src/components/players/DashJsPlayer.tsx +56 -0
- package/src/components/players/HlsJsPlayer.tsx +56 -0
- package/src/components/players/MewsWsPlayer/index.tsx +56 -0
- package/src/components/players/MistPlayer.tsx +60 -0
- package/src/components/players/MistWebRTCPlayer/index.tsx +59 -0
- package/src/components/players/NativePlayer.tsx +58 -0
- package/src/components/players/VideoJsPlayer.tsx +56 -0
- package/src/context/PlayerContext.tsx +71 -0
- package/src/context/index.ts +11 -0
- package/src/global.d.ts +4 -0
- package/src/hooks/useMetaTrack.ts +187 -0
- package/src/hooks/usePlaybackQuality.ts +126 -0
- package/src/hooks/usePlayerController.ts +525 -0
- package/src/hooks/usePlayerSelection.ts +117 -0
- package/src/hooks/useStreamState.ts +381 -0
- package/src/hooks/useTelemetry.ts +138 -0
- package/src/hooks/useViewerEndpoints.ts +120 -0
- package/src/index.tsx +75 -0
- package/src/player.css +2 -0
- package/src/types.ts +135 -0
- package/src/ui/badge.tsx +27 -0
- package/src/ui/button.tsx +47 -0
- package/src/ui/context-menu.tsx +193 -0
- package/src/ui/select.tsx +105 -0
- package/src/ui/slider.tsx +67 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import React, { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
cn,
|
|
4
|
+
globalPlayerManager,
|
|
5
|
+
QualityMonitor,
|
|
6
|
+
type StreamInfo,
|
|
7
|
+
type MistStreamInfo,
|
|
8
|
+
type PlaybackMode,
|
|
9
|
+
} from "@livepeer-frameworks/player-core";
|
|
10
|
+
|
|
11
|
+
/** Short labels for source types */
|
|
12
|
+
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
|
13
|
+
"html5/application/vnd.apple.mpegurl": "HLS",
|
|
14
|
+
"dash/video/mp4": "DASH",
|
|
15
|
+
"html5/video/mp4": "MP4",
|
|
16
|
+
"html5/video/webm": "WebM",
|
|
17
|
+
"whep": "WHEP",
|
|
18
|
+
"mist/html": "Mist",
|
|
19
|
+
"mist/legacy": "Auto",
|
|
20
|
+
"ws/video/mp4": "MEWS",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface DevModePanelProps {
|
|
24
|
+
/** Callback when user selects a combo (one-shot selection) */
|
|
25
|
+
onSettingsChange: (settings: {
|
|
26
|
+
forcePlayer?: string;
|
|
27
|
+
forceType?: string;
|
|
28
|
+
forceSource?: number;
|
|
29
|
+
}) => void;
|
|
30
|
+
/** Current playback mode */
|
|
31
|
+
playbackMode?: PlaybackMode;
|
|
32
|
+
/** Callback when playback mode changes */
|
|
33
|
+
onModeChange?: (mode: PlaybackMode) => void;
|
|
34
|
+
/** Callback to force player reload */
|
|
35
|
+
onReload?: () => void;
|
|
36
|
+
/** Stream info for getting all combinations (sources + tracks from MistServer) */
|
|
37
|
+
streamInfo?: StreamInfo | null;
|
|
38
|
+
/** MistServer stream metadata including tracks */
|
|
39
|
+
mistStreamInfo?: MistStreamInfo | null;
|
|
40
|
+
/** Current player info */
|
|
41
|
+
currentPlayer?: {
|
|
42
|
+
name: string;
|
|
43
|
+
shortname: string;
|
|
44
|
+
} | null;
|
|
45
|
+
/** Current source info */
|
|
46
|
+
currentSource?: {
|
|
47
|
+
url: string;
|
|
48
|
+
type: string;
|
|
49
|
+
} | null;
|
|
50
|
+
/** Video element for stats */
|
|
51
|
+
videoElement?: HTMLVideoElement | null;
|
|
52
|
+
/** Protocol/node info */
|
|
53
|
+
protocol?: string;
|
|
54
|
+
nodeId?: string;
|
|
55
|
+
/** Whether the panel toggle is visible (hover state) */
|
|
56
|
+
isVisible?: boolean;
|
|
57
|
+
/** Controlled open state (if provided, component is controlled) */
|
|
58
|
+
isOpen?: boolean;
|
|
59
|
+
/** Callback when open state changes */
|
|
60
|
+
onOpenChange?: (isOpen: boolean) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* DevModePanel - Advanced Settings overlay for testing player configurations
|
|
65
|
+
* Similar to MistPlayer's skin: "dev" mode
|
|
66
|
+
*/
|
|
67
|
+
const DevModePanel: React.FC<DevModePanelProps> = ({
|
|
68
|
+
onSettingsChange,
|
|
69
|
+
playbackMode = 'auto',
|
|
70
|
+
onModeChange,
|
|
71
|
+
onReload,
|
|
72
|
+
streamInfo,
|
|
73
|
+
mistStreamInfo,
|
|
74
|
+
currentPlayer,
|
|
75
|
+
currentSource,
|
|
76
|
+
videoElement,
|
|
77
|
+
protocol,
|
|
78
|
+
nodeId,
|
|
79
|
+
isVisible = true,
|
|
80
|
+
isOpen: controlledIsOpen,
|
|
81
|
+
onOpenChange,
|
|
82
|
+
}) => {
|
|
83
|
+
// Support both controlled and uncontrolled modes
|
|
84
|
+
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
|
85
|
+
const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
|
|
86
|
+
const setIsOpen = useCallback((value: boolean) => {
|
|
87
|
+
if (onOpenChange) {
|
|
88
|
+
onOpenChange(value);
|
|
89
|
+
} else {
|
|
90
|
+
setInternalIsOpen(value);
|
|
91
|
+
}
|
|
92
|
+
}, [onOpenChange]);
|
|
93
|
+
const [activeTab, setActiveTab] = useState<"config" | "stats">("config");
|
|
94
|
+
const [currentComboIndex, setCurrentComboIndex] = useState(0);
|
|
95
|
+
const [hoveredComboIndex, setHoveredComboIndex] = useState<number | null>(null);
|
|
96
|
+
const [tooltipAbove, setTooltipAbove] = useState(false);
|
|
97
|
+
const [showDisabledPlayers, setShowDisabledPlayers] = useState(false);
|
|
98
|
+
const comboListRef = useRef<HTMLDivElement>(null);
|
|
99
|
+
|
|
100
|
+
// Quality monitoring for playback score
|
|
101
|
+
const qualityMonitorRef = useRef<QualityMonitor | null>(null);
|
|
102
|
+
const [playbackScore, setPlaybackScore] = useState<number>(1.0);
|
|
103
|
+
const [qualityScore, setQualityScore] = useState<number>(100);
|
|
104
|
+
const [stallCount, setStallCount] = useState<number>(0);
|
|
105
|
+
const [frameDropRate, setFrameDropRate] = useState<number>(0);
|
|
106
|
+
|
|
107
|
+
// Player-specific stats (from getStats())
|
|
108
|
+
const [playerStats, setPlayerStats] = useState<any>(null);
|
|
109
|
+
const statsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
110
|
+
|
|
111
|
+
// Start/stop quality monitoring based on video element
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (videoElement && isOpen) {
|
|
114
|
+
if (!qualityMonitorRef.current) {
|
|
115
|
+
qualityMonitorRef.current = new QualityMonitor({
|
|
116
|
+
sampleInterval: 500,
|
|
117
|
+
onSample: (quality) => {
|
|
118
|
+
setQualityScore(quality.score);
|
|
119
|
+
setStallCount(quality.stallCount);
|
|
120
|
+
setFrameDropRate(quality.frameDropRate);
|
|
121
|
+
// Get playback score from monitor
|
|
122
|
+
if (qualityMonitorRef.current) {
|
|
123
|
+
setPlaybackScore(qualityMonitorRef.current.getPlaybackScore());
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
qualityMonitorRef.current.start(videoElement);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
if (qualityMonitorRef.current) {
|
|
133
|
+
qualityMonitorRef.current.stop();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}, [videoElement, isOpen]);
|
|
137
|
+
|
|
138
|
+
// Poll player-specific stats when stats tab is open
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (isOpen && activeTab === "stats") {
|
|
141
|
+
const pollStats = async () => {
|
|
142
|
+
try {
|
|
143
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
144
|
+
if (player && player.getStats) {
|
|
145
|
+
const stats = await player.getStats();
|
|
146
|
+
if (stats) {
|
|
147
|
+
setPlayerStats(stats);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
// Ignore errors
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Poll immediately and then every 500ms
|
|
156
|
+
pollStats();
|
|
157
|
+
statsIntervalRef.current = setInterval(pollStats, 500);
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
if (statsIntervalRef.current) {
|
|
161
|
+
clearInterval(statsIntervalRef.current);
|
|
162
|
+
statsIntervalRef.current = null;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}, [isOpen, activeTab]);
|
|
167
|
+
|
|
168
|
+
// Get all player-source combinations with scores (including incompatible)
|
|
169
|
+
// Uses cached results from PlayerManager - won't recompute if data unchanged
|
|
170
|
+
const allCombinations = useMemo(() => {
|
|
171
|
+
if (!streamInfo) return [];
|
|
172
|
+
try {
|
|
173
|
+
// getAllCombinations now includes all combos (compatible + incompatible)
|
|
174
|
+
// and uses content-based caching - won't spam on every render
|
|
175
|
+
return globalPlayerManager.getAllCombinations(streamInfo, playbackMode);
|
|
176
|
+
} catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}, [streamInfo, playbackMode]);
|
|
180
|
+
|
|
181
|
+
// For backward compatibility (Next Option only cycles compatible)
|
|
182
|
+
const combinations = useMemo(() => {
|
|
183
|
+
return allCombinations.filter(c => c.compatible);
|
|
184
|
+
}, [allCombinations]);
|
|
185
|
+
|
|
186
|
+
// Find current active combo index based on current player/source (in allCombinations)
|
|
187
|
+
const activeComboIndex = useMemo(() => {
|
|
188
|
+
if (!currentPlayer || !currentSource || allCombinations.length === 0) return -1;
|
|
189
|
+
return allCombinations.findIndex(
|
|
190
|
+
(c) => c.player === currentPlayer.shortname && c.sourceType === currentSource.type
|
|
191
|
+
);
|
|
192
|
+
}, [currentPlayer, currentSource, allCombinations]);
|
|
193
|
+
|
|
194
|
+
// Find index in compatible-only list for Next Option cycling
|
|
195
|
+
const activeCompatibleIndex = useMemo(() => {
|
|
196
|
+
if (!currentPlayer || !currentSource || combinations.length === 0) return -1;
|
|
197
|
+
return combinations.findIndex(
|
|
198
|
+
(c) => c.player === currentPlayer.shortname && c.sourceType === currentSource.type
|
|
199
|
+
);
|
|
200
|
+
}, [currentPlayer, currentSource, combinations]);
|
|
201
|
+
|
|
202
|
+
const handleReload = useCallback(() => {
|
|
203
|
+
// Just trigger reload - controller manages the state
|
|
204
|
+
onReload?.();
|
|
205
|
+
}, [onReload]);
|
|
206
|
+
|
|
207
|
+
const handleNextCombo = useCallback(() => {
|
|
208
|
+
if (combinations.length === 0) return;
|
|
209
|
+
|
|
210
|
+
// Start from active combo or 0, then move to next (only cycles compatible)
|
|
211
|
+
const startIdx = activeCompatibleIndex >= 0 ? activeCompatibleIndex : -1;
|
|
212
|
+
const nextIdx = (startIdx + 1) % combinations.length;
|
|
213
|
+
const combo = combinations[nextIdx];
|
|
214
|
+
|
|
215
|
+
setCurrentComboIndex(nextIdx);
|
|
216
|
+
onSettingsChange({
|
|
217
|
+
forcePlayer: combo.player,
|
|
218
|
+
forceType: combo.sourceType,
|
|
219
|
+
forceSource: combo.sourceIndex,
|
|
220
|
+
});
|
|
221
|
+
}, [combinations, activeCompatibleIndex, onSettingsChange]);
|
|
222
|
+
|
|
223
|
+
const handleSelectCombo = useCallback((index: number) => {
|
|
224
|
+
const combo = allCombinations[index];
|
|
225
|
+
if (!combo) return;
|
|
226
|
+
|
|
227
|
+
// Allow selecting even incompatible combos in dev mode (for testing)
|
|
228
|
+
setCurrentComboIndex(index);
|
|
229
|
+
onSettingsChange({
|
|
230
|
+
forcePlayer: combo.player,
|
|
231
|
+
forceType: combo.sourceType,
|
|
232
|
+
forceSource: combo.sourceIndex,
|
|
233
|
+
});
|
|
234
|
+
}, [allCombinations, onSettingsChange]);
|
|
235
|
+
|
|
236
|
+
// Video stats - poll periodically when stats tab is open
|
|
237
|
+
const [stats, setStats] = useState<{
|
|
238
|
+
resolution: string;
|
|
239
|
+
buffered: string;
|
|
240
|
+
playbackRate: string;
|
|
241
|
+
currentTime: string;
|
|
242
|
+
duration: string;
|
|
243
|
+
readyState: number;
|
|
244
|
+
networkState: number;
|
|
245
|
+
} | null>(null);
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (!isOpen || activeTab !== "stats") return;
|
|
249
|
+
|
|
250
|
+
const updateStats = () => {
|
|
251
|
+
// Get fresh video element from player manager
|
|
252
|
+
const player = globalPlayerManager.getCurrentPlayer();
|
|
253
|
+
const v = player?.getVideoElement() || videoElement;
|
|
254
|
+
if (!v) {
|
|
255
|
+
setStats(null);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
setStats({
|
|
259
|
+
resolution: `${v.videoWidth}x${v.videoHeight}`,
|
|
260
|
+
buffered:
|
|
261
|
+
v.buffered.length > 0
|
|
262
|
+
? (v.buffered.end(v.buffered.length - 1) - v.currentTime).toFixed(1)
|
|
263
|
+
: "0",
|
|
264
|
+
playbackRate: v.playbackRate.toFixed(2),
|
|
265
|
+
currentTime: v.currentTime.toFixed(1),
|
|
266
|
+
duration: isFinite(v.duration) ? v.duration.toFixed(1) : "live",
|
|
267
|
+
readyState: v.readyState,
|
|
268
|
+
networkState: v.networkState,
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
updateStats();
|
|
273
|
+
const interval = setInterval(updateStats, 500);
|
|
274
|
+
return () => clearInterval(interval);
|
|
275
|
+
}, [isOpen, activeTab, videoElement]);
|
|
276
|
+
|
|
277
|
+
// Toggle button (when closed)
|
|
278
|
+
if (!isOpen) {
|
|
279
|
+
return (
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={() => setIsOpen(true)}
|
|
283
|
+
className={cn(
|
|
284
|
+
"fw-dev-toggle",
|
|
285
|
+
!isVisible && "fw-dev-toggle--hidden"
|
|
286
|
+
)}
|
|
287
|
+
title="Advanced Settings"
|
|
288
|
+
aria-label="Open advanced settings panel"
|
|
289
|
+
>
|
|
290
|
+
<svg
|
|
291
|
+
width="16"
|
|
292
|
+
height="16"
|
|
293
|
+
viewBox="0 0 24 24"
|
|
294
|
+
fill="none"
|
|
295
|
+
stroke="currentColor"
|
|
296
|
+
strokeWidth="2"
|
|
297
|
+
>
|
|
298
|
+
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
|
299
|
+
</svg>
|
|
300
|
+
</button>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div className="fw-dev-panel">
|
|
306
|
+
{/* Header with tabs */}
|
|
307
|
+
<div className="fw-dev-header">
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
onClick={() => setActiveTab("config")}
|
|
311
|
+
className={cn("fw-dev-tab", activeTab === "config" && "fw-dev-tab--active")}
|
|
312
|
+
>
|
|
313
|
+
Config
|
|
314
|
+
</button>
|
|
315
|
+
<button
|
|
316
|
+
type="button"
|
|
317
|
+
onClick={() => setActiveTab("stats")}
|
|
318
|
+
className={cn("fw-dev-tab", activeTab === "stats" && "fw-dev-tab--active")}
|
|
319
|
+
>
|
|
320
|
+
Stats
|
|
321
|
+
</button>
|
|
322
|
+
<div className="fw-dev-spacer" />
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
onClick={() => setIsOpen(false)}
|
|
326
|
+
className="fw-dev-close"
|
|
327
|
+
aria-label="Close dev mode panel"
|
|
328
|
+
>
|
|
329
|
+
<svg
|
|
330
|
+
width="12"
|
|
331
|
+
height="12"
|
|
332
|
+
viewBox="0 0 12 12"
|
|
333
|
+
fill="none"
|
|
334
|
+
stroke="currentColor"
|
|
335
|
+
strokeWidth="1.5"
|
|
336
|
+
>
|
|
337
|
+
<path d="M2 2l8 8M10 2l-8 8" />
|
|
338
|
+
</svg>
|
|
339
|
+
</button>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
{/* Config Tab */}
|
|
343
|
+
{activeTab === "config" && (
|
|
344
|
+
<div ref={comboListRef} className="fw-dev-body">
|
|
345
|
+
{/* Current State */}
|
|
346
|
+
<div className="fw-dev-section">
|
|
347
|
+
<div className="fw-dev-label">Active</div>
|
|
348
|
+
<div className="fw-dev-value">
|
|
349
|
+
{currentPlayer?.name || "None"}{" "}
|
|
350
|
+
<span className="fw-dev-value-arrow">→</span>{" "}
|
|
351
|
+
{SOURCE_TYPE_LABELS[currentSource?.type || ""] || currentSource?.type || "—"}
|
|
352
|
+
</div>
|
|
353
|
+
{nodeId && (
|
|
354
|
+
<div className="fw-dev-value-muted">Node: {nodeId}</div>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
{/* Playback Mode Selector */}
|
|
359
|
+
<div className="fw-dev-section">
|
|
360
|
+
<div className="fw-dev-label">Playback Mode</div>
|
|
361
|
+
<div className="fw-dev-mode-group">
|
|
362
|
+
{(['auto', 'low-latency', 'quality'] as const).map((mode) => (
|
|
363
|
+
<button
|
|
364
|
+
key={mode}
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={() => onModeChange?.(mode)}
|
|
367
|
+
className={cn(
|
|
368
|
+
"fw-dev-mode-btn",
|
|
369
|
+
playbackMode === mode && "fw-dev-mode-btn--active"
|
|
370
|
+
)}
|
|
371
|
+
>
|
|
372
|
+
{mode === 'low-latency' ? 'Low Lat' : mode.charAt(0).toUpperCase() + mode.slice(1)}
|
|
373
|
+
</button>
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
<div className="fw-dev-mode-desc">
|
|
377
|
+
{playbackMode === 'auto' && 'Balanced: MP4/WS → WHEP → HLS'}
|
|
378
|
+
{playbackMode === 'low-latency' && 'WHEP/WebRTC first (<1s delay)'}
|
|
379
|
+
{playbackMode === 'quality' && 'MP4/WS first, HLS fallback'}
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{/* Action buttons */}
|
|
384
|
+
<div className="fw-dev-actions">
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
onClick={handleReload}
|
|
388
|
+
className="fw-dev-action-btn"
|
|
389
|
+
>
|
|
390
|
+
Reload
|
|
391
|
+
</button>
|
|
392
|
+
<button
|
|
393
|
+
type="button"
|
|
394
|
+
onClick={handleNextCombo}
|
|
395
|
+
className="fw-dev-action-btn"
|
|
396
|
+
>
|
|
397
|
+
Next Option
|
|
398
|
+
</button>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
{/* Combo list */}
|
|
402
|
+
<div className="fw-dev-section" style={{ padding: 0, borderBottom: 0 }}>
|
|
403
|
+
<div className="fw-dev-list-header">
|
|
404
|
+
<span className="fw-dev-list-title">
|
|
405
|
+
Player Options ({combinations.length})
|
|
406
|
+
</span>
|
|
407
|
+
{allCombinations.length > combinations.length && (
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
onClick={() => setShowDisabledPlayers(!showDisabledPlayers)}
|
|
411
|
+
className="fw-dev-list-toggle"
|
|
412
|
+
>
|
|
413
|
+
<svg
|
|
414
|
+
width="10"
|
|
415
|
+
height="10"
|
|
416
|
+
viewBox="0 0 24 24"
|
|
417
|
+
fill="none"
|
|
418
|
+
stroke="currentColor"
|
|
419
|
+
strokeWidth="2"
|
|
420
|
+
className={cn("fw-dev-chevron", showDisabledPlayers && "fw-dev-chevron--open")}
|
|
421
|
+
>
|
|
422
|
+
<path d="M6 9l6 6 6-6" />
|
|
423
|
+
</svg>
|
|
424
|
+
{showDisabledPlayers ? "Hide" : "Show"} disabled ({allCombinations.length - combinations.length})
|
|
425
|
+
</button>
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
{allCombinations.length === 0 ? (
|
|
429
|
+
<div className="fw-dev-list-empty">
|
|
430
|
+
No stream info available
|
|
431
|
+
</div>
|
|
432
|
+
) : (
|
|
433
|
+
<div>
|
|
434
|
+
{allCombinations.map((combo, index) => {
|
|
435
|
+
// Codec-incompatible items always show (with warning), MIME-incompatible hide in "disabled"
|
|
436
|
+
const isCodecIncompat = (combo as any).codecIncompatible === true;
|
|
437
|
+
if (!combo.compatible && !isCodecIncompat && !showDisabledPlayers) return null;
|
|
438
|
+
|
|
439
|
+
const isActive = activeComboIndex === index;
|
|
440
|
+
const typeLabel = SOURCE_TYPE_LABELS[combo.sourceType] || combo.sourceType.split("/").pop();
|
|
441
|
+
|
|
442
|
+
// Determine score class
|
|
443
|
+
const getScoreClass = () => {
|
|
444
|
+
if (!combo.compatible && !isCodecIncompat) return "fw-dev-combo-score--disabled";
|
|
445
|
+
if (isCodecIncompat) return "fw-dev-combo-score--low";
|
|
446
|
+
if (combo.score >= 2) return "fw-dev-combo-score--high";
|
|
447
|
+
if (combo.score >= 1.5) return "fw-dev-combo-score--mid";
|
|
448
|
+
return "fw-dev-combo-score--low";
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// Determine rank class
|
|
452
|
+
const getRankClass = () => {
|
|
453
|
+
if (isActive) return "fw-dev-combo-rank--active";
|
|
454
|
+
if (!combo.compatible && !isCodecIncompat) return "fw-dev-combo-rank--disabled";
|
|
455
|
+
if (isCodecIncompat) return "fw-dev-combo-rank--warn";
|
|
456
|
+
return "";
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// Determine type class
|
|
460
|
+
const getTypeClass = () => {
|
|
461
|
+
if (!combo.compatible && !isCodecIncompat) return "fw-dev-combo-type--disabled";
|
|
462
|
+
if (isCodecIncompat) return "fw-dev-combo-type--warn";
|
|
463
|
+
return "";
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
<div
|
|
468
|
+
key={`${combo.player}-${combo.sourceType}`}
|
|
469
|
+
onMouseEnter={(e) => {
|
|
470
|
+
setHoveredComboIndex(index);
|
|
471
|
+
if (comboListRef.current) {
|
|
472
|
+
const container = comboListRef.current;
|
|
473
|
+
const row = e.currentTarget;
|
|
474
|
+
const containerRect = container.getBoundingClientRect();
|
|
475
|
+
const rowRect = row.getBoundingClientRect();
|
|
476
|
+
const relativePosition = (rowRect.top - containerRect.top) / containerRect.height;
|
|
477
|
+
setTooltipAbove(relativePosition > 0.6);
|
|
478
|
+
}
|
|
479
|
+
}}
|
|
480
|
+
onMouseLeave={() => setHoveredComboIndex(null)}
|
|
481
|
+
className="fw-dev-combo"
|
|
482
|
+
>
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={() => handleSelectCombo(index)}
|
|
486
|
+
className={cn(
|
|
487
|
+
"fw-dev-combo-btn",
|
|
488
|
+
isActive && "fw-dev-combo-btn--active",
|
|
489
|
+
!combo.compatible && !isCodecIncompat && "fw-dev-combo-btn--disabled",
|
|
490
|
+
isCodecIncompat && "fw-dev-combo-btn--codec-warn"
|
|
491
|
+
)}
|
|
492
|
+
>
|
|
493
|
+
{/* Rank */}
|
|
494
|
+
<span className={cn("fw-dev-combo-rank", getRankClass())}>
|
|
495
|
+
{combo.compatible ? index + 1 : isCodecIncompat ? "⚠" : "—"}
|
|
496
|
+
</span>
|
|
497
|
+
{/* Player + Protocol */}
|
|
498
|
+
<span className="fw-dev-combo-name">
|
|
499
|
+
{combo.playerName}{" "}
|
|
500
|
+
<span className="fw-dev-combo-arrow">→</span>{" "}
|
|
501
|
+
<span className={cn("fw-dev-combo-type", getTypeClass())}>{typeLabel}</span>
|
|
502
|
+
</span>
|
|
503
|
+
{/* Score */}
|
|
504
|
+
<span className={cn("fw-dev-combo-score", getScoreClass())}>
|
|
505
|
+
{combo.score.toFixed(2)}
|
|
506
|
+
</span>
|
|
507
|
+
</button>
|
|
508
|
+
|
|
509
|
+
{/* Score breakdown tooltip */}
|
|
510
|
+
{hoveredComboIndex === index && (
|
|
511
|
+
<div className={cn(
|
|
512
|
+
"fw-dev-tooltip",
|
|
513
|
+
tooltipAbove ? "fw-dev-tooltip--above" : "fw-dev-tooltip--below"
|
|
514
|
+
)}>
|
|
515
|
+
{/* Full player/source info */}
|
|
516
|
+
<div className="fw-dev-tooltip-header">
|
|
517
|
+
<div className="fw-dev-tooltip-title">{combo.playerName}</div>
|
|
518
|
+
<div className="fw-dev-tooltip-subtitle">{combo.sourceType}</div>
|
|
519
|
+
{combo.scoreBreakdown?.trackTypes && combo.scoreBreakdown.trackTypes.length > 0 && (
|
|
520
|
+
<div className="fw-dev-tooltip-tracks">
|
|
521
|
+
Tracks: <span className="fw-dev-tooltip-value">{combo.scoreBreakdown.trackTypes.join(', ')}</span>
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
524
|
+
</div>
|
|
525
|
+
{combo.compatible && combo.scoreBreakdown ? (
|
|
526
|
+
<>
|
|
527
|
+
<div className="fw-dev-tooltip-score">Score: {combo.score.toFixed(2)}</div>
|
|
528
|
+
<div className="fw-dev-tooltip-row">
|
|
529
|
+
Tracks [{combo.scoreBreakdown.trackTypes.join(', ')}]: <span className="fw-dev-tooltip-value">{combo.scoreBreakdown.trackScore.toFixed(2)}</span> <span className="fw-dev-tooltip-weight">x{combo.scoreBreakdown.weights.tracks}</span>
|
|
530
|
+
</div>
|
|
531
|
+
<div className="fw-dev-tooltip-row">
|
|
532
|
+
Priority: <span className="fw-dev-tooltip-value">{combo.scoreBreakdown.priorityScore.toFixed(2)}</span> <span className="fw-dev-tooltip-weight">x{combo.scoreBreakdown.weights.priority}</span>
|
|
533
|
+
</div>
|
|
534
|
+
<div className="fw-dev-tooltip-row">
|
|
535
|
+
Source: <span className="fw-dev-tooltip-value">{combo.scoreBreakdown.sourceScore.toFixed(2)}</span> <span className="fw-dev-tooltip-weight">x{combo.scoreBreakdown.weights.source}</span>
|
|
536
|
+
</div>
|
|
537
|
+
{combo.scoreBreakdown.reliabilityScore !== undefined && (
|
|
538
|
+
<div className="fw-dev-tooltip-row">
|
|
539
|
+
Reliability: <span className="fw-dev-tooltip-value">{combo.scoreBreakdown.reliabilityScore.toFixed(2)}</span> <span className="fw-dev-tooltip-weight">x{combo.scoreBreakdown.weights.reliability ?? 0}</span>
|
|
540
|
+
</div>
|
|
541
|
+
)}
|
|
542
|
+
{combo.scoreBreakdown.modeBonus !== undefined && combo.scoreBreakdown.modeBonus !== 0 && (
|
|
543
|
+
<div className="fw-dev-tooltip-row">
|
|
544
|
+
Mode ({playbackMode}): <span className="fw-dev-tooltip-bonus">+{combo.scoreBreakdown.modeBonus.toFixed(2)}</span> <span className="fw-dev-tooltip-weight">x{combo.scoreBreakdown.weights.mode ?? 0}</span>
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
{combo.scoreBreakdown.routingBonus !== undefined && combo.scoreBreakdown.routingBonus !== 0 && (
|
|
548
|
+
<div className="fw-dev-tooltip-row">
|
|
549
|
+
Routing: <span className={combo.scoreBreakdown.routingBonus > 0 ? "fw-dev-tooltip-bonus" : "fw-dev-tooltip-penalty"}>{combo.scoreBreakdown.routingBonus > 0 ? '+' : ''}{combo.scoreBreakdown.routingBonus.toFixed(2)}</span> <span className="fw-dev-tooltip-weight">x{combo.scoreBreakdown.weights.routing ?? 0}</span>
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
</>
|
|
553
|
+
) : (
|
|
554
|
+
<div className="fw-dev-tooltip-error">{combo.incompatibleReason || 'Incompatible'}</div>
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
);
|
|
560
|
+
})}
|
|
561
|
+
</div>
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
)}
|
|
566
|
+
|
|
567
|
+
{/* Stats Tab */}
|
|
568
|
+
{activeTab === "stats" && (
|
|
569
|
+
<div className="fw-dev-body">
|
|
570
|
+
{/* Playback Rate */}
|
|
571
|
+
<div className="fw-dev-section">
|
|
572
|
+
<div className="fw-dev-label">Playback Rate</div>
|
|
573
|
+
<div className="fw-dev-rate">
|
|
574
|
+
<div className={cn(
|
|
575
|
+
"fw-dev-rate-value",
|
|
576
|
+
playbackScore >= 0.95 && playbackScore <= 1.05 ? "fw-dev-stat-value--good" :
|
|
577
|
+
playbackScore > 1.05 ? "fw-dev-stat-value--accent" :
|
|
578
|
+
playbackScore >= 0.75 ? "fw-dev-stat-value--warn" :
|
|
579
|
+
"fw-dev-stat-value--bad"
|
|
580
|
+
)}>
|
|
581
|
+
{playbackScore.toFixed(2)}×
|
|
582
|
+
</div>
|
|
583
|
+
<div className="fw-dev-rate-status">
|
|
584
|
+
{playbackScore >= 0.95 && playbackScore <= 1.05 ? "realtime" :
|
|
585
|
+
playbackScore > 1.05 ? "catching up" :
|
|
586
|
+
playbackScore >= 0.75 ? "slightly slow" :
|
|
587
|
+
"stalling"}
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
<div className="fw-dev-rate-stats">
|
|
591
|
+
<span className={qualityScore >= 75 ? "fw-dev-stat-value--good" : "fw-dev-stat-value--bad"}>
|
|
592
|
+
Quality: {qualityScore}/100
|
|
593
|
+
</span>
|
|
594
|
+
<span className={stallCount === 0 ? "fw-dev-stat-value--good" : "fw-dev-stat-value--warn"}>
|
|
595
|
+
Stalls: {stallCount}
|
|
596
|
+
</span>
|
|
597
|
+
<span className={frameDropRate < 1 ? "fw-dev-stat-value--good" : "fw-dev-stat-value--bad"}>
|
|
598
|
+
Drops: {frameDropRate.toFixed(1)}%
|
|
599
|
+
</span>
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
|
|
603
|
+
{/* Video Stats */}
|
|
604
|
+
{stats ? (
|
|
605
|
+
<div>
|
|
606
|
+
<div className="fw-dev-stat">
|
|
607
|
+
<span className="fw-dev-stat-label">Resolution</span>
|
|
608
|
+
<span className="fw-dev-stat-value">{stats.resolution}</span>
|
|
609
|
+
</div>
|
|
610
|
+
<div className="fw-dev-stat">
|
|
611
|
+
<span className="fw-dev-stat-label">Buffer</span>
|
|
612
|
+
<span className="fw-dev-stat-value">{stats.buffered}s</span>
|
|
613
|
+
</div>
|
|
614
|
+
<div className="fw-dev-stat">
|
|
615
|
+
<span className="fw-dev-stat-label">Playback Rate</span>
|
|
616
|
+
<span className="fw-dev-stat-value">{stats.playbackRate}x</span>
|
|
617
|
+
</div>
|
|
618
|
+
<div className="fw-dev-stat">
|
|
619
|
+
<span className="fw-dev-stat-label">Time</span>
|
|
620
|
+
<span className="fw-dev-stat-value">
|
|
621
|
+
{stats.currentTime} / {stats.duration}
|
|
622
|
+
</span>
|
|
623
|
+
</div>
|
|
624
|
+
<div className="fw-dev-stat">
|
|
625
|
+
<span className="fw-dev-stat-label">Ready State</span>
|
|
626
|
+
<span className="fw-dev-stat-value">{stats.readyState}</span>
|
|
627
|
+
</div>
|
|
628
|
+
<div className="fw-dev-stat">
|
|
629
|
+
<span className="fw-dev-stat-label">Network State</span>
|
|
630
|
+
<span className="fw-dev-stat-value">{stats.networkState}</span>
|
|
631
|
+
</div>
|
|
632
|
+
{protocol && (
|
|
633
|
+
<div className="fw-dev-stat">
|
|
634
|
+
<span className="fw-dev-stat-label">Protocol</span>
|
|
635
|
+
<span className="fw-dev-stat-value">{protocol}</span>
|
|
636
|
+
</div>
|
|
637
|
+
)}
|
|
638
|
+
{nodeId && (
|
|
639
|
+
<div className="fw-dev-stat">
|
|
640
|
+
<span className="fw-dev-stat-label">Node ID</span>
|
|
641
|
+
<span className="fw-dev-stat-value truncate" style={{ maxWidth: '150px' }}>
|
|
642
|
+
{nodeId}
|
|
643
|
+
</span>
|
|
644
|
+
</div>
|
|
645
|
+
)}
|
|
646
|
+
</div>
|
|
647
|
+
) : (
|
|
648
|
+
<div className="fw-dev-list-empty">
|
|
649
|
+
No video element available
|
|
650
|
+
</div>
|
|
651
|
+
)}
|
|
652
|
+
|
|
653
|
+
{/* Player-specific Stats (HLS.js / WebRTC) */}
|
|
654
|
+
{playerStats && (
|
|
655
|
+
<div>
|
|
656
|
+
<div className="fw-dev-list-header fw-dev-section-header">
|
|
657
|
+
<span className="fw-dev-list-title">
|
|
658
|
+
{playerStats.type === 'hls' ? 'HLS.js Stats' :
|
|
659
|
+
playerStats.type === 'webrtc' ? 'WebRTC Stats' : 'Player Stats'}
|
|
660
|
+
</span>
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
{/* HLS-specific stats */}
|
|
664
|
+
{playerStats.type === 'hls' && (
|
|
665
|
+
<>
|
|
666
|
+
<div className="fw-dev-stat">
|
|
667
|
+
<span className="fw-dev-stat-label">Bitrate</span>
|
|
668
|
+
<span className="fw-dev-stat-value--accent">
|
|
669
|
+
{playerStats.currentBitrate > 0
|
|
670
|
+
? `${Math.round(playerStats.currentBitrate / 1000)} kbps`
|
|
671
|
+
: 'N/A'}
|
|
672
|
+
</span>
|
|
673
|
+
</div>
|
|
674
|
+
<div className="fw-dev-stat">
|
|
675
|
+
<span className="fw-dev-stat-label">Bandwidth Est.</span>
|
|
676
|
+
<span className="fw-dev-stat-value">
|
|
677
|
+
{playerStats.bandwidthEstimate > 0
|
|
678
|
+
? `${Math.round(playerStats.bandwidthEstimate / 1000)} kbps`
|
|
679
|
+
: 'N/A'}
|
|
680
|
+
</span>
|
|
681
|
+
</div>
|
|
682
|
+
<div className="fw-dev-stat">
|
|
683
|
+
<span className="fw-dev-stat-label">Level</span>
|
|
684
|
+
<span className="fw-dev-stat-value">
|
|
685
|
+
{playerStats.currentLevel >= 0 ? playerStats.currentLevel : 'Auto'} / {playerStats.levels?.length || 0}
|
|
686
|
+
</span>
|
|
687
|
+
</div>
|
|
688
|
+
{playerStats.latency !== undefined && (
|
|
689
|
+
<div className="fw-dev-stat">
|
|
690
|
+
<span className="fw-dev-stat-label">Latency</span>
|
|
691
|
+
<span className={playerStats.latency > 5000 ? "fw-dev-stat-value--warn" : "fw-dev-stat-value"}>
|
|
692
|
+
{Math.round(playerStats.latency)} ms
|
|
693
|
+
</span>
|
|
694
|
+
</div>
|
|
695
|
+
)}
|
|
696
|
+
</>
|
|
697
|
+
)}
|
|
698
|
+
|
|
699
|
+
{/* WebRTC-specific stats */}
|
|
700
|
+
{playerStats.type === 'webrtc' && (
|
|
701
|
+
<>
|
|
702
|
+
{playerStats.video && (
|
|
703
|
+
<>
|
|
704
|
+
<div className="fw-dev-stat">
|
|
705
|
+
<span className="fw-dev-stat-label">Video Bitrate</span>
|
|
706
|
+
<span className="fw-dev-stat-value--accent">
|
|
707
|
+
{playerStats.video.bitrate > 0
|
|
708
|
+
? `${Math.round(playerStats.video.bitrate / 1000)} kbps`
|
|
709
|
+
: 'N/A'}
|
|
710
|
+
</span>
|
|
711
|
+
</div>
|
|
712
|
+
<div className="fw-dev-stat">
|
|
713
|
+
<span className="fw-dev-stat-label">FPS</span>
|
|
714
|
+
<span className="fw-dev-stat-value">
|
|
715
|
+
{Math.round(playerStats.video.framesPerSecond || 0)}
|
|
716
|
+
</span>
|
|
717
|
+
</div>
|
|
718
|
+
<div className="fw-dev-stat">
|
|
719
|
+
<span className="fw-dev-stat-label">Frames</span>
|
|
720
|
+
<span className="fw-dev-stat-value">
|
|
721
|
+
{playerStats.video.framesDecoded} decoded,{' '}
|
|
722
|
+
<span className={playerStats.video.frameDropRate > 1 ? "fw-dev-stat-value--bad" : "fw-dev-stat-value--good"}>
|
|
723
|
+
{playerStats.video.framesDropped} dropped
|
|
724
|
+
</span>
|
|
725
|
+
</span>
|
|
726
|
+
</div>
|
|
727
|
+
<div className="fw-dev-stat">
|
|
728
|
+
<span className="fw-dev-stat-label">Packet Loss</span>
|
|
729
|
+
<span className={playerStats.video.packetLossRate > 1 ? "fw-dev-stat-value--bad" : "fw-dev-stat-value--good"}>
|
|
730
|
+
{playerStats.video.packetLossRate.toFixed(2)}%
|
|
731
|
+
</span>
|
|
732
|
+
</div>
|
|
733
|
+
<div className="fw-dev-stat">
|
|
734
|
+
<span className="fw-dev-stat-label">Jitter</span>
|
|
735
|
+
<span className={playerStats.video.jitter > 30 ? "fw-dev-stat-value--warn" : "fw-dev-stat-value"}>
|
|
736
|
+
{playerStats.video.jitter.toFixed(1)} ms
|
|
737
|
+
</span>
|
|
738
|
+
</div>
|
|
739
|
+
<div className="fw-dev-stat">
|
|
740
|
+
<span className="fw-dev-stat-label">Jitter Buffer</span>
|
|
741
|
+
<span className="fw-dev-stat-value">
|
|
742
|
+
{playerStats.video.jitterBufferDelay.toFixed(1)} ms
|
|
743
|
+
</span>
|
|
744
|
+
</div>
|
|
745
|
+
</>
|
|
746
|
+
)}
|
|
747
|
+
{playerStats.network && (
|
|
748
|
+
<div className="fw-dev-stat">
|
|
749
|
+
<span className="fw-dev-stat-label">RTT</span>
|
|
750
|
+
<span className={playerStats.network.rtt > 200 ? "fw-dev-stat-value--warn" : "fw-dev-stat-value"}>
|
|
751
|
+
{Math.round(playerStats.network.rtt)} ms
|
|
752
|
+
</span>
|
|
753
|
+
</div>
|
|
754
|
+
)}
|
|
755
|
+
</>
|
|
756
|
+
)}
|
|
757
|
+
</div>
|
|
758
|
+
)}
|
|
759
|
+
|
|
760
|
+
{/* MistServer Track Info */}
|
|
761
|
+
{mistStreamInfo?.meta?.tracks && Object.keys(mistStreamInfo.meta.tracks).length > 0 && (
|
|
762
|
+
<div>
|
|
763
|
+
<div className="fw-dev-list-header fw-dev-section-header">
|
|
764
|
+
<span className="fw-dev-list-title">
|
|
765
|
+
Tracks ({Object.keys(mistStreamInfo.meta.tracks).length})
|
|
766
|
+
</span>
|
|
767
|
+
</div>
|
|
768
|
+
{Object.entries(mistStreamInfo.meta.tracks).map(([id, track]) => (
|
|
769
|
+
<div key={id} className="fw-dev-track">
|
|
770
|
+
<div className="fw-dev-track-header">
|
|
771
|
+
<span className={cn(
|
|
772
|
+
"fw-dev-track-badge",
|
|
773
|
+
track.type === 'video' ? "fw-dev-track-badge--video" :
|
|
774
|
+
track.type === 'audio' ? "fw-dev-track-badge--audio" :
|
|
775
|
+
"fw-dev-track-badge--other"
|
|
776
|
+
)}>
|
|
777
|
+
{track.type}
|
|
778
|
+
</span>
|
|
779
|
+
<span className="fw-dev-track-codec">{track.codec}</span>
|
|
780
|
+
<span className="fw-dev-track-id">#{id}</span>
|
|
781
|
+
</div>
|
|
782
|
+
<div className="fw-dev-track-meta">
|
|
783
|
+
{track.type === 'video' && track.width && track.height && (
|
|
784
|
+
<span>{track.width}×{track.height}</span>
|
|
785
|
+
)}
|
|
786
|
+
{track.bps && (
|
|
787
|
+
<span>{Math.round(track.bps / 1000)} kbps</span>
|
|
788
|
+
)}
|
|
789
|
+
{track.fpks && (
|
|
790
|
+
<span>{Math.round(track.fpks / 1000)} fps</span>
|
|
791
|
+
)}
|
|
792
|
+
{track.type === 'audio' && track.channels && (
|
|
793
|
+
<span>{track.channels}ch</span>
|
|
794
|
+
)}
|
|
795
|
+
{track.type === 'audio' && track.rate && (
|
|
796
|
+
<span>{track.rate} Hz</span>
|
|
797
|
+
)}
|
|
798
|
+
{track.lang && (
|
|
799
|
+
<span>{track.lang}</span>
|
|
800
|
+
)}
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
))}
|
|
804
|
+
</div>
|
|
805
|
+
)}
|
|
806
|
+
|
|
807
|
+
{/* Debug: Show if mistStreamInfo is missing tracks */}
|
|
808
|
+
{mistStreamInfo && (!mistStreamInfo.meta?.tracks || Object.keys(mistStreamInfo.meta.tracks).length === 0) && (
|
|
809
|
+
<div className="fw-dev-no-tracks">
|
|
810
|
+
<span className="fw-dev-no-tracks-text">
|
|
811
|
+
No track data available
|
|
812
|
+
{mistStreamInfo.type && <span className="fw-dev-no-tracks-type">({mistStreamInfo.type})</span>}
|
|
813
|
+
</span>
|
|
814
|
+
</div>
|
|
815
|
+
)}
|
|
816
|
+
</div>
|
|
817
|
+
)}
|
|
818
|
+
</div>
|
|
819
|
+
);
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
export default DevModePanel;
|