@livepeer-frameworks/player-react 0.1.0 → 0.1.2

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 (187) hide show
  1. package/README.md +7 -9
  2. package/dist/cjs/_virtual/_rollupPluginBabelHelpers.js +359 -0
  3. package/dist/cjs/_virtual/_rollupPluginBabelHelpers.js.map +1 -0
  4. package/dist/cjs/assets/logomark.svg.js +8 -0
  5. package/dist/cjs/assets/logomark.svg.js.map +1 -0
  6. package/dist/cjs/components/DevModePanel.js +826 -0
  7. package/dist/cjs/components/DevModePanel.js.map +1 -0
  8. package/dist/cjs/components/DvdLogo.js +200 -0
  9. package/dist/cjs/components/DvdLogo.js.map +1 -0
  10. package/dist/cjs/components/Icons.js +439 -0
  11. package/dist/cjs/components/Icons.js.map +1 -0
  12. package/dist/cjs/components/IdleScreen.js +587 -0
  13. package/dist/cjs/components/IdleScreen.js.map +1 -0
  14. package/dist/cjs/components/LoadingScreen.js +523 -0
  15. package/dist/cjs/components/LoadingScreen.js.map +1 -0
  16. package/dist/cjs/components/Player.js +420 -0
  17. package/dist/cjs/components/Player.js.map +1 -0
  18. package/dist/cjs/components/PlayerControls.js +798 -0
  19. package/dist/cjs/components/PlayerControls.js.map +1 -0
  20. package/dist/cjs/components/PlayerErrorBoundary.js +80 -0
  21. package/dist/cjs/components/PlayerErrorBoundary.js.map +1 -0
  22. package/dist/cjs/components/SeekBar.js +253 -0
  23. package/dist/cjs/components/SeekBar.js.map +1 -0
  24. package/dist/cjs/components/SkipIndicator.js +92 -0
  25. package/dist/cjs/components/SkipIndicator.js.map +1 -0
  26. package/dist/cjs/components/SpeedIndicator.js +43 -0
  27. package/dist/cjs/components/SpeedIndicator.js.map +1 -0
  28. package/dist/cjs/components/StatsPanel.js +202 -0
  29. package/dist/cjs/components/StatsPanel.js.map +1 -0
  30. package/dist/cjs/components/StreamStateOverlay.js +229 -0
  31. package/dist/cjs/components/StreamStateOverlay.js.map +1 -0
  32. package/dist/cjs/components/ThumbnailOverlay.js +86 -0
  33. package/dist/cjs/components/ThumbnailOverlay.js.map +1 -0
  34. package/dist/cjs/components/TitleOverlay.js +32 -0
  35. package/dist/cjs/components/TitleOverlay.js.map +1 -0
  36. package/dist/cjs/context/PlayerContext.js +46 -0
  37. package/dist/cjs/context/PlayerContext.js.map +1 -0
  38. package/dist/cjs/hooks/useMetaTrack.js +165 -0
  39. package/dist/cjs/hooks/useMetaTrack.js.map +1 -0
  40. package/dist/cjs/hooks/usePlaybackQuality.js +131 -0
  41. package/dist/cjs/hooks/usePlaybackQuality.js.map +1 -0
  42. package/dist/cjs/hooks/usePlayerController.js +518 -0
  43. package/dist/cjs/hooks/usePlayerController.js.map +1 -0
  44. package/dist/cjs/hooks/usePlayerSelection.js +90 -0
  45. package/dist/cjs/hooks/usePlayerSelection.js.map +1 -0
  46. package/dist/cjs/hooks/useStreamState.js +360 -0
  47. package/dist/cjs/hooks/useStreamState.js.map +1 -0
  48. package/dist/cjs/hooks/useTelemetry.js +120 -0
  49. package/dist/cjs/hooks/useTelemetry.js.map +1 -0
  50. package/dist/cjs/hooks/useViewerEndpoints.js +222 -0
  51. package/dist/cjs/hooks/useViewerEndpoints.js.map +1 -0
  52. package/dist/cjs/index.js +97 -1
  53. package/dist/cjs/index.js.map +1 -1
  54. package/dist/cjs/ui/badge.js +34 -0
  55. package/dist/cjs/ui/badge.js.map +1 -0
  56. package/dist/cjs/ui/button.js +74 -0
  57. package/dist/cjs/ui/button.js.map +1 -0
  58. package/dist/cjs/ui/context-menu.js +163 -0
  59. package/dist/cjs/ui/context-menu.js.map +1 -0
  60. package/dist/cjs/ui/slider.js +60 -0
  61. package/dist/cjs/ui/slider.js.map +1 -0
  62. package/dist/esm/_virtual/_rollupPluginBabelHelpers.js +329 -0
  63. package/dist/esm/_virtual/_rollupPluginBabelHelpers.js.map +1 -0
  64. package/dist/esm/assets/logomark.svg.js +4 -0
  65. package/dist/esm/assets/logomark.svg.js.map +1 -0
  66. package/dist/esm/components/DevModePanel.js +822 -0
  67. package/dist/esm/components/DevModePanel.js.map +1 -0
  68. package/dist/esm/components/DvdLogo.js +196 -0
  69. package/dist/esm/components/DvdLogo.js.map +1 -0
  70. package/dist/esm/components/Icons.js +421 -0
  71. package/dist/esm/components/Icons.js.map +1 -0
  72. package/dist/esm/components/IdleScreen.js +582 -0
  73. package/dist/esm/components/IdleScreen.js.map +1 -0
  74. package/dist/esm/components/LoadingScreen.js +519 -0
  75. package/dist/esm/components/LoadingScreen.js.map +1 -0
  76. package/dist/esm/components/Player.js +416 -0
  77. package/dist/esm/components/Player.js.map +1 -0
  78. package/dist/esm/components/PlayerControls.js +794 -0
  79. package/dist/esm/components/PlayerControls.js.map +1 -0
  80. package/dist/esm/components/PlayerErrorBoundary.js +76 -0
  81. package/dist/esm/components/PlayerErrorBoundary.js.map +1 -0
  82. package/dist/esm/components/SeekBar.js +249 -0
  83. package/dist/esm/components/SeekBar.js.map +1 -0
  84. package/dist/esm/components/SkipIndicator.js +88 -0
  85. package/dist/esm/components/SkipIndicator.js.map +1 -0
  86. package/dist/esm/components/SpeedIndicator.js +39 -0
  87. package/dist/esm/components/SpeedIndicator.js.map +1 -0
  88. package/dist/esm/components/StatsPanel.js +198 -0
  89. package/dist/esm/components/StatsPanel.js.map +1 -0
  90. package/dist/esm/components/StreamStateOverlay.js +224 -0
  91. package/dist/esm/components/StreamStateOverlay.js.map +1 -0
  92. package/dist/esm/components/ThumbnailOverlay.js +82 -0
  93. package/dist/esm/components/ThumbnailOverlay.js.map +1 -0
  94. package/dist/esm/components/TitleOverlay.js +28 -0
  95. package/dist/esm/components/TitleOverlay.js.map +1 -0
  96. package/dist/esm/context/PlayerContext.js +41 -0
  97. package/dist/esm/context/PlayerContext.js.map +1 -0
  98. package/dist/esm/hooks/useMetaTrack.js +163 -0
  99. package/dist/esm/hooks/useMetaTrack.js.map +1 -0
  100. package/dist/esm/hooks/usePlaybackQuality.js +129 -0
  101. package/dist/esm/hooks/usePlaybackQuality.js.map +1 -0
  102. package/dist/esm/hooks/usePlayerController.js +516 -0
  103. package/dist/esm/hooks/usePlayerController.js.map +1 -0
  104. package/dist/esm/hooks/usePlayerSelection.js +88 -0
  105. package/dist/esm/hooks/usePlayerSelection.js.map +1 -0
  106. package/dist/esm/hooks/useStreamState.js +358 -0
  107. package/dist/esm/hooks/useStreamState.js.map +1 -0
  108. package/dist/esm/hooks/useTelemetry.js +118 -0
  109. package/dist/esm/hooks/useTelemetry.js.map +1 -0
  110. package/dist/esm/hooks/useViewerEndpoints.js +220 -0
  111. package/dist/esm/hooks/useViewerEndpoints.js.map +1 -0
  112. package/dist/esm/index.js +23 -1
  113. package/dist/esm/index.js.map +1 -1
  114. package/dist/esm/ui/badge.js +31 -0
  115. package/dist/esm/ui/badge.js.map +1 -0
  116. package/dist/esm/ui/button.js +52 -0
  117. package/dist/esm/ui/button.js.map +1 -0
  118. package/dist/esm/ui/context-menu.js +132 -0
  119. package/dist/esm/ui/context-menu.js.map +1 -0
  120. package/dist/esm/ui/slider.js +38 -0
  121. package/dist/esm/ui/slider.js.map +1 -0
  122. package/dist/types/components/DvdLogo.d.ts +1 -1
  123. package/dist/types/components/Icons.d.ts +1 -1
  124. package/dist/types/components/Player.d.ts +1 -1
  125. package/dist/types/components/PlayerErrorBoundary.d.ts +2 -1
  126. package/dist/types/components/StreamStateOverlay.d.ts +2 -2
  127. package/dist/types/components/SubtitleRenderer.d.ts +2 -2
  128. package/dist/types/context/PlayerContext.d.ts +2 -2
  129. package/dist/types/context/index.d.ts +2 -2
  130. package/dist/types/hooks/useMetaTrack.d.ts +3 -3
  131. package/dist/types/hooks/usePlaybackQuality.d.ts +2 -2
  132. package/dist/types/hooks/usePlayerController.d.ts +26 -3
  133. package/dist/types/hooks/usePlayerSelection.d.ts +1 -1
  134. package/dist/types/hooks/useStreamState.d.ts +1 -1
  135. package/dist/types/hooks/useTelemetry.d.ts +1 -1
  136. package/dist/types/hooks/useViewerEndpoints.d.ts +3 -3
  137. package/dist/types/index.d.ts +28 -28
  138. package/dist/types/types.d.ts +3 -3
  139. package/dist/types/ui/select.d.ts +1 -1
  140. package/package.json +22 -14
  141. package/src/components/DevModePanel.tsx +244 -143
  142. package/src/components/DvdLogo.tsx +1 -1
  143. package/src/components/Icons.tsx +105 -25
  144. package/src/components/IdleScreen.tsx +262 -128
  145. package/src/components/LoadingScreen.tsx +169 -151
  146. package/src/components/LogoOverlay.tsx +3 -6
  147. package/src/components/Player.tsx +126 -59
  148. package/src/components/PlayerControls.tsx +384 -272
  149. package/src/components/PlayerErrorBoundary.tsx +7 -13
  150. package/src/components/SeekBar.tsx +96 -88
  151. package/src/components/SkipIndicator.tsx +2 -12
  152. package/src/components/SpeedIndicator.tsx +2 -11
  153. package/src/components/StatsPanel.tsx +31 -22
  154. package/src/components/StreamStateOverlay.tsx +105 -49
  155. package/src/components/SubtitleRenderer.tsx +29 -29
  156. package/src/components/ThumbnailOverlay.tsx +5 -6
  157. package/src/components/TitleOverlay.tsx +2 -8
  158. package/src/context/PlayerContext.tsx +4 -8
  159. package/src/context/index.ts +3 -3
  160. package/src/hooks/useMetaTrack.ts +27 -27
  161. package/src/hooks/usePlaybackQuality.ts +3 -3
  162. package/src/hooks/usePlayerController.ts +246 -138
  163. package/src/hooks/usePlayerSelection.ts +6 -6
  164. package/src/hooks/useStreamState.ts +51 -56
  165. package/src/hooks/useTelemetry.ts +18 -3
  166. package/src/hooks/useViewerEndpoints.ts +34 -23
  167. package/src/index.tsx +36 -28
  168. package/src/types.ts +8 -8
  169. package/src/ui/badge.tsx +6 -5
  170. package/src/ui/button.tsx +9 -8
  171. package/src/ui/context-menu.tsx +42 -61
  172. package/src/ui/select.tsx +13 -7
  173. package/src/ui/slider.tsx +18 -29
  174. package/dist/types/components/players/DashJsPlayer.d.ts +0 -18
  175. package/dist/types/components/players/HlsJsPlayer.d.ts +0 -18
  176. package/dist/types/components/players/MewsWsPlayer/index.d.ts +0 -18
  177. package/dist/types/components/players/MistPlayer.d.ts +0 -20
  178. package/dist/types/components/players/MistWebRTCPlayer/index.d.ts +0 -20
  179. package/dist/types/components/players/NativePlayer.d.ts +0 -19
  180. package/dist/types/components/players/VideoJsPlayer.d.ts +0 -18
  181. package/src/components/players/DashJsPlayer.tsx +0 -56
  182. package/src/components/players/HlsJsPlayer.tsx +0 -56
  183. package/src/components/players/MewsWsPlayer/index.tsx +0 -56
  184. package/src/components/players/MistPlayer.tsx +0 -60
  185. package/src/components/players/MistWebRTCPlayer/index.tsx +0 -59
  186. package/src/components/players/NativePlayer.tsx +0 -58
  187. package/src/components/players/VideoJsPlayer.tsx +0 -56
