@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
@@ -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<{ id: string; label: string; bitrate?: number; width?: number; height?: number; isAuto?: boolean; active?: boolean }>;
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 = 'auto',
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: propIsLoopEnabled,
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 = document.querySelector('.fw-player-video') as HTMLVideoElement | null
129
- ?? document.querySelector('[data-player-container="true"] video') as HTMLVideoElement | null
130
- ?? document.querySelector('.fw-player-container video') as HTMLVideoElement | null;
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 === 'video')
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 volumeValue = propVolume !== undefined ? Math.round(propVolume * 100) : internalVolume;
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('.fw-settings-menu')) {
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('click', handleWindowClick);
247
+ window.addEventListener("click", handleWindowClick);
243
248
  }, 0);
244
249
 
245
250
  return () => {
246
251
  clearTimeout(timeoutId);
247
- window.removeEventListener('click', handleWindowClick);
252
+ window.removeEventListener("click", handleWindowClick);
248
253
  };
249
254
  }, [isSettingsOpen]);
250
255
 
251
256
  // Core utility-based calculations
252
- const deriveBufferWindowMs = useCallback((tracks?: Record<string, { firstms?: number; lastms?: number }>) => {
253
- if (!tracks) return undefined;
254
- const list = Object.values(tracks);
255
- if (list.length === 0) return undefined;
256
- const firstmsValues = list.map(t => t.firstms).filter((v): v is number => v !== undefined);
257
- const lastmsValues = list.map(t => t.lastms).filter((v): v is number => v !== undefined);
258
- if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
259
- const firstms = Math.max(...firstmsValues);
260
- const lastms = Math.min(...lastmsValues);
261
- const window = lastms - firstms;
262
- if (!Number.isFinite(window) || window <= 0) return undefined;
263
- return window;
264
- }, []);
265
-
266
- const bufferWindowMs = mistStreamInfo?.meta?.buffer_window
267
- ?? deriveBufferWindowMs(mistStreamInfo?.meta?.tracks as Record<string, { firstms?: number; lastms?: number }> | undefined);
268
-
269
- const isLive = useMemo(() => isLiveContent(isContentLive, mistStreamInfo, duration),
270
- [isContentLive, mistStreamInfo, duration]);
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 = isMediaStreamSource(video) &&
278
- (bufferWindowMs !== undefined && bufferWindowMs > 0) &&
279
- (sourceType !== 'whep' && sourceType !== 'webrtc');
280
- const { seekableStart: calcSeekableStart, liveEdge: calcLiveEdge } = useMemo(() => calculateSeekableRange({
281
- isLive,
282
- video,
283
- mistStreamInfo,
284
- currentTime,
285
- duration,
286
- allowMediaStreamDvr,
287
- }), [isLive, video, mistStreamInfo, currentTime, duration, allowMediaStreamDvr]);
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 = Number.isFinite(controllerSeekableStart) &&
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 ? (controllerSeekableStart as number) : calcSeekableStart;
317
+ const seekableStart = useControllerRange
318
+ ? (controllerSeekableStart as number)
319
+ : calcSeekableStart;
295
320
  const liveEdge = useControllerRange ? (controllerLiveEdge as number) : calcLiveEdge;
296
321
 
