@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.
Files changed (41) hide show
  1. package/README.md +7 -9
  2. package/package.json +1 -1
  3. package/src/components/DevModePanel.tsx +244 -143
  4. package/src/components/Icons.tsx +105 -25
  5. package/src/components/IdleScreen.tsx +262 -128
  6. package/src/components/LoadingScreen.tsx +169 -151
  7. package/src/components/LogoOverlay.tsx +3 -6
  8. package/src/components/Player.tsx +84 -56
  9. package/src/components/PlayerControls.tsx +349 -256
  10. package/src/components/PlayerErrorBoundary.tsx +6 -13
  11. package/src/components/SeekBar.tsx +96 -88
  12. package/src/components/SkipIndicator.tsx +2 -12
  13. package/src/components/SpeedIndicator.tsx +2 -11
  14. package/src/components/StatsPanel.tsx +31 -22
  15. package/src/components/StreamStateOverlay.tsx +105 -49
  16. package/src/components/SubtitleRenderer.tsx +29 -29
  17. package/src/components/ThumbnailOverlay.tsx +5 -6
  18. package/src/components/TitleOverlay.tsx +2 -8
  19. package/src/components/players/DashJsPlayer.tsx +13 -11
  20. package/src/components/players/HlsJsPlayer.tsx +13 -11
  21. package/src/components/players/MewsWsPlayer/index.tsx +13 -11
  22. package/src/components/players/MistPlayer.tsx +13 -11
  23. package/src/components/players/MistWebRTCPlayer/index.tsx +19 -10
  24. package/src/components/players/NativePlayer.tsx +10 -12
  25. package/src/components/players/VideoJsPlayer.tsx +13 -11
  26. package/src/context/PlayerContext.tsx +4 -8
  27. package/src/context/index.ts +3 -3
  28. package/src/hooks/useMetaTrack.ts +27 -27
  29. package/src/hooks/usePlaybackQuality.ts +3 -3
  30. package/src/hooks/usePlayerController.ts +186 -138
  31. package/src/hooks/usePlayerSelection.ts +6 -6
  32. package/src/hooks/useStreamState.ts +51 -56
  33. package/src/hooks/useTelemetry.ts +18 -3
  34. package/src/hooks/useViewerEndpoints.ts +34 -23
  35. package/src/index.tsx +36 -28
  36. package/src/types.ts +8 -8
  37. package/src/ui/badge.tsx +6 -5
  38. package/src/ui/button.tsx +9 -8
  39. package/src/ui/context-menu.tsx +42 -61
  40. package/src/ui/select.tsx +13 -7
  41. 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<{ 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
+ }>;
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 = 'auto',
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 = document.querySelector('.fw-player-video') as HTMLVideoElement | null
124
- ?? document.querySelector('[data-player-container="true"] video') as HTMLVideoElement | null
125
- ?? 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);
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 === 'video')
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 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;
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('.fw-settings-menu')) {
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('click', handleWindowClick);
247
+ window.addEventListener("click", handleWindowClick);
238
248
  }, 0);
239
249
 
240
250
  return () => {
241
251
  clearTimeout(timeoutId);
242
- window.removeEventListener('click', handleWindowClick);
252
+ window.removeEventListener("click", handleWindowClick);
243
253
  };
244
254
  }, [isSettingsOpen]);
245
255
 
246
256
  // Core utility-based calculations