@@ -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,16 +86,15 @@ interface PlayerControlsProps {
78
86
  onJumpToLive?: () => void;
79
87
  }
80
88
 
81
-
82
89
  const PlayerControls: React.FC<PlayerControlsProps> = ({
83
90
  currentTime,
84
91
  duration,
85
92
  isVisible = true,
86
- className,
93
+ className: _className,
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);
@@ -469,22 +509,25 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
469
509
  else setInternalVolume(Math.round(v.volume * 100));
470
510
  };
471
511
 
472
- const handleVolumeChange = (value: number[]) => {
473
- if (disabled) return;
474
- const next = Math.max(0, Math.min(100, value[0] ?? 0));
475
- // Prefer prop callback from usePlayerController
476
- if (onVolumeChange) {
477
- onVolumeChange(next / 100);
478
- return;
479
- }
480
- // Fallback: direct video manipulation
481
- const v = video ?? document.querySelector('.fw-player-video') as HTMLVideoElement | null;
482
- if (!v) return;
483
- v.volume = next / 100;
484
- v.muted = next === 0;
485
- setInternalVolume(next);
486
- setInternalIsMuted(next === 0);
487
- };
512
+ const handleVolumeChange = useCallback(
513
+ (value: number[]) => {
514
+ if (disabled) return;
515
+ const next = Math.max(0, Math.min(100, value[0] ?? 0));
516
+ // Prefer prop callback from usePlayerController
517
+ if (onVolumeChange) {
518
+ onVolumeChange(next / 100);
519
+ return;
520
+ }
521
+ // Fallback: direct video manipulation
522
+ const v = video ?? (document.querySelector(".fw-player-video") as HTMLVideoElement | null);
523
+ if (!v) return;
524
+ v.volume = next / 100;
525
+ v.muted = next === 0;
526
+ setInternalVolume(next);
527
+ setInternalIsMuted(next === 0);
528
+ },
529
+ [disabled, onVolumeChange, video]
530
+ );
488
531
 
