@livepeer-frameworks/player-react 0.0.4 → 0.1.1

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