247
- const deriveBufferWindowMs = useCallback((tracks?: Record<string, { firstms?: number; lastms?: number }>) => {
248
- if (!tracks) return undefined;
249
- const list = Object.values(tracks);
250
- if (list.length === 0) return undefined;
251
- const firstmsValues = list.map(t => t.firstms).filter((v): v is number => v !== undefined);
252
- const lastmsValues = list.map(t => t.lastms).filter((v): v is number => v !== undefined);
253
- if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
254
- const firstms = Math.max(...firstmsValues);
255
- const lastms = Math.min(...lastmsValues);
256
- const window = lastms - firstms;
257
- if (!Number.isFinite(window) || window <= 0) return undefined;
258
- return window;
259
- }, []);
260
-
261
- const bufferWindowMs = mistStreamInfo?.meta?.buffer_window
262
- ?? deriveBufferWindowMs(mistStreamInfo?.meta?.tracks as Record<string, { firstms?: number; lastms?: number }> | undefined);
263
-
264
- const isLive = useMemo(() => isLiveContent(isContentLive, mistStreamInfo, duration),
265
- [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
+ );
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 = isMediaStreamSource(video) &&
273
- (bufferWindowMs !== undefined && bufferWindowMs > 0) &&
274
- (sourceType !== 'whep' && sourceType !== 'webrtc');
275
- const { seekableStart: calcSeekableStart, liveEdge: calcLiveEdge } = useMemo(() => calculateSeekableRange({
276
- isLive,
277
- video,
278
- mistStreamInfo,
279
- currentTime,
280
- duration,
281
- allowMediaStreamDvr,
282
- }), [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
+ );
283
310
  const controllerSeekableStart = player?.getSeekableStart?.();
284
311
  const controllerLiveEdge = player?.getLiveEdge?.();
285
- const useControllerRange = Number.isFinite(controllerSeekableStart) &&
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 ? (controllerSeekableStart as number) : calcSeekableStart;
317
+ const seekableStart = useControllerRange
318
+ ? (controllerSeekableStart as number)
319
+ : calcSeekableStart;
290
320
  const liveEdge = useControllerRange ? (controllerLiveEdge as number) : calcLiveEdge;
291
321
 
292
- 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;
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 === 'function') {
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") document.addEventListener("fullscreenchange", updateFullscreenState);
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") document.removeEventListener("fullscreenchange", updateFullscreenState);
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) { setHasAudio(true); return; }
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('.fw-player-video') as HTMLVideoElement | null;
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('.fw-player-video') as HTMLVideoElement | null;
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('[data-player-container="true"]') as HTMLElement | null;
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(() => formatTimeDisplay({
547
- isLive,
548
- currentTime,
549
- duration,
550
- liveEdge,
551
- seekableStart,
552
- unixoffset: mistStreamInfo?.unixoffset,
553
- }), [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
+ );
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 className={cn(
561
- "fw-player-surface fw-controls-wrapper",
562
- isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
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
- {/* Left: Controls & Time */}
595
- <div className="fw-controls-left">
596
- <div className="fw-control-group">
597
- <button type="button" className="fw-btn-flush" aria-label={isPlaying ? "Pause" : "Play"} onClick={handlePlayPause}>
598
- <PlayPauseIcon isPlaying={isPlaying} size={18} />
599
- </button>
600
- {canSeek && (
601
- <>
602
- <button type="button" className="fw-btn-flush hidden sm:flex" aria-label="Skip back 10 seconds" onClick={handleSkipBack}>
603
- <SkipBackIcon size={16} />
604
- </button>
605
- <button type="button" className="fw-btn-flush hidden sm:flex" aria-label="Skip forward 10 seconds" onClick={handleSkipForward}>
606
- <SkipForwardIcon size={16} />
607
- </button>
608
- </>
609
- )}
610
- </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>
611
671
 
612
- {/* Volume pill - cohesive hover element (slab style) */}
613
- <div
614
- className={cn(
615
- "fw-volume-group",
616
- isVolumeExpanded && "fw-volume-group--expanded",
617
- !hasAudio && "fw-volume-group--disabled"
618
- )}
619
- onMouseEnter={() => hasAudio && setIsVolumeHovered(true)}
620
- onMouseLeave={() => {
621
- setIsVolumeHovered(false);
622
- setIsVolumeFocused(false);
623
- }}
624
- onFocusCapture={() => hasAudio && setIsVolumeFocused(true)}
625
- onBlurCapture={(e) => {
626
- if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsVolumeFocused(false);
627
- }}
628
- onClick={(e) => {
629
- // Click on the pill (not slider) toggles mute
630
- if (hasAudio && e.target === e.currentTarget) {
631
- handleMute();
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
- <VolumeIcon isMuted={isMuted || !hasAudio} size={16} />
644
- </button>
645
- {/* Slider - expands within the pill */}
646
- <div className={cn(
647
- "fw-volume-slider-wrapper",
648
- isVolumeExpanded ? "fw-volume-slider-wrapper--expanded" : "fw-volume-slider-wrapper--collapsed"
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
- <div className="fw-control-group">
664
- <span className="fw-time-display">
665
- {timeDisplay}
666
- </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
+ )}
667
752
  </div>
668
753
 
669
- {isLive && (
670
- <div className="fw-control-group">
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
- onClick={handleGoLive}
674
- disabled={!hasDvrWindow || isNearLiveState}
675
- className={cn(
676
- "fw-live-badge",
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
- LIVE
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
- {/* Right Group: Settings, Fullscreen */}
689
- <div className="fw-controls-right">
690
- <div className="fw-control-group relative">
691
- <button
692
- type="button"
693
- className={cn("fw-btn-flush group", isSettingsOpen && "fw-btn-flush--active")}
694
- aria-label="Settings"
695
- title="Settings"
696
- onClick={() => setIsSettingsOpen(!isSettingsOpen)}
697
- >
698
- <SettingsIcon size={16} className="transition-transform group-hover:rotate-90" />
699
- </button>
700
-
701
- {/* Settings Popup */}
702
- {isSettingsOpen && (
703
- <div className="fw-player-surface fw-settings-menu">
704
- {/* Playback Mode - only show for live content (not VOD/clips) */}
705
- {onModeChange && isContentLive !== false && (
706
- <div className="fw-settings-section">
707
- <div className="fw-settings-label">Mode</div>
708
- <div className="fw-settings-options">
709
- {(['auto', 'low-latency', 'quality'] as const).map((mode) => (
710
- <button
711
- key={mode}
712
- className={cn(
713
- "fw-settings-btn",
714
- playbackMode === mode && "fw-settings-btn--active"
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
- </div>
723
- )}
724
- {supportsPlaybackRate && (
725
- <div className="fw-settings-section">
726
- <div className="fw-settings-label">Speed</div>
727
- <div className="fw-settings-options fw-settings-options--wrap">
728
- {SPEED_PRESETS.map((rate) => (
729
- <button
730
- key={rate}
731
- className={cn(
732
- "fw-settings-btn",
733
- playbackRate === rate && "fw-settings-btn--active"
734
- )}
735
- onClick={() => { handleSpeedChange(String(rate)); setIsSettingsOpen(false); }}
736
- >
737
- {rate}x
738
- </button>
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
- </div>
742
- )}
743
- {qualities.length > 0 && (
744
- <div className="fw-settings-section">
745
- <div className="fw-settings-label">Quality</div>
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 === q.id && "fw-settings-list-item--active"
826
+ qualityValue === "auto" && "fw-settings-list-item--active"
762
827
  )}
763
- onClick={() => { handleQualityChange(q.id); setIsSettingsOpen(false); }}
828
+ onClick={() => {
829
+ handleQualityChange("auto");
830
+ setIsSettingsOpen(false);
831
+ }}
764
832
  >
765
- {q.label}
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
- </div>
770
- )}
771
- {textTracks.length > 0 && (
772
- <div className="fw-settings-section">
773
- <div className="fw-settings-label">Captions</div>
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 === t.id && "fw-settings-list-item--active"
860
+ captionValue === "none" && "fw-settings-list-item--active"
790
861
  )}
791
- onClick={() => { handleCaptionChange(t.id); setIsSettingsOpen(false); }}
862
+ onClick={() => {
863
+ handleCaptionChange("none");
864
+ setIsSettingsOpen(false);
865
+ }}
792
866
  >
793
- {t.label || t.id}
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
- </div>
798
- )}
799
- </div>
800
- )}
801
- </div>
886
+ )}
887
+ </div>
888
+ )}
889
+ </div>
802
890
 
803
- <div className="fw-control-group">
804
- <button type="button" className="fw-btn-flush" aria-label="Toggle fullscreen" onClick={handleFullscreen}>
805
- <FullscreenToggleIcon isFullscreen={isFullscreen} size={16} />
806
- </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>
807
901
  </div>
808
902
  </div>
809
- </div>
810
903
  </div>
811
904
  </div>
812
905
  );