297
- const hasDvrWindow = isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart;
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 === 'function') {
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") document.addEventListener("fullscreenchange", updateFullscreenState);
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") document.removeEventListener("fullscreenchange", updateFullscreenState);
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) { setHasAudio(true); return; }
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('.fw-player-video') as HTMLVideoElement | null;
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('.fw-player-video') as HTMLVideoElement | null;
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('[data-player-container="true"]') as HTMLElement | null;
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(() => formatTimeDisplay({
552
- isLive,
553
- currentTime,
554
- duration,
555
- liveEdge,
556
- seekableStart,
557
- unixoffset: mistStreamInfo?.unixoffset,
558
- }), [isLive, currentTime, duration, liveEdge, seekableStart, mistStreamInfo?.unixoffset]);
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 className={cn(
566
- "fw-player-surface fw-controls-wrapper",
567
- isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
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
- {/* Left: Controls & Time */}
600
- <div className="fw-controls-left">
601
- <div className="fw-control-group">
602
- <button type="button" className="fw-btn-flush" aria-label={isPlaying ? "Pause" : "Play"} onClick={handlePlayPause}>
603
- <PlayPauseIcon isPlaying={isPlaying} size={18} />
604
- </button>
605
- {canSeek && (
606
- <>
607
- <button type="button" className="fw-btn-flush hidden sm:flex" aria-label="Skip back 10 seconds" onClick={handleSkipBack}>
608
- <SkipBackIcon size={16} />
609
- </button>
610
- <button type="button" className="fw-btn-flush hidden sm:flex" aria-label="Skip forward 10 seconds" onClick={handleSkipForward}>
611
- <SkipForwardIcon size={16} />
612
- </button>
613
- </>
614
- )}
615
- </div>
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
- {/* Volume pill - cohesive hover element (slab style) */}
618
- <div
619
- className={cn(
620
- "fw-volume-group",
621
- isVolumeExpanded && "fw-volume-group--expanded",
622
- !hasAudio && "fw-volume-group--disabled"
623
- )}
624
- onMouseEnter={() => hasAudio && setIsVolumeHovered(true)}
625
- onMouseLeave={() => {
626
- setIsVolumeHovered(false);
627
- setIsVolumeFocused(false);
628
- }}
629
- onFocusCapture={() => hasAudio && setIsVolumeFocused(true)}
630
- onBlurCapture={(e) => {
631
- if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsVolumeFocused(false);
632
- }}
633
- onClick={(e) => {
634
- // Click on the pill (not slider) toggles mute
635
- if (hasAudio && e.target === e.currentTarget) {
636
- handleMute();
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
- <VolumeIcon isMuted={isMuted || !hasAudio} size={16} />
649
- </button>
650
- {/* Slider - expands within the pill */}
651
- <div className={cn(
652
- "fw-volume-slider-wrapper",
653
- isVolumeExpanded ? "fw-volume-slider-wrapper--expanded" : "fw-volume-slider-wrapper--collapsed"
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
- <div className="fw-control-group">
669
- <span className="fw-time-display">
670
- {timeDisplay}
671
- </span>
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
- {isLive && (
675
- <div className="fw-control-group">
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
- onClick={handleGoLive}
679
- disabled={!hasDvrWindow || isNearLiveState}
680
- className={cn(
681
- "fw-live-badge",
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
- LIVE
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
- {/* Right Group: Settings, Fullscreen */}
694
- <div className="fw-controls-right">
695
- <div className="fw-control-group relative">
696
- <button
697
- type="button"
698
- className={cn("fw-btn-flush group", isSettingsOpen && "fw-btn-flush--active")}
699
- aria-label="Settings"
700
- title="Settings"
701
- onClick={() => setIsSettingsOpen(!isSettingsOpen)}
702
- >
703
- <SettingsIcon size={16} className="transition-transform group-hover:rotate-90" />
704
- </button>
705
-
706
- {/* Settings Popup */}
707
- {isSettingsOpen && (
708
- <div className="fw-player-surface fw-settings-menu">
709
- {/* Playback Mode - only show for live content (not VOD/clips) */}
710
- {onModeChange && isContentLive !== false && (
711
- <div className="fw-settings-section">
712
- <div className="fw-settings-label">Mode</div>
713
- <div className="fw-settings-options">
714
- {(['auto', 'low-latency', 'quality'] as const).map((mode) => (
715
- <button
716
- key={mode}
717
- className={cn(
718
- "fw-settings-btn",
719
- playbackMode === mode && "fw-settings-btn--active"
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
- </div>
728
- )}
729
- {supportsPlaybackRate && (
730
- <div className="fw-settings-section">
731
- <div className="fw-settings-label">Speed</div>
732
- <div className="fw-settings-options fw-settings-options--wrap">
733
- {SPEED_PRESETS.map((rate) => (
734
- <button
735
- key={rate}
736
- className={cn(
737
- "fw-settings-btn",
738
- playbackRate === rate && "fw-settings-btn--active"
739
- )}
740
- onClick={() => { handleSpeedChange(String(rate)); setIsSettingsOpen(false); }}
741
- >
742
- {rate}x
743
- </button>
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
- </div>
747
- )}
748
- {qualities.length > 0 && (
749
- <div className="fw-settings-section">
750
- <div className="fw-settings-label">Quality</div>
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 === q.id && "fw-settings-list-item--active"
826
+ qualityValue === "auto" && "fw-settings-list-item--active"
767
827
  )}
768
- onClick={() => { handleQualityChange(q.id); setIsSettingsOpen(false); }}
828
+ onClick={() => {
829
+ handleQualityChange("auto");
830
+ setIsSettingsOpen(false);
831
+ }}
769
832
  >
770
- {q.label}
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
- </div>
775
- )}
776
- {textTracks.length > 0 && (
777
- <div className="fw-settings-section">
778
- <div className="fw-settings-label">Captions</div>
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 === t.id && "fw-settings-list-item--active"
860
+ captionValue === "none" && "fw-settings-list-item--active"
795
861
  )}
796
- onClick={() => { handleCaptionChange(t.id); setIsSettingsOpen(false); }}
862
+ onClick={() => {
863
+ handleCaptionChange("none");
864
+ setIsSettingsOpen(false);
865
+ }}
797
866
  >
798
- {t.label || t.id}
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
- </div>
803
- )}
804
- </div>
805
- )}
806
- </div>
886
+ )}
887
+ </div>
888
+ )}
889
+ </div>
807
890
 
808
- <div className="fw-control-group">
809
- <button type="button" className="fw-btn-flush" aria-label="Toggle fullscreen" onClick={handleFullscreen}>
810
- <FullscreenToggleIcon isFullscreen={isFullscreen} size={16} />
811
- </button>
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
  );