@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
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
SkipBackIcon,
|
|
24
24
|
SkipForwardIcon,
|
|
25
25
|
VolumeIcon,
|
|
26
|
-
SettingsIcon
|
|
26
|
+
SettingsIcon,
|
|
27
27
|
} from "./Icons";
|
|
28
28
|
import type { MistStreamInfo, PlaybackMode } from "../types";
|
|
29
29
|
|
|
@@ -51,7 +51,15 @@ interface PlayerControlsProps {
|
|
|
51
51
|
/** Video element - passed from parent hook */
|
|
52
52
|
videoElement?: HTMLVideoElement | null;
|
|
53
53
|
/** Available quality levels - passed from parent hook */
|
|
54
|
-
qualities?: Array<{
|
|
54
|
+
qualities?: Array<{
|
|
55
|
+
id: string;
|
|
56
|
+
label: string;
|
|
57
|
+
bitrate?: number;
|
|
58
|
+
width?: number;
|
|
59
|
+
height?: number;
|
|
60
|
+
isAuto?: boolean;
|
|
61
|
+
active?: boolean;
|
|
62
|
+
}>;
|
|
55
63
|
/** Callback to select quality */
|
|
56
64
|
onSelectQuality?: (id: string) => void;
|
|
57
65
|
/** Is player muted */
|
|
@@ -78,7 +86,6 @@ interface PlayerControlsProps {
|
|
|
78
86
|
onJumpToLive?: () => void;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
|
|
82
89
|
const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
83
90
|
currentTime,
|
|
84
91
|
duration,
|
|
@@ -87,7 +94,7 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
87
94
|
onSeek,
|
|
88
95
|
mistStreamInfo,
|
|
89
96
|
disabled = false,
|
|
90
|
-
playbackMode =
|
|
97
|
+
playbackMode = "auto",
|
|
91
98
|
onModeChange,
|
|
92
99
|
sourceType,
|
|
93
100
|
isContentLive,
|
|
@@ -120,9 +127,10 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
120
127
|
if (propVideoElement) return propVideoElement;
|
|
121
128
|
if (contextVideo) return contextVideo;
|
|
122
129
|
if (player?.getVideoElement?.()) return player.getVideoElement();
|
|
123
|
-
const domVideo =
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
const domVideo =
|
|
131
|
+
(document.querySelector(".fw-player-video") as HTMLVideoElement | null) ??
|
|
132
|
+
(document.querySelector('[data-player-container="true"] video') as HTMLVideoElement | null) ??
|
|
133
|
+
(document.querySelector(".fw-player-container video") as HTMLVideoElement | null);
|
|
126
134
|
return domVideo;
|
|
127
135
|
}, [propVideoElement, contextVideo, player]);
|
|
128
136
|
|
|
@@ -183,7 +191,7 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
183
191
|
// Fallback to Mist track metadata for players without quality API
|
|
184
192
|
if (mistTracks) {
|
|
185
193
|
return Object.entries(mistTracks)
|
|
186
|
-
.filter(([, t]) => t.type ===
|
|
194
|
+
.filter(([, t]) => t.type === "video")
|
|
187
195
|
.map(([id, t]) => ({
|
|
188
196
|
id,
|
|
189
197
|
label: t.height ? `${t.height}p` : t.codec,
|
|
@@ -214,7 +222,9 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
214
222
|
const isPlaying = propIsPlaying ?? internalIsPlaying;
|
|
215
223
|
const isMuted = propIsMuted ?? internalIsMuted;
|
|
216
224
|
const isFullscreen = propIsFullscreen ?? internalIsFullscreen;
|
|
217
|
-
const
|
|
225
|
+
const actualVolume = propVolume !== undefined ? Math.round(propVolume * 100) : internalVolume;
|
|
226
|
+
// Show 0 when muted, actual volume otherwise
|
|
227
|
+
const volumeValue = isMuted ? 0 : actualVolume;
|
|
218
228
|
const [qualityValue, setQualityValue] = useState<string>("auto");
|
|
219
229
|
const [captionValue, setCaptionValue] = useState<string>("none");
|
|
220
230
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
@@ -227,81 +237,106 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
227
237
|
|
|
228
238
|
const handleWindowClick = (event: MouseEvent) => {
|
|
229
239
|
const target = event.target as HTMLElement;
|
|
230
|
-
if (target && !target.closest(
|
|
240
|
+
if (target && !target.closest(".fw-settings-menu")) {
|
|
231
241
|
setIsSettingsOpen(false);
|
|
232
242
|
}
|
|
233
243
|
};
|
|
234
244
|
|
|
235
245
|
// Use setTimeout to avoid immediate close from the same click that opened it
|
|
236
246
|
const timeoutId = setTimeout(() => {
|
|
237
|
-
window.addEventListener(
|
|
247
|
+
window.addEventListener("click", handleWindowClick);
|
|
238
248
|
}, 0);
|
|
239
249
|
|
|
240
250
|
return () => {
|
|
241
251
|
clearTimeout(timeoutId);
|
|
242
|
-
window.removeEventListener(
|
|
252
|
+
window.removeEventListener("click", handleWindowClick);
|
|
243
253
|
};
|
|
244
254
|
}, [isSettingsOpen]);
|
|
245
255
|
|
|
246
256
|
// Core utility-based calculations
|
|
247
|
-
const deriveBufferWindowMs = useCallback(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
257
|
+
const deriveBufferWindowMs = useCallback(
|
|
258
|
+
(tracks?: Record<string, { firstms?: number; lastms?: number }>) => {
|
|
259
|
+
if (!tracks) return undefined;
|
|
260
|
+
const list = Object.values(tracks);
|
|
261
|
+
if (list.length === 0) return undefined;
|
|
262
|
+
const firstmsValues = list.map((t) => t.firstms).filter((v): v is number => v !== undefined);
|
|
263
|
+
const lastmsValues = list.map((t) => t.lastms).filter((v): v is number => v !== undefined);
|
|
264
|
+
if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
|
|
265
|
+
const firstms = Math.max(...firstmsValues);
|
|
266
|
+
const lastms = Math.min(...lastmsValues);
|
|
267
|
+
const window = lastms - firstms;
|
|
268
|
+
if (!Number.isFinite(window) || window <= 0) return undefined;
|
|
269
|
+
return window;
|
|
270
|
+
},
|
|
271
|
+
[]
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const bufferWindowMs =
|
|
275
|
+
mistStreamInfo?.meta?.buffer_window ??
|
|
276
|
+
deriveBufferWindowMs(
|
|
277
|
+
mistStreamInfo?.meta?.tracks as
|
|
278
|
+
| Record<string, { firstms?: number; lastms?: number }>
|
|
279
|
+
| undefined
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const isLive = useMemo(
|
|
283
|
+
() => isLiveContent(isContentLive, mistStreamInfo, duration),
|
|
284
|
+
[isContentLive, mistStreamInfo, duration]
|
|
285
|
+
);
|
|
266
286
|
|
|
267
287
|
const isWebRTC = useMemo(() => isMediaStreamSource(video), [video]);
|
|
268
288
|
|
|
269
289
|
const supportsPlaybackRate = useMemo(() => coreSupportsPlaybackRate(video), [video]);
|
|
270
290
|
|
|
271
291
|
// Seekable range using core calculation (allow controller override)
|
|
272
|
-
const allowMediaStreamDvr =
|
|
273
|
-
(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
292
|
+
const allowMediaStreamDvr =
|
|
293
|
+
isMediaStreamSource(video) &&
|
|
294
|
+
bufferWindowMs !== undefined &&
|
|
295
|
+
bufferWindowMs > 0 &&
|
|
296
|
+
sourceType !== "whep" &&
|
|
297
|
+
sourceType !== "webrtc";
|
|
298
|
+
const { seekableStart: calcSeekableStart, liveEdge: calcLiveEdge } = useMemo(
|
|
299
|
+
() =>
|
|
300
|
+
calculateSeekableRange({
|
|
301
|
+
isLive,
|
|
302
|
+
video,
|
|
303
|
+
mistStreamInfo,
|
|
304
|
+
currentTime,
|
|
305
|
+
duration,
|
|
306
|
+
allowMediaStreamDvr,
|
|
307
|
+
}),
|
|
308
|
+
[isLive, video, mistStreamInfo, currentTime, duration, allowMediaStreamDvr]
|
|
309
|
+
);
|
|
283
310
|
const controllerSeekableStart = player?.getSeekableStart?.();
|
|
284
311
|
const controllerLiveEdge = player?.getLiveEdge?.();
|
|
285
|
-
const useControllerRange =
|
|
312
|
+
const useControllerRange =
|
|
313
|
+
Number.isFinite(controllerSeekableStart) &&
|
|
286
314
|
Number.isFinite(controllerLiveEdge) &&
|
|
287
315
|
(controllerLiveEdge as number) >= (controllerSeekableStart as number) &&
|
|
288
316
|
((controllerLiveEdge as number) > 0 || (controllerSeekableStart as number) > 0);
|
|
289
|
-
const seekableStart = useControllerRange
|
|
317
|
+
const seekableStart = useControllerRange
|
|
318
|
+
? (controllerSeekableStart as number)
|
|
319
|
+
: calcSeekableStart;
|
|
290
320
|
const liveEdge = useControllerRange ? (controllerLiveEdge as number) : calcLiveEdge;
|
|
291
321
|
|
|
292
|
-
const hasDvrWindow =
|
|
322
|
+
const hasDvrWindow =
|
|
323
|
+
isLive &&
|
|
324
|
+
Number.isFinite(liveEdge) &&
|
|
325
|
+
Number.isFinite(seekableStart) &&
|
|
326
|
+
liveEdge > seekableStart;
|
|
293
327
|
const commitOnRelease = isLive;
|
|
294
328
|
|
|
295
329
|
// Live thresholds with buffer window scaling
|
|
296
|
-
const liveThresholds = useMemo(
|
|
297
|
-
calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs),
|
|
298
|
-
[sourceType, isWebRTC, bufferWindowMs]
|
|
330
|
+
const liveThresholds = useMemo(
|
|
331
|
+
() => calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs),
|
|
332
|
+
[sourceType, isWebRTC, bufferWindowMs]
|
|
333
|
+
);
|
|
299
334
|
|
|
300
335
|
// Can seek - prefer PlayerController's computed value (includes player-specific canSeek)
|
|
301
336
|
// Fall back to utility function when controller not available
|
|
302
337
|
const baseCanSeek = useMemo(() => {
|
|
303
338
|
// PlayerController already computes canSeek with player-specific logic
|
|
304
|
-
if (player && typeof (player as any).canSeekStream ===
|
|
339
|
+
if (player && typeof (player as any).canSeekStream === "function") {
|
|
305
340
|
return (player as any).canSeekStream();
|
|
306
341
|
}
|
|
307
342
|
// Fallback when no controller
|
|
@@ -353,7 +388,8 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
353
388
|
video.addEventListener("playing", updatePlayingState);
|
|
354
389
|
video.addEventListener("volumechange", updateMutedState);
|
|
355
390
|
video.addEventListener("ratechange", updatePlaybackRate);
|
|
356
|
-
if (typeof document !== "undefined")
|
|
391
|
+
if (typeof document !== "undefined")
|
|
392
|
+
document.addEventListener("fullscreenchange", updateFullscreenState);
|
|
357
393
|
|
|
358
394
|
return () => {
|
|
359
395
|
video.removeEventListener("play", updatePlayingState);
|
|
@@ -361,7 +397,8 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
361
397
|
video.removeEventListener("playing", updatePlayingState);
|
|
362
398
|
video.removeEventListener("volumechange", updateMutedState);
|
|
363
399
|
video.removeEventListener("ratechange", updatePlaybackRate);
|
|
364
|
-
if (typeof document !== "undefined")
|
|
400
|
+
if (typeof document !== "undefined")
|
|
401
|
+
document.removeEventListener("fullscreenchange", updateFullscreenState);
|
|
365
402
|
};
|
|
366
403
|
}, [video, isLive]);
|
|
367
404
|
|
|
@@ -392,7 +429,10 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
392
429
|
}, [video]);
|
|
393
430
|
|
|
394
431
|
useEffect(() => {
|
|
395
|
-
if (!video) {
|
|
432
|
+
if (!video) {
|
|
433
|
+
setHasAudio(true);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
396
436
|
const checkAudio = () => {
|
|
397
437
|
if (video.srcObject instanceof MediaStream) {
|
|
398
438
|
const audioTracks = video.srcObject.getAudioTracks();
|
|
@@ -459,7 +499,7 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
459
499
|
return;
|
|
460
500
|
}
|
|
461
501
|
// Fallback: direct video/player manipulation
|
|
462
|
-
const v = video ?? document.querySelector(
|
|
502
|
+
const v = video ?? (document.querySelector(".fw-player-video") as HTMLVideoElement | null);
|
|
463
503
|
if (!v) return;
|
|
464
504
|
const nextMuted = !(player?.isMuted?.() ?? v.muted);
|
|
465
505
|
player?.setMuted?.(nextMuted);
|
|
@@ -478,7 +518,7 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
478
518
|
return;
|
|
479
519
|
}
|
|
480
520
|
// Fallback: direct video manipulation
|
|
481
|
-
const v = video ?? document.querySelector(
|
|
521
|
+
const v = video ?? (document.querySelector(".fw-player-video") as HTMLVideoElement | null);
|
|
482
522
|
if (!v) return;
|
|
483
523
|
v.volume = next / 100;
|
|
484
524
|
v.muted = next === 0;
|
|
@@ -495,7 +535,9 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
495
535
|
}
|
|
496
536
|
// Fallback: direct DOM manipulation
|
|
497
537
|
if (typeof document === "undefined") return;
|
|
498
|
-
const container = document.querySelector(
|
|
538
|
+
const container = document.querySelector(
|
|
539
|
+
'[data-player-container="true"]'
|
|
540
|
+
) as HTMLElement | null;
|
|
499
541
|
if (!container) return;
|
|
500
542
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
|
501
543
|
else container.requestFullscreen().catch(() => {});
|
|
@@ -543,29 +585,32 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
543
585
|
};
|
|
544
586
|
|
|
545
587
|
// Time display - using core formatTimeDisplay
|
|
546
|
-
const timeDisplay = useMemo(
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
588
|
+
const timeDisplay = useMemo(
|
|
589
|
+
() =>
|
|
590
|
+
formatTimeDisplay({
|
|
591
|
+
isLive,
|
|
592
|
+
currentTime,
|
|
593
|
+
duration,
|
|
594
|
+
liveEdge,
|
|
595
|
+
seekableStart,
|
|
596
|
+
unixoffset: mistStreamInfo?.unixoffset,
|
|
597
|
+
}),
|
|
598
|
+
[isLive, currentTime, duration, liveEdge, seekableStart, mistStreamInfo?.unixoffset]
|
|
599
|
+
);
|
|
554
600
|
|
|
555
601
|
const [isVolumeHovered, setIsVolumeHovered] = useState(false);
|
|
556
602
|
const [isVolumeFocused, setIsVolumeFocused] = useState(false);
|
|
557
603
|
const isVolumeExpanded = isVolumeHovered || isVolumeFocused;
|
|
558
604
|
|
|
559
605
|
return (
|
|
560
|
-
<div
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
606
|
+
<div
|
|
607
|
+
className={cn(
|
|
608
|
+
"fw-player-surface fw-controls-wrapper",
|
|
609
|
+
isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
|
|
610
|
+
)}
|
|
611
|
+
>
|
|
564
612
|
{/* Bottom Row: Controls with SeekBar on top */}
|
|
565
|
-
<div
|
|
566
|
-
className="fw-control-bar pointer-events-auto"
|
|
567
|
-
onClick={(e) => e.stopPropagation()}
|
|
568
|
-
>
|
|
613
|
+
<div className="fw-control-bar pointer-events-auto" onClick={(e) => e.stopPropagation()}>
|
|
569
614
|
{/* SeekBar - sits directly on top of control buttons */}
|
|
570
615
|
{canSeek && (
|
|
571
616
|
<div className="fw-seek-wrapper">
|
|
@@ -591,222 +636,270 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
591
636
|
|
|
592
637
|
{/* Control buttons row */}
|
|
593
638
|
<div className="fw-controls-row">
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
639
|
+
{/* Left: Controls & Time */}
|
|
640
|
+
<div className="fw-controls-left">
|
|
641
|
+
<div className="fw-control-group">
|
|
642
|
+
<button
|
|
643
|
+
type="button"
|
|
644
|
+
className="fw-btn-flush"
|
|
645
|
+
aria-label={isPlaying ? "Pause" : "Play"}
|
|
646
|
+
onClick={handlePlayPause}
|
|
647
|
+
>
|
|
648
|
+
<PlayPauseIcon isPlaying={isPlaying} size={18} />
|
|
649
|
+
</button>
|
|
650
|
+
{canSeek && (
|
|
651
|
+
<>
|
|
652
|
+
<button
|
|
653
|
+
type="button"
|
|
654
|
+
className="fw-btn-flush hidden sm:flex"
|
|
655
|
+
aria-label="Skip back 10 seconds"
|
|
656
|
+
onClick={handleSkipBack}
|
|
657
|
+
>
|
|
658
|
+
<SkipBackIcon size={16} />
|
|
659
|
+
</button>
|
|
660
|
+
<button
|
|
661
|
+
type="button"
|
|
662
|
+
className="fw-btn-flush hidden sm:flex"
|
|
663
|
+
aria-label="Skip forward 10 seconds"
|
|
664
|
+
onClick={handleSkipForward}
|
|
665
|
+
>
|
|
666
|
+
<SkipForwardIcon size={16} />
|
|
667
|
+
</button>
|
|
668
|
+
</>
|
|
669
|
+
)}
|
|
670
|
+
</div>
|
|
611
671
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
>
|
|
635
|
-
{/* Volume icon - part of the pill */}
|
|
636
|
-
<button
|
|
637
|
-
type="button"
|
|
638
|
-
className="fw-volume-btn"
|
|
639
|
-
aria-label={!hasAudio ? "No audio" : (isMuted ? "Unmute" : "Mute")}
|
|
640
|
-
onClick={hasAudio ? handleMute : undefined}
|
|
641
|
-
disabled={!hasAudio}
|
|
672
|
+
{/* Volume pill - cohesive hover element (slab style) */}
|
|
673
|
+
<div
|
|
674
|
+
className={cn(
|
|
675
|
+
"fw-volume-group",
|
|
676
|
+
isVolumeExpanded && "fw-volume-group--expanded",
|
|
677
|
+
!hasAudio && "fw-volume-group--disabled"
|
|
678
|
+
)}
|
|
679
|
+
onMouseEnter={() => hasAudio && setIsVolumeHovered(true)}
|
|
680
|
+
onMouseLeave={() => {
|
|
681
|
+
setIsVolumeHovered(false);
|
|
682
|
+
setIsVolumeFocused(false);
|
|
683
|
+
}}
|
|
684
|
+
onFocusCapture={() => hasAudio && setIsVolumeFocused(true)}
|
|
685
|
+
onBlurCapture={(e) => {
|
|
686
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsVolumeFocused(false);
|
|
687
|
+
}}
|
|
688
|
+
onClick={(e) => {
|
|
689
|
+
// Click on the pill (not slider) toggles mute
|
|
690
|
+
if (hasAudio && e.target === e.currentTarget) {
|
|
691
|
+
handleMute();
|
|
692
|
+
}
|
|
693
|
+
}}
|
|
642
694
|
>
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
)}>
|
|
650
|
-
<Slider
|
|
651
|
-
orientation="horizontal"
|
|
652
|
-
aria-label="Volume"
|
|
653
|
-
max={100}
|
|
654
|
-
step={1}
|
|
655
|
-
value={[volumeValue]}
|
|
656
|
-
onValueChange={handleVolumeChange}
|
|
657
|
-
className="w-full"
|
|
695
|
+
{/* Volume icon - part of the pill */}
|
|
696
|
+
<button
|
|
697
|
+
type="button"
|
|
698
|
+
className="fw-volume-btn"
|
|
699
|
+
aria-label={!hasAudio ? "No audio" : isMuted ? "Unmute" : "Mute"}
|
|
700
|
+
onClick={hasAudio ? handleMute : undefined}
|
|
658
701
|
disabled={!hasAudio}
|
|
659
|
-
|
|
702
|
+
>
|
|
703
|
+
<VolumeIcon isMuted={isMuted || !hasAudio} size={16} />
|
|
704
|
+
</button>
|
|
705
|
+
{/* Slider - expands within the pill */}
|
|
706
|
+
<div
|
|
707
|
+
className={cn(
|
|
708
|
+
"fw-volume-slider-wrapper",
|
|
709
|
+
isVolumeExpanded
|
|
710
|
+
? "fw-volume-slider-wrapper--expanded"
|
|
711
|
+
: "fw-volume-slider-wrapper--collapsed"
|
|
712
|
+
)}
|
|
713
|
+
>
|
|
714
|
+
<Slider
|
|
715
|
+
orientation="horizontal"
|
|
716
|
+
aria-label="Volume"
|
|
717
|
+
max={100}
|
|
718
|
+
step={1}
|
|
719
|
+
value={[volumeValue]}
|
|
720
|
+
onValueChange={handleVolumeChange}
|
|
721
|
+
className="w-full"
|
|
722
|
+
disabled={!hasAudio}
|
|
723
|
+
/>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
|
|
727
|
+
<div className="fw-control-group">
|
|
728
|
+
<span className="fw-time-display">{timeDisplay}</span>
|
|
660
729
|
</div>
|
|
661
|
-
</div>
|
|
662
730
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
731
|
+
{isLive && (
|
|
732
|
+
<div className="fw-control-group">
|
|
733
|
+
<button
|
|
734
|
+
type="button"
|
|
735
|
+
onClick={handleGoLive}
|
|
736
|
+
disabled={!hasDvrWindow || isNearLiveState}
|
|
737
|
+
className={cn(
|
|
738
|
+
"fw-live-badge",
|
|
739
|
+
!hasDvrWindow || isNearLiveState
|
|
740
|
+
? "fw-live-badge--active"
|
|
741
|
+
: "fw-live-badge--behind"
|
|
742
|
+
)}
|
|
743
|
+
title={
|
|
744
|
+
!hasDvrWindow ? "Live only" : isNearLiveState ? "At live edge" : "Jump to live"
|
|
745
|
+
}
|
|
746
|
+
>
|
|
747
|
+
LIVE
|
|
748
|
+
{!isNearLiveState && hasDvrWindow && <SeekToLiveIcon size={10} />}
|
|
749
|
+
</button>
|
|
750
|
+
</div>
|
|
751
|
+
)}
|
|
667
752
|
</div>
|
|
668
753
|
|
|
669
|
-
{
|
|
670
|
-
|
|
754
|
+
{/* Right Group: Settings, Fullscreen */}
|
|
755
|
+
<div className="fw-controls-right">
|
|
756
|
+
<div className="fw-control-group relative">
|
|
671
757
|
<button
|
|
672
758
|
type="button"
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
(!hasDvrWindow || isNearLiveState) ? "fw-live-badge--active" : "fw-live-badge--behind"
|
|
678
|
-
)}
|
|
679
|
-
title={!hasDvrWindow ? "Live only" : (isNearLiveState ? "At live edge" : "Jump to live")}
|
|
759
|
+
className={cn("fw-btn-flush group", isSettingsOpen && "fw-btn-flush--active")}
|
|
760
|
+
aria-label="Settings"
|
|
761
|
+
title="Settings"
|
|
762
|
+
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
|
680
763
|
>
|
|
681
|
-
|
|
682
|
-
{!isNearLiveState && hasDvrWindow && <SeekToLiveIcon size={10} />}
|
|
764
|
+
<SettingsIcon size={16} className="transition-transform group-hover:rotate-90" />
|
|
683
765
|
</button>
|
|
684
|
-
</div>
|
|
685
|
-
)}
|
|
686
|
-
</div>
|
|
687
766
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
onClick={() => { onModeChange(mode); setIsSettingsOpen(false); }}
|
|
717
|
-
>
|
|
718
|
-
{mode === 'low-latency' ? 'Fast' : mode === 'quality' ? 'Stable' : 'Auto'}
|
|
719
|
-
</button>
|
|
720
|
-
))}
|
|
767
|
+
{/* Settings Popup */}
|
|
768
|
+
{isSettingsOpen && (
|
|
769
|
+
<div className="fw-player-surface fw-settings-menu">
|
|
770
|
+
{/* Playback Mode - only show for live content (not VOD/clips) */}
|
|
771
|
+
{onModeChange && isContentLive !== false && (
|
|
772
|
+
<div className="fw-settings-section">
|
|
773
|
+
<div className="fw-settings-label">Mode</div>
|
|
774
|
+
<div className="fw-settings-options">
|
|
775
|
+
{(["auto", "low-latency", "quality"] as const).map((mode) => (
|
|
776
|
+
<button
|
|
777
|
+
key={mode}
|
|
778
|
+
className={cn(
|
|
779
|
+
"fw-settings-btn",
|
|
780
|
+
playbackMode === mode && "fw-settings-btn--active"
|
|
781
|
+
)}
|
|
782
|
+
onClick={() => {
|
|
783
|
+
onModeChange(mode);
|
|
784
|
+
setIsSettingsOpen(false);
|
|
785
|
+
}}
|
|
786
|
+
>
|
|
787
|
+
{mode === "low-latency"
|
|
788
|
+
? "Fast"
|
|
789
|
+
: mode === "quality"
|
|
790
|
+
? "Stable"
|
|
791
|
+
: "Auto"}
|
|
792
|
+
</button>
|
|
793
|
+
))}
|
|
794
|
+
</div>
|
|
721
795
|
</div>
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
796
|
+
)}
|
|
797
|
+
{supportsPlaybackRate && (
|
|
798
|
+
<div className="fw-settings-section">
|
|
799
|
+
<div className="fw-settings-label">Speed</div>
|
|
800
|
+
<div className="fw-settings-options fw-settings-options--wrap">
|
|
801
|
+
{SPEED_PRESETS.map((rate) => (
|
|
802
|
+
<button
|
|
803
|
+
key={rate}
|
|
804
|
+
className={cn(
|
|
805
|
+
"fw-settings-btn",
|
|
806
|
+
playbackRate === rate && "fw-settings-btn--active"
|
|
807
|
+
)}
|
|
808
|
+
onClick={() => {
|
|
809
|
+
handleSpeedChange(String(rate));
|
|
810
|
+
setIsSettingsOpen(false);
|
|
811
|
+
}}
|
|
812
|
+
>
|
|
813
|
+
{rate}x
|
|
814
|
+
</button>
|
|
815
|
+
))}
|
|
816
|
+
</div>
|
|
740
817
|
</div>
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
<div className="fw-settings-list">
|
|
747
|
-
<button
|
|
748
|
-
className={cn(
|
|
749
|
-
"fw-settings-list-item",
|
|
750
|
-
qualityValue === 'auto' && "fw-settings-list-item--active"
|
|
751
|
-
)}
|
|
752
|
-
onClick={() => { handleQualityChange('auto'); setIsSettingsOpen(false); }}
|
|
753
|
-
>
|
|
754
|
-
Auto
|
|
755
|
-
</button>
|
|
756
|
-
{qualities.map((q) => (
|
|
818
|
+
)}
|
|
819
|
+
{qualities.length > 0 && (
|
|
820
|
+
<div className="fw-settings-section">
|
|
821
|
+
<div className="fw-settings-label">Quality</div>
|
|
822
|
+
<div className="fw-settings-list">
|
|
757
823
|
<button
|
|
758
|
-
key={q.id}
|
|
759
824
|
className={cn(
|
|
760
825
|
"fw-settings-list-item",
|
|
761
|
-
qualityValue ===
|
|
826
|
+
qualityValue === "auto" && "fw-settings-list-item--active"
|
|
762
827
|
)}
|
|
763
|
-
onClick={() => {
|
|
828
|
+
onClick={() => {
|
|
829
|
+
handleQualityChange("auto");
|
|
830
|
+
setIsSettingsOpen(false);
|
|
831
|
+
}}
|
|
764
832
|
>
|
|
765
|
-
|
|
833
|
+
Auto
|
|
766
834
|
</button>
|
|
767
|
-
|
|
835
|
+
{qualities.map((q) => (
|
|
836
|
+
<button
|
|
837
|
+
key={q.id}
|
|
838
|
+
className={cn(
|
|
839
|
+
"fw-settings-list-item",
|
|
840
|
+
qualityValue === q.id && "fw-settings-list-item--active"
|
|
841
|
+
)}
|
|
842
|
+
onClick={() => {
|
|
843
|
+
handleQualityChange(q.id);
|
|
844
|
+
setIsSettingsOpen(false);
|
|
845
|
+
}}
|
|
846
|
+
>
|
|
847
|
+
{q.label}
|
|
848
|
+
</button>
|
|
849
|
+
))}
|
|
850
|
+
</div>
|
|
768
851
|
</div>
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
<div className="fw-settings-list">
|
|
775
|
-
<button
|
|
776
|
-
className={cn(
|
|
777
|
-
"fw-settings-list-item",
|
|
778
|
-
captionValue === 'none' && "fw-settings-list-item--active"
|
|
779
|
-
)}
|
|
780
|
-
onClick={() => { handleCaptionChange('none'); setIsSettingsOpen(false); }}
|
|
781
|
-
>
|
|
782
|
-
Off
|
|
783
|
-
</button>
|
|
784
|
-
{textTracks.map((t) => (
|
|
852
|
+
)}
|
|
853
|
+
{textTracks.length > 0 && (
|
|
854
|
+
<div className="fw-settings-section">
|
|
855
|
+
<div className="fw-settings-label">Captions</div>
|
|
856
|
+
<div className="fw-settings-list">
|
|
785
857
|
<button
|
|
786
|
-
key={t.id}
|
|
787
858
|
className={cn(
|
|
788
859
|
"fw-settings-list-item",
|
|
789
|
-
captionValue ===
|
|
860
|
+
captionValue === "none" && "fw-settings-list-item--active"
|
|
790
861
|
)}
|
|
791
|
-
onClick={() => {
|
|
862
|
+
onClick={() => {
|
|
863
|
+
handleCaptionChange("none");
|
|
864
|
+
setIsSettingsOpen(false);
|
|
865
|
+
}}
|
|
792
866
|
>
|
|
793
|
-
|
|
867
|
+
Off
|
|
794
868
|
</button>
|
|
795
|
-
|
|
869
|
+
{textTracks.map((t) => (
|
|
870
|
+
<button
|
|
871
|
+
key={t.id}
|
|
872
|
+
className={cn(
|
|
873
|
+
"fw-settings-list-item",
|
|
874
|
+
captionValue === t.id && "fw-settings-list-item--active"
|
|
875
|
+
)}
|
|
876
|
+
onClick={() => {
|
|
877
|
+
handleCaptionChange(t.id);
|
|
878
|
+
setIsSettingsOpen(false);
|
|
879
|
+
}}
|
|
880
|
+
>
|
|
881
|
+
{t.label || t.id}
|
|
882
|
+
</button>
|
|
883
|
+
))}
|
|
884
|
+
</div>
|
|
796
885
|
</div>
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
</div>
|
|
886
|
+
)}
|
|
887
|
+
</div>
|
|
888
|
+
)}
|
|
889
|
+
</div>
|
|
802
890
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
891
|
+
<div className="fw-control-group">
|
|
892
|
+
<button
|
|
893
|
+
type="button"
|
|
894
|
+
className="fw-btn-flush"
|
|
895
|
+
aria-label="Toggle fullscreen"
|
|
896
|
+
onClick={handleFullscreen}
|
|
897
|
+
>
|
|
898
|
+
<FullscreenToggleIcon isFullscreen={isFullscreen} size={16} />
|
|
899
|
+
</button>
|
|
900
|
+
</div>
|
|
807
901
|
</div>
|
|
808
902
|
</div>
|
|
809
|
-
</div>
|
|
810
903
|
</div>
|
|
811
904
|
</div>
|
|
812
905
|
);
|