@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.
Files changed (88) hide show
  1. package/dist/cjs/index.js +2 -0
  2. package/dist/cjs/index.js.map +1 -0
  3. package/dist/esm/index.js +2 -0
  4. package/dist/esm/index.js.map +1 -0
  5. package/dist/types/components/DevModePanel.d.ts +47 -0
  6. package/dist/types/components/DvdLogo.d.ts +4 -0
  7. package/dist/types/components/Icons.d.ts +33 -0
  8. package/dist/types/components/IdleScreen.d.ts +16 -0
  9. package/dist/types/components/LoadingScreen.d.ts +6 -0
  10. package/dist/types/components/LogoOverlay.d.ts +11 -0
  11. package/dist/types/components/Player.d.ts +11 -0
  12. package/dist/types/components/PlayerControls.d.ts +60 -0
  13. package/dist/types/components/PlayerErrorBoundary.d.ts +23 -0
  14. package/dist/types/components/SeekBar.d.ts +33 -0
  15. package/dist/types/components/SkipIndicator.d.ts +14 -0
  16. package/dist/types/components/SpeedIndicator.d.ts +12 -0
  17. package/dist/types/components/StatsPanel.d.ts +31 -0
  18. package/dist/types/components/StreamStateOverlay.d.ts +24 -0
  19. package/dist/types/components/SubtitleRenderer.d.ts +69 -0
  20. package/dist/types/components/ThumbnailOverlay.d.ts +4 -0
  21. package/dist/types/components/TitleOverlay.d.ts +13 -0
  22. package/dist/types/components/players/DashJsPlayer.d.ts +18 -0
  23. package/dist/types/components/players/HlsJsPlayer.d.ts +18 -0
  24. package/dist/types/components/players/MewsWsPlayer/index.d.ts +18 -0
  25. package/dist/types/components/players/MistPlayer.d.ts +20 -0
  26. package/dist/types/components/players/MistWebRTCPlayer/index.d.ts +20 -0
  27. package/dist/types/components/players/NativePlayer.d.ts +19 -0
  28. package/dist/types/components/players/VideoJsPlayer.d.ts +18 -0
  29. package/dist/types/context/PlayerContext.d.ts +40 -0
  30. package/dist/types/context/index.d.ts +5 -0
  31. package/dist/types/hooks/useMetaTrack.d.ts +54 -0
  32. package/dist/types/hooks/usePlaybackQuality.d.ts +42 -0
  33. package/dist/types/hooks/usePlayerController.d.ts +163 -0
  34. package/dist/types/hooks/usePlayerSelection.d.ts +47 -0
  35. package/dist/types/hooks/useStreamState.d.ts +27 -0
  36. package/dist/types/hooks/useTelemetry.d.ts +57 -0
  37. package/dist/types/hooks/useViewerEndpoints.d.ts +14 -0
  38. package/dist/types/index.d.ts +33 -0
  39. package/dist/types/types.d.ts +94 -0
  40. package/dist/types/ui/badge.d.ts +9 -0
  41. package/dist/types/ui/button.d.ts +11 -0
  42. package/dist/types/ui/context-menu.d.ts +27 -0
  43. package/dist/types/ui/select.d.ts +10 -0
  44. package/dist/types/ui/slider.d.ts +13 -0
  45. package/package.json +71 -0
  46. package/src/assets/logomark.svg +56 -0
  47. package/src/components/DevModePanel.tsx +822 -0
  48. package/src/components/DvdLogo.tsx +201 -0
  49. package/src/components/Icons.tsx +282 -0
  50. package/src/components/IdleScreen.tsx +664 -0
  51. package/src/components/LoadingScreen.tsx +710 -0
  52. package/src/components/LogoOverlay.tsx +75 -0
  53. package/src/components/Player.tsx +419 -0
  54. package/src/components/PlayerControls.tsx +820 -0
  55. package/src/components/PlayerErrorBoundary.tsx +70 -0
  56. package/src/components/SeekBar.tsx +291 -0
  57. package/src/components/SkipIndicator.tsx +113 -0
  58. package/src/components/SpeedIndicator.tsx +57 -0
  59. package/src/components/StatsPanel.tsx +150 -0
  60. package/src/components/StreamStateOverlay.tsx +200 -0
  61. package/src/components/SubtitleRenderer.tsx +235 -0
  62. package/src/components/ThumbnailOverlay.tsx +90 -0
  63. package/src/components/TitleOverlay.tsx +48 -0
  64. package/src/components/players/DashJsPlayer.tsx +56 -0
  65. package/src/components/players/HlsJsPlayer.tsx +56 -0
  66. package/src/components/players/MewsWsPlayer/index.tsx +56 -0
  67. package/src/components/players/MistPlayer.tsx +60 -0
  68. package/src/components/players/MistWebRTCPlayer/index.tsx +59 -0
  69. package/src/components/players/NativePlayer.tsx +58 -0
  70. package/src/components/players/VideoJsPlayer.tsx +56 -0
  71. package/src/context/PlayerContext.tsx +71 -0
  72. package/src/context/index.ts +11 -0
  73. package/src/global.d.ts +4 -0
  74. package/src/hooks/useMetaTrack.ts +187 -0
  75. package/src/hooks/usePlaybackQuality.ts +126 -0
  76. package/src/hooks/usePlayerController.ts +525 -0
  77. package/src/hooks/usePlayerSelection.ts +117 -0
  78. package/src/hooks/useStreamState.ts +381 -0
  79. package/src/hooks/useTelemetry.ts +138 -0
  80. package/src/hooks/useViewerEndpoints.ts +120 -0
  81. package/src/index.tsx +75 -0
  82. package/src/player.css +2 -0
  83. package/src/types.ts +135 -0
  84. package/src/ui/badge.tsx +27 -0
  85. package/src/ui/button.tsx +47 -0
  86. package/src/ui/context-menu.tsx +193 -0
  87. package/src/ui/select.tsx +105 -0
  88. 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;