@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.
- package/README.md +16 -5
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/types/components/PlayerControls.d.ts +2 -0
- package/dist/types/components/StatsPanel.d.ts +2 -14
- package/dist/types/hooks/useMetaTrack.d.ts +1 -1
- package/dist/types/hooks/usePlayerController.d.ts +2 -0
- package/dist/types/hooks/useStreamState.d.ts +1 -1
- package/dist/types/hooks/useTelemetry.d.ts +1 -1
- package/dist/types/hooks/useViewerEndpoints.d.ts +2 -2
- package/dist/types/types.d.ts +1 -1
- package/dist/types/ui/button.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/DevModePanel.tsx +249 -170
- package/src/components/Icons.tsx +105 -25
- package/src/components/IdleScreen.tsx +262 -142
- package/src/components/LoadingScreen.tsx +171 -153
- package/src/components/LogoOverlay.tsx +3 -6
- package/src/components/Player.tsx +86 -74
- package/src/components/PlayerControls.tsx +351 -263
- 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 +65 -34
- 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 +28 -28
- package/src/hooks/usePlaybackQuality.ts +3 -3
- package/src/hooks/usePlayerController.ts +186 -140
- package/src/hooks/usePlayerSelection.ts +6 -6
- package/src/hooks/useStreamState.ts +53 -58
- package/src/hooks/useTelemetry.ts +19 -4
- package/src/hooks/useViewerEndpoints.ts +40 -30
- package/src/index.tsx +36 -28
- package/src/types.ts +9 -9
- 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
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
cn,
|
|
5
5
|
// Seeking utilities from core
|
|
6
6
|
SPEED_PRESETS,
|
|
7
|
-
getLatencyTier,
|
|
8
7
|
isMediaStreamSource,
|
|
9
8
|
supportsPlaybackRate as coreSupportsPlaybackRate,
|
|
10
9
|
calculateSeekableRange,
|
|
@@ -13,22 +12,18 @@ import {
|
|
|
13
12
|
calculateIsNearLive,
|
|
14
13
|
isLiveContent,
|
|
15
14
|
// Time formatting from core
|
|
16
|
-
formatTime,
|
|
17
15
|
formatTimeDisplay,
|
|
18
16
|
} from "@livepeer-frameworks/player-core";
|
|
19
17
|
import { Slider } from "../ui/slider";
|
|
20
18
|
import SeekBar from "./SeekBar";
|
|
21
19
|
import {
|
|
22
|
-
ClosedCaptionsIcon,
|
|
23
20
|
FullscreenToggleIcon,
|
|
24
|
-
LiveIcon,
|
|
25
21
|
PlayPauseIcon,
|
|
26
22
|
SeekToLiveIcon,
|
|
27
23
|
SkipBackIcon,
|
|
28
24
|
SkipForwardIcon,
|
|
29
|
-
StatsIcon,
|
|
30
25
|
VolumeIcon,
|
|
31
|
-
SettingsIcon
|
|
26
|
+
SettingsIcon,
|
|
32
27
|
} from "./Icons";
|
|
33
28
|
import type { MistStreamInfo, PlaybackMode } from "../types";
|
|
34
29
|
|
|
@@ -56,7 +51,15 @@ interface PlayerControlsProps {
|
|
|
56
51
|
/** Video element - passed from parent hook */
|
|
57
52
|
videoElement?: HTMLVideoElement | null;
|
|
58
53
|
/** Available quality levels - passed from parent hook */
|
|
59
|
-
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
|
+
}>;
|
|
60
63
|
/** Callback to select quality */
|
|
61
64
|
onSelectQuality?: (id: string) => void;
|
|
62
65
|
/** Is player muted */
|
|
@@ -83,7 +86,6 @@ interface PlayerControlsProps {
|
|
|
83
86
|
onJumpToLive?: () => void;
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
|
|
87
89
|
const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
88
90
|
currentTime,
|
|
89
91
|
duration,
|
|
@@ -92,7 +94,7 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
92
94
|
onSeek,
|
|
93
95
|
mistStreamInfo,
|
|
94
96
|
disabled = false,
|
|
95
|
-
playbackMode =
|
|
97
|
+
playbackMode = "auto",
|
|
96
98
|
onModeChange,
|
|
97
99
|
sourceType,
|
|
98
100
|
isContentLive,
|
|
@@ -107,8 +109,8 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
107
109
|
onTogglePlay,
|
|
108
110
|
onToggleFullscreen,
|
|
109
111
|
isFullscreen: propIsFullscreen,
|
|
110
|
-
isLoopEnabled:
|
|
111
|
-
onToggleLoop,
|
|
112
|
+
isLoopEnabled: _propIsLoopEnabled,
|
|
113
|
+
onToggleLoop: _onToggleLoop,
|
|
112
114
|
onJumpToLive,
|
|
113
115
|
}) => {
|
|
114
116
|
// Context fallback - prefer props passed from parent over context
|
|
@@ -125,9 +127,10 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
125
127
|
if (propVideoElement) return propVideoElement;
|
|
126
128
|
if (contextVideo) return contextVideo;
|
|
127
129
|
if (player?.getVideoElement?.()) return player.getVideoElement();
|
|
128
|
-
const domVideo =
|
|
129
|
-
|
|
130
|
-
|
|
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);
|
|
131
134
|
return domVideo;
|
|
132
135
|
}, [propVideoElement, contextVideo, player]);
|
|
133
136
|
|
|
@@ -188,7 +191,7 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
188
191
|
// Fallback to Mist track metadata for players without quality API
|
|
189
192
|
if (mistTracks) {
|
|
190
193
|
return Object.entries(mistTracks)
|
|
191
|
-
.filter(([, t]) => t.type ===
|
|
194
|
+
.filter(([, t]) => t.type === "video")
|
|
192
195
|
.map(([id, t]) => ({
|
|
193
196
|
id,
|
|
194
197
|
label: t.height ? `${t.height}p` : t.codec,
|
|
@@ -219,7 +222,9 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
219
222
|
const isPlaying = propIsPlaying ?? internalIsPlaying;
|
|
220
223
|
const isMuted = propIsMuted ?? internalIsMuted;
|
|
221
224
|
const isFullscreen = propIsFullscreen ?? internalIsFullscreen;
|
|
222
|
-
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;
|
|
223
228
|
const [qualityValue, setQualityValue] = useState<string>("auto");
|
|
224
229
|
const [captionValue, setCaptionValue] = useState<string>("none");
|
|
225
230
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
@@ -232,81 +237,106 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
232
237
|
|
|
233
238
|
const handleWindowClick = (event: MouseEvent) => {
|
|
234
239
|
const target = event.target as HTMLElement;
|
|
235
|
-
if (target && !target.closest(
|
|
240
|
+
if (target && !target.closest(".fw-settings-menu")) {
|
|
236
241
|
setIsSettingsOpen(false);
|
|
237
242
|
}
|
|
238
243
|
};
|
|
239
244
|
|
|
240
245
|
// Use setTimeout to avoid immediate close from the same click that opened it
|
|
241
246
|
const timeoutId = setTimeout(() => {
|
|
242
|
-
window.addEventListener(
|
|
247
|
+
window.addEventListener("click", handleWindowClick);
|
|
243
248
|
}, 0);
|
|
244
249
|
|
|
245
250
|
return () => {
|
|
246
251
|
clearTimeout(timeoutId);
|
|
247
|
-
window.removeEventListener(
|
|
252
|
+
window.removeEventListener("click", handleWindowClick);
|
|
248
253
|
};
|
|
249
254
|
}, [isSettingsOpen]);
|
|
250
255
|
|
|
251
256
|
// Core utility-based calculations
|
|
252
|
-
const deriveBufferWindowMs = useCallback(
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
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
|
+
);
|
|
271
286
|
|
|
272
287
|
const isWebRTC = useMemo(() => isMediaStreamSource(video), [video]);
|
|
273
288
|
|
|
274
289
|
const supportsPlaybackRate = useMemo(() => coreSupportsPlaybackRate(video), [video]);
|
|
275
290
|
|
|
276
291
|
// Seekable range using core calculation (allow controller override)
|
|
277
|
-
const allowMediaStreamDvr =
|
|
278
|
-
(
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
);
|
|
288
310
|
const controllerSeekableStart = player?.getSeekableStart?.();
|
|
289
311
|
const controllerLiveEdge = player?.getLiveEdge?.();
|
|
290
|
-
const useControllerRange =
|
|
312
|
+
const useControllerRange =
|
|
313
|
+
Number.isFinite(controllerSeekableStart) &&
|
|
291
314
|
Number.isFinite(controllerLiveEdge) &&
|
|
292
315
|
(controllerLiveEdge as number) >= (controllerSeekableStart as number) &&
|
|
293
316
|
((controllerLiveEdge as number) > 0 || (controllerSeekableStart as number) > 0);
|
|
294
|
-
const seekableStart = useControllerRange
|
|
317
|
+
const seekableStart = useControllerRange
|
|
318
|
+
? (controllerSeekableStart as number)
|
|
319
|
+
: calcSeekableStart;
|
|
295
320
|
const liveEdge = useControllerRange ? (controllerLiveEdge as number) : calcLiveEdge;
|
|
296
321
|
|
|
297
|
-
const hasDvrWindow =
|
|
322
|
+
const hasDvrWindow =
|
|
323
|
+
isLive &&
|
|
324
|
+
Number.isFinite(liveEdge) &&
|
|
325
|
+
Number.isFinite(seekableStart) &&
|
|
326
|
+
liveEdge > seekableStart;
|
|
298
327
|
const commitOnRelease = isLive;
|
|
299
328
|
|
|
300
329
|
// Live thresholds with buffer window scaling
|
|
301
|
-
const liveThresholds = useMemo(
|
|
302
|
-
calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs),
|
|
303
|
-
[sourceType, isWebRTC, bufferWindowMs]
|
|
330
|
+
const liveThresholds = useMemo(
|
|
331
|
+
() => calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs),
|
|
332
|
+
[sourceType, isWebRTC, bufferWindowMs]
|
|
333
|
+
);
|
|
304
334
|
|
|
305
335
|
// Can seek - prefer PlayerController's computed value (includes player-specific canSeek)
|
|
306
336
|
// Fall back to utility function when controller not available
|
|
307
337
|
const baseCanSeek = useMemo(() => {
|
|
308
338
|
// PlayerController already computes canSeek with player-specific logic
|
|
309
|
-
if (player && typeof (player as any).canSeekStream ===
|
|
339
|
+
if (player && typeof (player as any).canSeekStream === "function") {
|
|
310
340
|
return (player as any).canSeekStream();
|
|
311
341
|
}
|
|
312
342
|
// Fallback when no controller
|
|
@@ -358,7 +388,8 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
358
388
|
video.addEventListener("playing", updatePlayingState);
|
|
359
389
|
video.addEventListener("volumechange", updateMutedState);
|
|
360
390
|
video.addEventListener("ratechange", updatePlaybackRate);
|
|
361
|
-
if (typeof document !== "undefined")
|
|
391
|
+
if (typeof document !== "undefined")
|
|
392
|
+
document.addEventListener("fullscreenchange", updateFullscreenState);
|
|
362
393
|
|
|
363
394
|
return () => {
|
|
364
395
|
video.removeEventListener("play", updatePlayingState);
|
|
@@ -366,7 +397,8 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
366
397
|
video.removeEventListener("playing", updatePlayingState);
|
|
367
398
|
video.removeEventListener("volumechange", updateMutedState);
|
|
368
399
|
video.removeEventListener("ratechange", updatePlaybackRate);
|
|
369
|
-
if (typeof document !== "undefined")
|
|
400
|
+
if (typeof document !== "undefined")
|
|
401
|
+
document.removeEventListener("fullscreenchange", updateFullscreenState);
|
|
370
402
|
};
|
|
371
403
|
}, [video, isLive]);
|
|
372
404
|
|
|
@@ -397,7 +429,10 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
397
429
|
}, [video]);
|
|
398
430
|
|
|
399
431
|
useEffect(() => {
|
|
400
|
-
if (!video) {
|
|
432
|
+
if (!video) {
|
|
433
|
+
setHasAudio(true);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
401
436
|
const checkAudio = () => {
|
|
402
437
|
if (video.srcObject instanceof MediaStream) {
|
|
403
438
|
const audioTracks = video.srcObject.getAudioTracks();
|
|
@@ -464,7 +499,7 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
464
499
|
return;
|
|
465
500
|
}
|
|
466
501
|
// Fallback: direct video/player manipulation
|
|
467
|
-
const v = video ?? document.querySelector(
|
|
502
|
+
const v = video ?? (document.querySelector(".fw-player-video") as HTMLVideoElement | null);
|
|
468
503
|
if (!v) return;
|
|
469
504
|
const nextMuted = !(player?.isMuted?.() ?? v.muted);
|
|
470
505
|
player?.setMuted?.(nextMuted);
|
|
@@ -483,7 +518,7 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
483
518
|
return;
|
|
484
519
|
}
|
|
485
520
|
// Fallback: direct video manipulation
|
|
486
|
-
const v = video ?? document.querySelector(
|
|
521
|
+
const v = video ?? (document.querySelector(".fw-player-video") as HTMLVideoElement | null);
|
|
487
522
|
if (!v) return;
|
|
488
523
|
v.volume = next / 100;
|
|
489
524
|
v.muted = next === 0;
|
|
@@ -500,7 +535,9 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
500
535
|
}
|
|
501
536
|
// Fallback: direct DOM manipulation
|
|
502
537
|
if (typeof document === "undefined") return;
|
|
503
|
-
const container = document.querySelector(
|
|
538
|
+
const container = document.querySelector(
|
|
539
|
+
'[data-player-container="true"]'
|
|
540
|
+
) as HTMLElement | null;
|
|
504
541
|
if (!container) return;
|
|
505
542
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
|
506
543
|
else container.requestFullscreen().catch(() => {});
|
|
@@ -548,29 +585,32 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
548
585
|
};
|
|
549
586
|
|
|
550
587
|
// Time display - using core formatTimeDisplay
|
|
551
|
-
const timeDisplay = useMemo(
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
+
);
|
|
559
600
|
|
|
560
601
|
const [isVolumeHovered, setIsVolumeHovered] = useState(false);
|
|
561
602
|
const [isVolumeFocused, setIsVolumeFocused] = useState(false);
|
|
562
603
|
const isVolumeExpanded = isVolumeHovered || isVolumeFocused;
|
|
563
604
|
|
|
564
605
|
return (
|
|
565
|
-
<div
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
606
|
+
<div
|
|
607
|
+
className={cn(
|
|
608
|
+
"fw-player-surface fw-controls-wrapper",
|
|
609
|
+
isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
|
|
610
|
+
)}
|
|
611
|
+
>
|
|
569
612
|
{/* Bottom Row: Controls with SeekBar on top */}
|
|
570
|
-
<div
|
|
571
|
-
className="fw-control-bar pointer-events-auto"
|
|
572
|
-
onClick={(e) => e.stopPropagation()}
|
|
573
|
-
>
|
|
613
|
+
<div className="fw-control-bar pointer-events-auto" onClick={(e) => e.stopPropagation()}>
|
|
574
614
|
{/* SeekBar - sits directly on top of control buttons */}
|
|
575
615
|
{canSeek && (
|
|
576
616
|
<div className="fw-seek-wrapper">
|
|
@@ -596,222 +636,270 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|
|
596
636
|
|
|
597
637
|
{/* Control buttons row */}
|
|
598
638
|
<div className="fw-controls-row">
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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>
|
|
616
671
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
>
|
|
640
|
-
{/* Volume icon - part of the pill */}
|
|
641
|
-
<button
|
|
642
|
-
type="button"
|
|
643
|
-
className="fw-volume-btn"
|
|
644
|
-
aria-label={!hasAudio ? "No audio" : (isMuted ? "Unmute" : "Mute")}
|
|
645
|
-
onClick={hasAudio ? handleMute : undefined}
|
|
646
|
-
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
|
+
}}
|
|
647
694
|
>
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
)}>
|
|
655
|
-
<Slider
|
|
656
|
-
orientation="horizontal"
|
|
657
|
-
aria-label="Volume"
|
|
658
|
-
max={100}
|
|
659
|
-
step={1}
|
|
660
|
-
value={[volumeValue]}
|
|
661
|
-
onValueChange={handleVolumeChange}
|
|
662
|
-
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}
|
|
663
701
|
disabled={!hasAudio}
|
|
664
|
-
|
|
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>
|
|
665
729
|
</div>
|
|
666
|
-
</div>
|
|
667
730
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
+
)}
|
|
672
752
|
</div>
|
|
673
753
|
|
|
674
|
-
{
|
|
675
|
-
|
|
754
|
+
{/* Right Group: Settings, Fullscreen */}
|
|
755
|
+
<div className="fw-controls-right">
|
|
756
|
+
<div className="fw-control-group relative">
|
|
676
757
|
<button
|
|
677
758
|
type="button"
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
(!hasDvrWindow || isNearLiveState) ? "fw-live-badge--active" : "fw-live-badge--behind"
|
|
683
|
-
)}
|
|
684
|
-
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)}
|
|
685
763
|
>
|
|
686
|
-
|
|
687
|
-
{!isNearLiveState && hasDvrWindow && <SeekToLiveIcon size={10} />}
|
|
764
|
+
<SettingsIcon size={16} className="transition-transform group-hover:rotate-90" />
|
|
688
765
|
</button>
|
|
689
|
-
</div>
|
|
690
|
-
)}
|
|
691
|
-
</div>
|
|
692
766
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
onClick={() => { onModeChange(mode); setIsSettingsOpen(false); }}
|
|
722
|
-
>
|
|
723
|
-
{mode === 'low-latency' ? 'Fast' : mode === 'quality' ? 'Stable' : 'Auto'}
|
|
724
|
-
</button>
|
|
725
|
-
))}
|
|
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>
|
|
726
795
|
</div>
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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>
|
|
745
817
|
</div>
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
<div className="fw-settings-list">
|
|
752
|
-
<button
|
|
753
|
-
className={cn(
|
|
754
|
-
"fw-settings-list-item",
|
|
755
|
-
qualityValue === 'auto' && "fw-settings-list-item--active"
|
|
756
|
-
)}
|
|
757
|
-
onClick={() => { handleQualityChange('auto'); setIsSettingsOpen(false); }}
|
|
758
|
-
>
|
|
759
|
-
Auto
|
|
760
|
-
</button>
|
|
761
|
-
{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">
|
|
762
823
|
<button
|
|
763
|
-
key={q.id}
|
|
764
824
|
className={cn(
|
|
765
825
|
"fw-settings-list-item",
|
|
766
|
-
qualityValue ===
|
|
826
|
+
qualityValue === "auto" && "fw-settings-list-item--active"
|
|
767
827
|
)}
|
|
768
|
-
onClick={() => {
|
|
828
|
+
onClick={() => {
|
|
829
|
+
handleQualityChange("auto");
|
|
830
|
+
setIsSettingsOpen(false);
|
|
831
|
+
}}
|
|
769
832
|
>
|
|
770
|
-
|
|
833
|
+
Auto
|
|
771
834
|
</button>
|
|
772
|
-
|
|
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>
|
|
773
851
|
</div>
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
<div className="fw-settings-list">
|
|
780
|
-
<button
|
|
781
|
-
className={cn(
|
|
782
|
-
"fw-settings-list-item",
|
|
783
|
-
captionValue === 'none' && "fw-settings-list-item--active"
|
|
784
|
-
)}
|
|
785
|
-
onClick={() => { handleCaptionChange('none'); setIsSettingsOpen(false); }}
|
|
786
|
-
>
|
|
787
|
-
Off
|
|
788
|
-
</button>
|
|
789
|
-
{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">
|
|
790
857
|
<button
|
|
791
|
-
key={t.id}
|
|
792
858
|
className={cn(
|
|
793
859
|
"fw-settings-list-item",
|
|
794
|
-
captionValue ===
|
|
860
|
+
captionValue === "none" && "fw-settings-list-item--active"
|
|
795
861
|
)}
|
|
796
|
-
onClick={() => {
|
|
862
|
+
onClick={() => {
|
|
863
|
+
handleCaptionChange("none");
|
|
864
|
+
setIsSettingsOpen(false);
|
|
865
|
+
}}
|
|
797
866
|
>
|
|
798
|
-
|
|
867
|
+
Off
|
|
799
868
|
</button>
|
|
800
|
-
|
|
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>
|
|
801
885
|
</div>
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
</div>
|
|
886
|
+
)}
|
|
887
|
+
</div>
|
|
888
|
+
)}
|
|
889
|
+
</div>
|
|
807
890
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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>
|
|
812
901
|
</div>
|
|
813
902
|
</div>
|
|
814
|
-
</div>
|
|
815
903
|
</div>
|
|
816
904
|
</div>
|
|
817
905
|
);
|