@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,70 @@
1
+ import React, { Component, ErrorInfo, ReactNode } from 'react';
2
+ import { Button } from '../ui/button';
3
+
4
+ interface Props {
5
+ children: ReactNode;
6
+ fallback?: ReactNode;
7
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
8
+ onRetry?: () => void;
9
+ }
10
+
11
+ interface State {
12
+ hasError: boolean;
13
+ error: Error | null;
14
+ }
15
+
16
+ /**
17
+ * Error boundary to catch and handle player errors gracefully.
18
+ * Prevents player errors from crashing the parent application.
19
+ */
20
+ class PlayerErrorBoundary extends Component<Props, State> {
21
+ constructor(props: Props) {
22
+ super(props);
23
+ this.state = { hasError: false, error: null };
24
+ }
25
+
26
+ static getDerivedStateFromError(error: Error): State {
27
+ return { hasError: true, error };
28
+ }
29
+
30
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
31
+ console.error('[PlayerErrorBoundary] Caught error:', error, errorInfo);
32
+ this.props.onError?.(error, errorInfo);
33
+ }
34
+
35
+ handleRetry = (): void => {
36
+ this.setState({ hasError: false, error: null });
37
+ this.props.onRetry?.();
38
+ };
39
+
40
+ render(): ReactNode {
41
+ if (this.state.hasError) {
42
+ if (this.props.fallback) {
43
+ return this.props.fallback;
44
+ }
45
+
46
+ return (
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>
51
+ <p className="max-w-sm text-sm text-slate-400">
52
+ {this.state.error?.message || 'An unexpected error occurred while loading the player.'}
53
+ </p>
54
+ <Button
55
+ type="button"
56
+ variant="secondary"
57
+ onClick={this.handleRetry}
58
+ className="mt-2"
59
+ >
60
+ Try Again
61
+ </Button>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ return this.props.children;
67
+ }
68
+ }
69
+
70
+ export default PlayerErrorBoundary;
@@ -0,0 +1,291 @@
1
+ import React, { useRef, useState, useCallback, useMemo } from "react";
2
+ import { cn } from "@livepeer-frameworks/player-core";
3
+
4
+ interface SeekBarProps {
5
+ /** Current playback time in seconds */
6
+ currentTime: number;
7
+ /** Total duration in seconds */
8
+ duration: number;
9
+ /** Buffered time ranges from video element */
10
+ buffered?: TimeRanges;
11
+ /** Whether seeking is allowed */
12
+ disabled?: boolean;
13
+ /** Called when user seeks to a new time */
14
+ onSeek?: (time: number) => void;
15
+ /** Additional class names */
16
+ className?: string;
17
+ /** Whether this is a live stream */
18
+ isLive?: boolean;
19
+ /** For live: start of seekable DVR window (seconds) */
20
+ seekableStart?: number;
21
+ /** For live: current live edge position (seconds) */
22
+ liveEdge?: number;
23
+ /** Defer seeking until drag release */
24
+ commitOnRelease?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Industry-standard video seek bar with:
29
+ * - Track background
30
+ * - Buffer progress indicator
31
+ * - Playback progress indicator
32
+ * - Thumb on hover
33
+ * - Time tooltip on hover (relative for live: "-5:30")
34
+ */
35
+ const SeekBar: React.FC<SeekBarProps> = ({
36
+ currentTime,
37
+ duration,
38
+ buffered,
39
+ disabled = false,
40
+ onSeek,
41
+ className,
42
+ isLive = false,
43
+ seekableStart = 0,
44
+ liveEdge,
45
+ commitOnRelease = false,
46
+ }) => {
47
+ const trackRef = useRef<HTMLDivElement>(null);
48
+ const [isHovering, setIsHovering] = useState(false);
49
+ const [isDragging, setIsDragging] = useState(false);
50
+ const [dragTime, setDragTime] = useState<number | null>(null);
51
+ const dragTimeRef = useRef<number | null>(null);
52
+ const [hoverPosition, setHoverPosition] = useState(0);
53
+ const [hoverTime, setHoverTime] = useState(0);
54
+
55
+ // Effective live edge (use provided or fall back to duration)
56
+ const effectiveLiveEdge = liveEdge ?? duration;
57
+
58
+ // Seekable window size
59
+ const seekableWindow = effectiveLiveEdge - seekableStart;
60
+
61
+ // Calculate progress percentage
62
+ // For live streams: position within the DVR window
63
+ // For VOD: position within total duration
64
+ const displayTime = dragTime ?? currentTime;
65
+ const progressPercent = useMemo(() => {
66
+ if (isLive && seekableWindow > 0) {
67
+ const positionInWindow = displayTime - seekableStart;
68
+ return Math.min(100, Math.max(0, (positionInWindow / seekableWindow) * 100));
69
+ }
70
+ if (!Number.isFinite(duration) || duration <= 0) return 0;
71
+ return Math.min(100, Math.max(0, (displayTime / duration) * 100));
72
+ }, [displayTime, duration, isLive, seekableStart, seekableWindow]);
73
+
74
+ // Calculate buffered segments as array of {start%, end%} for accurate display
75
+ const bufferedSegments = useMemo(() => {
76
+ if (!buffered || buffered.length === 0) return [];
77
+
78
+ const rangeEnd = isLive ? effectiveLiveEdge : duration;
79
+ const rangeStart = isLive ? seekableStart : 0;
80
+ const rangeSize = rangeEnd - rangeStart;
81
+
82
+ if (!Number.isFinite(rangeSize) || rangeSize <= 0) return [];
83
+
84
+ const segments: Array<{ startPercent: number; endPercent: number }> = [];
85
+ for (let i = 0; i < buffered.length; i++) {
86
+ const start = buffered.start(i);
87
+ const end = buffered.end(i);
88
+
89
+ // Calculate position relative to the seekable range
90
+ const relativeStart = start - rangeStart;
91
+ const relativeEnd = end - rangeStart;
92
+
93
+ segments.push({
94
+ startPercent: Math.min(100, Math.max(0, (relativeStart / rangeSize) * 100)),
95
+ endPercent: Math.min(100, Math.max(0, (relativeEnd / rangeSize) * 100)),
96
+ });
97
+ }
98
+ return segments;
99
+ }, [buffered, duration, isLive, seekableStart, effectiveLiveEdge]);
100
+
101
+ // Format time as MM:SS or HH:MM:SS (for VOD)
102
+ const formatTime = useCallback((seconds: number): string => {
103
+ if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
104
+ const total = Math.floor(seconds);
105
+ const hours = Math.floor(total / 3600);
106
+ const minutes = Math.floor((total % 3600) / 60);
107
+ const secs = total % 60;
108
+ if (hours > 0) {
109
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
110
+ }
111
+ return `${minutes}:${String(secs).padStart(2, "0")}`;
112
+ }, []);
113
+
114
+ // Format relative time for live streams (e.g., "-5:30" = 5:30 behind live edge)
115
+ const formatLiveTime = useCallback((seconds: number, edge: number): string => {
116
+ const behindSeconds = edge - seconds;
117
+ if (behindSeconds < 1) return "LIVE";
118
+ const total = Math.floor(behindSeconds);
119
+ const hours = Math.floor(total / 3600);
120
+ const minutes = Math.floor((total % 3600) / 60);
121
+ const secs = total % 60;
122
+ if (hours > 0) {
123
+ return `-${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
124
+ }
125
+ return `-${minutes}:${String(secs).padStart(2, "0")}`;
126
+ }, []);
127
+
128
+ // Calculate time from mouse position
129
+ // For live: maps position to time within DVR window
130
+ const getTimeFromPosition = useCallback((clientX: number): number => {
131
+ if (!trackRef.current) return 0;
132
+ const rect = trackRef.current.getBoundingClientRect();
133
+ const x = clientX - rect.left;
134
+ const percent = Math.min(1, Math.max(0, x / rect.width));
135
+
136
+ // Live with valid seekable window
137
+ if (isLive && Number.isFinite(seekableWindow) && seekableWindow > 0) {
138
+ return seekableStart + (percent * seekableWindow);
139
+ }
140
+
141
+ // VOD with finite duration
142
+ if (Number.isFinite(duration) && duration > 0) {
143
+ return percent * duration;
144
+ }
145
+
146
+ // Fallback: If we have liveEdge, use it even if not marked as live
147
+ // This handles cases where duration is Infinity but we have valid seekable data
148
+ if (Number.isFinite(liveEdge) && liveEdge > 0) {
149
+ const start = Number.isFinite(seekableStart) ? seekableStart : 0;
150
+ const window = liveEdge - start;
151
+ if (window > 0) {
152
+ return start + (percent * window);
153
+ }
154
+ }
155
+
156
+ // Last resort: use currentTime as a baseline
157
+ return percent * (currentTime || 1);
158
+ }, [duration, isLive, seekableStart, seekableWindow, liveEdge, currentTime]);
159
+
160
+ // Handle mouse move for hover preview
161
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
162
+ if (!trackRef.current || disabled) return;
163
+ const rect = trackRef.current.getBoundingClientRect();
164
+ const x = e.clientX - rect.left;
165
+ const percent = Math.min(1, Math.max(0, x / rect.width));
166
+ setHoverPosition(percent * 100);
167
+ setHoverTime(getTimeFromPosition(e.clientX));
168
+ }, [disabled, getTimeFromPosition]);
169
+
170
+ // Handle click to seek
171
+ const handleClick = useCallback((e: React.MouseEvent) => {
172
+ if (disabled) return;
173
+ if (!isLive && !Number.isFinite(duration)) return;
174
+ const time = getTimeFromPosition(e.clientX);
175
+ onSeek?.(time);
176
+ setDragTime(null);
177
+ dragTimeRef.current = null;
178
+ }, [disabled, duration, isLive, getTimeFromPosition, onSeek]);
179
+
180
+ // Handle drag start
181
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
182
+ if (disabled) return;
183
+ if (!isLive && !Number.isFinite(duration)) return;
184
+ e.preventDefault();
185
+ setIsDragging(true);
186
+
187
+ const handleDragMove = (moveEvent: MouseEvent) => {
188
+ const time = getTimeFromPosition(moveEvent.clientX);
189
+ if (commitOnRelease) {
190
+ setDragTime(time);
191
+ dragTimeRef.current = time;
192
+ } else {
193
+ onSeek?.(time);
194
+ }
195
+ };
196
+
197
+ const handleDragEnd = () => {
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]);
221
+
222
+ const showThumb = isHovering || isDragging;
223
+ const canShowTooltip = isLive ? seekableWindow > 0 : Number.isFinite(duration);
224
+
225
+ return (
226
+ <div
227
+ ref={trackRef}
228
+ className={cn(
229
+ "group relative w-full h-6 flex items-center cursor-pointer",
230
+ disabled && "opacity-50 cursor-not-allowed",
231
+ className
232
+ )}
233
+ onMouseEnter={() => !disabled && setIsHovering(true)}
234
+ onMouseLeave={() => { setIsHovering(false); setIsDragging(false); }}
235
+ onMouseMove={handleMouseMove}
236
+ onClick={handleClick}
237
+ onMouseDown={handleMouseDown}
238
+ role="slider"
239
+ aria-label="Seek"
240
+ aria-valuemin={isLive ? seekableStart : 0}
241
+ aria-valuemax={isLive ? effectiveLiveEdge : (duration || 100)}
242
+ aria-valuenow={displayTime}
243
+ aria-valuetext={isLive ? formatLiveTime(displayTime, effectiveLiveEdge) : formatTime(displayTime)}
244
+ tabIndex={disabled ? -1 : 0}
245
+ >
246
+ {/* Track background */}
247
+ <div className={cn(
248
+ "fw-seek-track",
249
+ isDragging && "fw-seek-track--active"
250
+ )}>
251
+ {/* Buffered segments - show actual buffered ranges */}
252
+ {bufferedSegments.map((segment, index) => (
253
+ <div
254
+ key={index}
255
+ className="fw-seek-buffered"
256
+ style={{
257
+ left: `${segment.startPercent}%`,
258
+ width: `${segment.endPercent - segment.startPercent}%`,
259
+ }}
260
+ />
261
+ ))}
262
+ {/* Playback progress */}
263
+ <div
264
+ className="fw-seek-progress"
265
+ style={{ width: `${progressPercent}%` }}
266
+ />
267
+ </div>
268
+
269
+ {/* Thumb */}
270
+ <div
271
+ className={cn(
272
+ "fw-seek-thumb",
273
+ showThumb ? "fw-seek-thumb--active" : "fw-seek-thumb--hidden"
274
+ )}
275
+ style={{ left: `${progressPercent}%` }}
276
+ />
277
+
278
+ {/* Hover time tooltip */}
279
+ {isHovering && !isDragging && canShowTooltip && (
280
+ <div
281
+ className="fw-seek-tooltip"
282
+ style={{ left: `${hoverPosition}%` }}
283
+ >
284
+ {isLive ? formatLiveTime(hoverTime, effectiveLiveEdge) : formatTime(hoverTime)}
285
+ </div>
286
+ )}
287
+ </div>
288
+ );
289
+ };
290
+
291
+ export default SeekBar;
@@ -0,0 +1,113 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { cn } from "@livepeer-frameworks/player-core";
3
+
4
+ export type SkipDirection = "back" | "forward" | null;
5
+
6
+ interface SkipIndicatorProps {
7
+ direction: SkipDirection;
8
+ seconds?: number;
9
+ className?: string;
10
+ onHide?: () => void;
11
+ }
12
+
13
+ /**
14
+ * Skip indicator overlay that appears when double-tapping to skip.
15
+ * Shows the skip direction and amount (e.g., "-10s" or "+10s") with a ripple effect.
16
+ */
17
+ const SkipIndicator: React.FC<SkipIndicatorProps> = ({
18
+ direction,
19
+ seconds = 10,
20
+ className,
21
+ onHide,
22
+ }) => {
23
+ const [isAnimating, setIsAnimating] = useState(false);
24
+
25
+ useEffect(() => {
26
+ if (direction) {
27
+ setIsAnimating(true);
28
+ const timer = setTimeout(() => {
29
+ setIsAnimating(false);
30
+ onHide?.();
31
+ }, 600);
32
+ return () => clearTimeout(timer);
33
+ }
34
+ }, [direction, onHide]);
35
+
36
+ if (!direction) return null;
37
+
38
+ const isBack = direction === "back";
39
+
40
+ return (
41
+ <div
42
+ className={cn(
43
+ "fw-skip-indicator absolute inset-0 z-30 pointer-events-none",
44
+ "flex items-center",
45
+ isBack ? "justify-start pl-8" : "justify-end pr-8",
46
+ className
47
+ )}
48
+ >
49
+ {/* Ripple background */}
50
+ <div
51
+ className={cn(
52
+ "absolute top-0 bottom-0 w-1/3",
53
+ isBack ? "left-0" : "right-0",
54
+ "bg-white/10",
55
+ isAnimating && "animate-pulse"
56
+ )}
57
+ />
58
+
59
+ {/* Skip content */}
60
+ <div
61
+ className={cn(
62
+ "relative flex flex-col items-center gap-1 text-white",
63
+ "transition-all duration-200",
64
+ isAnimating ? "opacity-100 scale-100" : "opacity-0 scale-75"
65
+ )}
66
+ >
67
+ {/* Icon */}
68
+ <div className="flex">
69
+ {isBack ? (
70
+ <>
71
+ <RewindIcon className="w-8 h-8" />
72
+ <RewindIcon className="w-8 h-8 -ml-4" />
73
+ </>
74
+ ) : (
75
+ <>
76
+ <FastForwardIcon className="w-8 h-8" />
77
+ <FastForwardIcon className="w-8 h-8 -ml-4" />
78
+ </>
79
+ )}
80
+ </div>
81
+
82
+ {/* Text */}
83
+ <span className="text-sm font-semibold tabular-nums">
84
+ {isBack ? `-${seconds}s` : `+${seconds}s`}
85
+ </span>
86
+ </div>
87
+ </div>
88
+ );
89
+ };
90
+
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
+ >
98
+ <path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
99
+ </svg>
100
+ );
101
+
102
+ 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
+ >
109
+ <path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
110
+ </svg>
111
+ );
112
+
113
+ export default SkipIndicator;
@@ -0,0 +1,57 @@
1
+ import React from "react";
2
+ import { cn } from "@livepeer-frameworks/player-core";
3
+
4
+ interface SpeedIndicatorProps {
5
+ isVisible: boolean;
6
+ speed: number;
7
+ className?: string;
8
+ }
9
+
10
+ /**
11
+ * Speed indicator overlay that appears when holding for fast-forward.
12
+ * Shows the current playback speed (e.g., "2x") in a pill overlay.
13
+ */
14
+ const SpeedIndicator: React.FC<SpeedIndicatorProps> = ({
15
+ isVisible,
16
+ speed,
17
+ className,
18
+ }) => {
19
+ return (
20
+ <div
21
+ className={cn(
22
+ "fw-speed-indicator absolute top-3 right-3 z-30 pointer-events-none",
23
+ "transition-opacity duration-150",
24
+ isVisible ? "opacity-100" : "opacity-0",
25
+ className
26
+ )}
27
+ >
28
+ <div
29
+ className={cn(
30
+ "bg-black/60 text-white px-2.5 py-1 rounded-md",
31
+ "text-xs font-semibold tabular-nums",
32
+ "flex items-center gap-2",
33
+ "border border-white/15",
34
+ "transform transition-transform duration-150",
35
+ isVisible ? "scale-100" : "scale-90"
36
+ )}
37
+ >
38
+ <FastForwardIcon className="w-4 h-4" />
39
+ <span>{speed}x</span>
40
+ </div>
41
+ </div>
42
+ );
43
+ };
44
+
45
+ // Simple fast-forward icon
46
+ 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
+ >
53
+ <path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
54
+ </svg>
55
+ );
56
+
57
+ export default SpeedIndicator;
@@ -0,0 +1,150 @@
1
+ import React from "react";
2
+ import { cn, type ContentMetadata, type PlaybackQuality } from "@livepeer-frameworks/player-core";
3
+
4
+ interface StreamStateInfo {
5
+ status?: string;
6
+ viewers?: number;
7
+ tracks?: Array<{
8
+ type: string;
9
+ codec: string;
10
+ width?: number;
11
+ height?: number;
12
+ bps?: number;
13
+ channels?: number;
14
+ }>;
15
+ }
16
+
17
+ interface StatsPanelProps {
18
+ isOpen: boolean;
19
+ onClose: () => void;
20
+ metadata?: ContentMetadata | null;
21
+ streamState?: StreamStateInfo | null;
22
+ quality?: PlaybackQuality | null;
23
+ videoElement?: HTMLVideoElement | null;
24
+ protocol?: string;
25
+ nodeId?: string;
26
+ geoDistance?: number;
27
+ }
28
+
29
+ /**
30
+ * "Stats for nerds" panel showing detailed playback information.
31
+ * Toggleable overlay with technical details about the stream.
32
+ */
33
+ const StatsPanel: React.FC<StatsPanelProps> = ({
34
+ isOpen,
35
+ onClose,
36
+ metadata,
37
+ streamState,
38
+ quality,
39
+ videoElement,
40
+ protocol,
41
+ nodeId,
42
+ geoDistance,
43
+ }) => {
44
+ if (!isOpen) return null;
45
+
46
+ // Video element stats
47
+ const video = videoElement;
48
+ const currentRes = video ? `${video.videoWidth}x${video.videoHeight}` : "—";
49
+ const buffered = video && video.buffered.length > 0
50
+ ? (video.buffered.end(video.buffered.length - 1) - video.currentTime).toFixed(1)
51
+ : "—";
52
+ const playbackRate = video?.playbackRate?.toFixed(2) ?? "1.00";
53
+
54
+ // Quality monitor stats
55
+ const qualityScore = quality?.score?.toFixed(0) ?? "—";
56
+ const bitrateKbps = quality?.bitrate
57
+ ? `${(quality.bitrate / 1000).toFixed(0)} kbps`
58
+ : "—";
59
+ const frameDropRate = quality?.frameDropRate?.toFixed(1) ?? "—";
60
+ const stallCount = quality?.stallCount ?? 0;
61
+ const latency = quality?.latency ? `${Math.round(quality.latency)} ms` : "—";
62
+
63
+ // Stream state stats
64
+ const viewers = streamState?.viewers ?? metadata?.viewers ?? "—";
65
+ const streamStatus = streamState?.status ?? metadata?.status ?? "—";
66
+
67
+ // Format track info from metadata
68
+ const formatTracks = () => {
69
+ if (!streamState?.tracks?.length) return "—";
70
+ return streamState.tracks.map(t => {
71
+ if (t.type === "video") {
72
+ return `${t.codec} ${t.width}x${t.height}@${t.bps ? Math.round(t.bps / 1000) + "kbps" : "?"}`;
73
+ }
74
+ return `${t.codec} ${t.channels}ch`;
75
+ }).join(", ");
76
+ };
77
+
78
+ const stats = [
79
+ { label: "Resolution", value: currentRes },
80
+ { label: "Buffer", value: `${buffered}s` },
81
+ { label: "Latency", value: latency },
82
+ { label: "Bitrate", value: bitrateKbps },
83
+ { label: "Quality Score", value: `${qualityScore}/100` },
84
+ { label: "Frame Drop Rate", value: `${frameDropRate}%` },
85
+ { label: "Stalls", value: String(stallCount) },
86
+ { label: "Playback Rate", value: `${playbackRate}x` },
87
+ { label: "Protocol", value: protocol ?? "—" },
88
+ { label: "Node", value: nodeId ?? "—" },
89
+ { label: "Geo Distance", value: geoDistance ? `${geoDistance.toFixed(0)} km` : "—" },
90
+ { label: "Viewers", value: String(viewers) },
91
+ { label: "Status", value: streamStatus },
92
+ { label: "Tracks", value: formatTracks() },
93
+ ];
94
+
95
+ // Add metadata fields if available
96
+ if (metadata?.title) {
97
+ stats.unshift({ label: "Title", value: metadata.title });
98
+ }
99
+ if (metadata?.durationSeconds) {
100
+ const mins = Math.floor(metadata.durationSeconds / 60);
101
+ const secs = metadata.durationSeconds % 60;
102
+ stats.push({ label: "Duration", value: `${mins}:${String(secs).padStart(2, "0")}` });
103
+ }
104
+ if (metadata?.recordingSizeBytes) {
105
+ const mb = (metadata.recordingSizeBytes / (1024 * 1024)).toFixed(1);
106
+ stats.push({ label: "Size", value: `${mb} MB` });
107
+ }
108
+
109
+ return (
110
+ <div
111
+ className={cn(
112
+ "fw-stats-panel absolute top-2 right-2 z-30",
113
+ "bg-black border border-white/10 rounded",
114
+ "text-white text-xs font-mono",
115
+ "max-w-[320px] max-h-[80%] overflow-auto",
116
+ "shadow-lg"
117
+ )}
118
+ style={{ backgroundColor: '#000000' }} // Inline fallback for opaque background
119
+ >
120
+ {/* Header */}
121
+ <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>
125
+ <button
126
+ type="button"
127
+ onClick={onClose}
128
+ className="text-white/50 hover:text-white transition-colors p-1 -mr-1"
129
+ aria-label="Close stats panel"
130
+ >
131
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
132
+ <path d="M2 2l8 8M10 2l-8 8" />
133
+ </svg>
134
+ </button>
135
+ </div>
136
+
137
+ {/* Stats grid */}
138
+ <div className="px-3 py-2 space-y-1">
139
+ {stats.map(({ label, value }) => (
140
+ <div key={label} className="flex justify-between gap-4">
141
+ <span className="text-white/50 shrink-0">{label}</span>
142
+ <span className="text-white/90 truncate text-right">{value}</span>
143
+ </div>
144
+ ))}
145
+ </div>
146
+ </div>
147
+ );
148
+ };
149
+
150
+ export default StatsPanel;