489
532
  const handleFullscreen = () => {
490
533
  if (disabled) return;
@@ -495,7 +538,9 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
495
538
  }
496
539
  // Fallback: direct DOM manipulation
497
540
  if (typeof document === "undefined") return;
498
- const container = document.querySelector('[data-player-container="true"]') as HTMLElement | null;
541
+ const container = document.querySelector(
542
+ '[data-player-container="true"]'
543
+ ) as HTMLElement | null;
499
544
  if (!container) return;
500
545
  if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
501
546
  else container.requestFullscreen().catch(() => {});
@@ -543,29 +588,47 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
543
588
  };
544
589
 
545
590
  // 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]);
591
+ const timeDisplay = useMemo(
592
+ () =>
593
+ formatTimeDisplay({
594
+ isLive,
595
+ currentTime,
596
+ duration,
597
+ liveEdge,
598
+ seekableStart,
599
+ unixoffset: mistStreamInfo?.unixoffset,
600
+ }),
601
+ [isLive, currentTime, duration, liveEdge, seekableStart, mistStreamInfo?.unixoffset]
602
+ );
554
603
 
555
604
  const [isVolumeHovered, setIsVolumeHovered] = useState(false);
556
605
  const [isVolumeFocused, setIsVolumeFocused] = useState(false);
