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