557
606
  const isVolumeExpanded = isVolumeHovered || isVolumeFocused;
607
+ const volumeGroupRef = useRef<HTMLDivElement>(null);
608
+
609
+ // Non-passive wheel listener for volume control
610
+ useEffect(() => {
611
+ const el = volumeGroupRef.current;
612
+ if (!el) return;
613
+ const handler = (e: WheelEvent) => {
614
+ if (disabled || !hasAudio) return;
615
+ e.preventDefault();
616
+ const delta = e.deltaY < 0 ? 5 : -5;
617
+ handleVolumeChange([actualVolume + delta]);
618
+ };
619
+ el.addEventListener("wheel", handler, { passive: false });
620
+ return () => el.removeEventListener("wheel", handler);
621
+ }, [disabled, hasAudio, actualVolume, handleVolumeChange]);
558
622
 
559
623
  return (
560
- <div className={cn(
561
- "fw-player-surface fw-controls-wrapper",
562
- isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
563
- )}>
624
+ <div
625
+ className={cn(
626
+ "fw-player-surface fw-controls-wrapper",
627
+ isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
628
+ )}
629
+ >
564
630
  {/* Bottom Row: Controls with SeekBar on top */}
565
- <div
566
- className="fw-control-bar pointer-events-auto"
567
- onClick={(e) => e.stopPropagation()}
568
- >
631
+ <div className="fw-control-bar pointer-events-auto" onClick={(e) => e.stopPropagation()}>
569
632
  {/* SeekBar - sits directly on top of control buttons */}
570
633
  {canSeek && (
571
634
  <div className="fw-seek-wrapper">
@@ -591,222 +654,271 @@ const PlayerControls: React.FC<PlayerControlsProps> = ({
591
654
 
592
655
  {/* Control buttons row */}
593
656
  <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>
657
+ {/* Left: Controls & Time */}
658
+ <div className="fw-controls-left">
659
+ <div className="fw-control-group">
660
+ <button
661
+ type="button"
662
+ className="fw-btn-flush"
663
+ aria-label={isPlaying ? "Pause" : "Play"}
664
+ onClick={handlePlayPause}
665
+ >
666
+ <PlayPauseIcon isPlaying={isPlaying} size={18} />
667
+ </button>
668
+ {canSeek && (
669
+ <>
670
+ <button
671
+ type="button"
672
+ className="fw-btn-flush hidden sm:flex"
673
+ aria-label="Skip back 10 seconds"
674
+ onClick={handleSkipBack}
675
+ >
676
+ <SkipBackIcon size={16} />
677
+ </button>
678
+ <button
679
+ type="button"
680
+ className="fw-btn-flush hidden sm:flex"
681
+ aria-label="Skip forward 10 seconds"
682
+ onClick={handleSkipForward}
683
+ >
684
+ <SkipForwardIcon size={16} />
685
+ </button>
686
+ </>
687
+ )}
688
+ </div>
611
689
 
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}
690
+ {/* Volume pill - cohesive hover element (slab style) */}
691
+ <div
692
+ ref={volumeGroupRef}
693
+ className={cn(
694
+ "fw-volume-group",
695
+ isVolumeExpanded && "fw-volume-group--expanded",
696
+ !hasAudio && "fw-volume-group--disabled"
697
+ )}
698
+ onMouseEnter={() => hasAudio && setIsVolumeHovered(true)}
699
+ onMouseLeave={() => {
700
+ setIsVolumeHovered(false);
701
+ setIsVolumeFocused(false);
702
+ }}
703
+ onFocusCapture={() => hasAudio && setIsVolumeFocused(true)}
704
+ onBlurCapture={(e) => {
705
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsVolumeFocused(false);
706
+ }}
707
+ onClick={(e) => {
708
+ // Click on the pill (not slider) toggles mute
709
+ if (hasAudio && e.target === e.currentTarget) {
710
+ handleMute();
711
+ }
712
+ }}
642
713
  >
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"
714
+ {/* Volume icon - part of the pill */}
715
+ <button
716
+ type="button"
717
+ className="fw-volume-btn"
718
+ aria-label={!hasAudio ? "No audio" : isMuted ? "Unmute" : "Mute"}
719
+ onClick={hasAudio ? handleMute : undefined}
658
720
  disabled={!hasAudio}
659
- />
721
+ >
722
+ <VolumeIcon isMuted={isMuted || !hasAudio} size={16} />
723
+ </button>
724
+ {/* Slider - expands within the pill */}
725
+ <div
726
+ className={cn(
727
+ "fw-volume-slider-wrapper",
728
+ isVolumeExpanded
729
+ ? "fw-volume-slider-wrapper--expanded"
730
+ : "fw-volume-slider-wrapper--collapsed"
731
+ )}
732
+ >
733
+ <Slider
734
+ orientation="horizontal"
735
+ aria-label="Volume"
736
+ max={100}
737
+ step={1}
738
+ value={[volumeValue]}
739
+ onValueChange={handleVolumeChange}
740
+ className="w-full"
741
+ disabled={!hasAudio}
742
+ />
743
+ </div>
660
744
  </div>
661
- </div>
662
745
 
663
- <div className="fw-control-group">
664
- <span className="fw-time-display">
665
- {timeDisplay}
666
- </span>
746
+ <div className="fw-control-group">
747
+ <span className="fw-time-display">{timeDisplay}</span>
748
+ </div>
749
+
750
+ {isLive && (
751
+ <div className="fw-control-group">
752
+ <button
753
+ type="button"
754
+ onClick={handleGoLive}
755
+ disabled={!hasDvrWindow || isNearLiveState}
756
+ className={cn(
757
+ "fw-live-badge",
758
+ !hasDvrWindow || isNearLiveState
759
+ ? "fw-live-badge--active"
760
+ : "fw-live-badge--behind"
761
+ )}
762
+ title={
763
+ !hasDvrWindow ? "Live only" : isNearLiveState ? "At live edge" : "Jump to live"
764
+ }
765
+ >
766
+ LIVE
767
+ {!isNearLiveState && hasDvrWindow && <SeekToLiveIcon size={10} />}
768
+ </button>
769
+ </div>
770
+ )}
667
771
  </div>
668
772
 
669
- {isLive && (
670
- <div className="fw-control-group">
773
+ {/* Right Group: Settings, Fullscreen */}
774
+ <div className="fw-controls-right">
775
+ <div className="fw-control-group relative">
671
776
  <button
672
777
  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")}
778
+ className={cn("fw-btn-flush group", isSettingsOpen && "fw-btn-flush--active")}
779
+ aria-label="Settings"
780
+ title="Settings"
781
+ onClick={() => setIsSettingsOpen(!isSettingsOpen)}
680
782
  >
681
- LIVE
682
- {!isNearLiveState && hasDvrWindow && <SeekToLiveIcon size={10} />}
783
+ <SettingsIcon size={16} className="transition-transform group-hover:rotate-90" />
683
784
  </button>
684
- </div>
685
- )}
686
- </div>
687
785
 
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
- ))}
786
+ {/* Settings Popup */}
787
+ {isSettingsOpen && (
788
+ <div className="fw-player-surface fw-settings-menu">
789
+ {/* Playback Mode - only show for live content (not VOD/clips) */}
790
+ {onModeChange && isContentLive !== false && (
791
+ <div className="fw-settings-section">
792
+ <div className="fw-settings-label">Mode</div>
793
+ <div className="fw-settings-options">
794
+ {(["auto", "low-latency", "quality"] as const).map((mode) => (
795
+ <button
796
+ key={mode}
797
+ className={cn(
798
+ "fw-settings-btn",
799
+ playbackMode === mode && "fw-settings-btn--active"
800
+ )}
801
+ onClick={() => {
802
+ onModeChange(mode);
803
+ setIsSettingsOpen(false);
804
+ }}
805
+ >
806
+ {mode === "low-latency"
807
+ ? "Fast"
808
+ : mode === "quality"
809
+ ? "Stable"
810
+ : "Auto"}
811
+ </button>
812
+ ))}
813
+ </div>
721
814
  </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
- ))}
815
+ )}
816
+ {supportsPlaybackRate && (
817
+ <div className="fw-settings-section">
818
+ <div className="fw-settings-label">Speed</div>
819
+ <div className="fw-settings-options fw-settings-options--wrap">
820
+ {SPEED_PRESETS.map((rate) => (
821
+ <button
822
+ key={rate}
823
+ className={cn(
824
+ "fw-settings-btn",
825
+ playbackRate === rate && "fw-settings-btn--active"
826
+ )}
827
+ onClick={() => {
828
+ handleSpeedChange(String(rate));
829
+ setIsSettingsOpen(false);
830
+ }}
831
+ >
832
+ {rate}x
833
+ </button>
834
+ ))}
835
+ </div>
740
836
  </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) => (
837
+ )}
838
+ {qualities.length > 0 && (
839
+ <div className="fw-settings-section">
840
+ <div className="fw-settings-label">Quality</div>
841
+ <div className="fw-settings-list">
757
842
  <button
758
- key={q.id}
759
843
  className={cn(
760
844
  "fw-settings-list-item",
761
- qualityValue === q.id && "fw-settings-list-item--active"
845
+ qualityValue === "auto" && "fw-settings-list-item--active"
762
846
  )}
763
- onClick={() => { handleQualityChange(q.id); setIsSettingsOpen(false); }}
847
+ onClick={() => {
848
+ handleQualityChange("auto");
849
+ setIsSettingsOpen(false);
850
+ }}
764
851
  >
765
- {q.label}
852
+ Auto
766
853
  </button>
767
- ))}
854
+ {qualities.map((q) => (
855
+ <button
856
+ key={q.id}
857
+ className={cn(
858
+ "fw-settings-list-item",
859
+ qualityValue === q.id && "fw-settings-list-item--active"
860
+ )}
861
+ onClick={() => {
862
+ handleQualityChange(q.id);
863
+ setIsSettingsOpen(false);
864
+ }}
865
+ >
866
+ {q.label}
867
+ </button>
868
+ ))}
869
+ </div>
768
870
  </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) => (
871
+ )}
872
+ {textTracks.length > 0 && (
873
+ <div className="fw-settings-section">
874
+ <div className="fw-settings-label">Captions</div>
875
+ <div className="fw-settings-list">
785
876
  <button
786
- key={t.id}
787
877
  className={cn(
788
878
  "fw-settings-list-item",
789
- captionValue === t.id && "fw-settings-list-item--active"
879
+ captionValue === "none" && "fw-settings-list-item--active"
790
880
  )}
791
- onClick={() => { handleCaptionChange(t.id); setIsSettingsOpen(false); }}
881
+ onClick={() => {
882
+ handleCaptionChange("none");
883
+ setIsSettingsOpen(false);
884
+ }}
792
885
  >
793
- {t.label || t.id}
886
+ Off
794
887
  </button>
795
- ))}
888
+ {textTracks.map((t) => (
889
+ <button
890
+ key={t.id}
891
+ className={cn(
892
+ "fw-settings-list-item",
893
+ captionValue === t.id && "fw-settings-list-item--active"
894
+ )}
895
+ onClick={() => {
896
+ handleCaptionChange(t.id);
897
+ setIsSettingsOpen(false);
898
+ }}
899
+ >
900
+ {t.label || t.id}
901
+ </button>
902
+ ))}
903
+ </div>
796
904
  </div>
797
- </div>
798
- )}
799
- </div>
800
- )}
801
- </div>
905
+ )}
906
+ </div>
907
+ )}
908
+ </div>
802
909
 
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>
910
+ <div className="fw-control-group">
911
+ <button
912
+ type="button"
913
+ className="fw-btn-flush"
914
+ aria-label="Toggle fullscreen"
915
+ onClick={handleFullscreen}
916
+ >
917
+ <FullscreenToggleIcon isFullscreen={isFullscreen} size={16} />
918
+ </button>
919
+ </div>
807
920
  </div>
808
921
  </div>
809
- </div>
810
922
  </div>
811
923
  </div>
812
924
  );