@livepeer-frameworks/player-react 0.0.3

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 (88) hide show
  1. package/dist/cjs/index.js +2 -0
  2. package/dist/cjs/index.js.map +1 -0
  3. package/dist/esm/index.js +2 -0
  4. package/dist/esm/index.js.map +1 -0
  5. package/dist/types/components/DevModePanel.d.ts +47 -0
  6. package/dist/types/components/DvdLogo.d.ts +4 -0
  7. package/dist/types/components/Icons.d.ts +33 -0
  8. package/dist/types/components/IdleScreen.d.ts +16 -0
  9. package/dist/types/components/LoadingScreen.d.ts +6 -0
  10. package/dist/types/components/LogoOverlay.d.ts +11 -0
  11. package/dist/types/components/Player.d.ts +11 -0
  12. package/dist/types/components/PlayerControls.d.ts +60 -0
  13. package/dist/types/components/PlayerErrorBoundary.d.ts +23 -0
  14. package/dist/types/components/SeekBar.d.ts +33 -0
  15. package/dist/types/components/SkipIndicator.d.ts +14 -0
  16. package/dist/types/components/SpeedIndicator.d.ts +12 -0
  17. package/dist/types/components/StatsPanel.d.ts +31 -0
  18. package/dist/types/components/StreamStateOverlay.d.ts +24 -0
  19. package/dist/types/components/SubtitleRenderer.d.ts +69 -0
  20. package/dist/types/components/ThumbnailOverlay.d.ts +4 -0
  21. package/dist/types/components/TitleOverlay.d.ts +13 -0
  22. package/dist/types/components/players/DashJsPlayer.d.ts +18 -0
  23. package/dist/types/components/players/HlsJsPlayer.d.ts +18 -0
  24. package/dist/types/components/players/MewsWsPlayer/index.d.ts +18 -0
  25. package/dist/types/components/players/MistPlayer.d.ts +20 -0
  26. package/dist/types/components/players/MistWebRTCPlayer/index.d.ts +20 -0
  27. package/dist/types/components/players/NativePlayer.d.ts +19 -0
  28. package/dist/types/components/players/VideoJsPlayer.d.ts +18 -0
  29. package/dist/types/context/PlayerContext.d.ts +40 -0
  30. package/dist/types/context/index.d.ts +5 -0
  31. package/dist/types/hooks/useMetaTrack.d.ts +54 -0
  32. package/dist/types/hooks/usePlaybackQuality.d.ts +42 -0
  33. package/dist/types/hooks/usePlayerController.d.ts +163 -0
  34. package/dist/types/hooks/usePlayerSelection.d.ts +47 -0
  35. package/dist/types/hooks/useStreamState.d.ts +27 -0
  36. package/dist/types/hooks/useTelemetry.d.ts +57 -0
  37. package/dist/types/hooks/useViewerEndpoints.d.ts +14 -0
  38. package/dist/types/index.d.ts +33 -0
  39. package/dist/types/types.d.ts +94 -0
  40. package/dist/types/ui/badge.d.ts +9 -0
  41. package/dist/types/ui/button.d.ts +11 -0
  42. package/dist/types/ui/context-menu.d.ts +27 -0
  43. package/dist/types/ui/select.d.ts +10 -0
  44. package/dist/types/ui/slider.d.ts +13 -0
  45. package/package.json +71 -0
  46. package/src/assets/logomark.svg +56 -0
  47. package/src/components/DevModePanel.tsx +822 -0
  48. package/src/components/DvdLogo.tsx +201 -0
  49. package/src/components/Icons.tsx +282 -0
  50. package/src/components/IdleScreen.tsx +664 -0
  51. package/src/components/LoadingScreen.tsx +710 -0
  52. package/src/components/LogoOverlay.tsx +75 -0
  53. package/src/components/Player.tsx +419 -0
  54. package/src/components/PlayerControls.tsx +820 -0
  55. package/src/components/PlayerErrorBoundary.tsx +70 -0
  56. package/src/components/SeekBar.tsx +291 -0
  57. package/src/components/SkipIndicator.tsx +113 -0
  58. package/src/components/SpeedIndicator.tsx +57 -0
  59. package/src/components/StatsPanel.tsx +150 -0
  60. package/src/components/StreamStateOverlay.tsx +200 -0
  61. package/src/components/SubtitleRenderer.tsx +235 -0
  62. package/src/components/ThumbnailOverlay.tsx +90 -0
  63. package/src/components/TitleOverlay.tsx +48 -0
  64. package/src/components/players/DashJsPlayer.tsx +56 -0
  65. package/src/components/players/HlsJsPlayer.tsx +56 -0
  66. package/src/components/players/MewsWsPlayer/index.tsx +56 -0
  67. package/src/components/players/MistPlayer.tsx +60 -0
  68. package/src/components/players/MistWebRTCPlayer/index.tsx +59 -0
  69. package/src/components/players/NativePlayer.tsx +58 -0
  70. package/src/components/players/VideoJsPlayer.tsx +56 -0
  71. package/src/context/PlayerContext.tsx +71 -0
  72. package/src/context/index.ts +11 -0
  73. package/src/global.d.ts +4 -0
  74. package/src/hooks/useMetaTrack.ts +187 -0
  75. package/src/hooks/usePlaybackQuality.ts +126 -0
  76. package/src/hooks/usePlayerController.ts +525 -0
  77. package/src/hooks/usePlayerSelection.ts +117 -0
  78. package/src/hooks/useStreamState.ts +381 -0
  79. package/src/hooks/useTelemetry.ts +138 -0
  80. package/src/hooks/useViewerEndpoints.ts +120 -0
  81. package/src/index.tsx +75 -0
  82. package/src/player.css +2 -0
  83. package/src/types.ts +135 -0
  84. package/src/ui/badge.tsx +27 -0
  85. package/src/ui/button.tsx +47 -0
  86. package/src/ui/context-menu.tsx +193 -0
  87. package/src/ui/select.tsx +105 -0
  88. package/src/ui/slider.tsx +67 -0
@@ -0,0 +1,820 @@
1
+ import React, { useEffect, useMemo, useState, useRef, useCallback } from "react";
2
+ import { usePlayerContextOptional } from "../context/PlayerContext";
3
+ import {
4
+ cn,
5
+ // Seeking utilities from core
6
+ SPEED_PRESETS,
7
+ getLatencyTier,
8
+ isMediaStreamSource,
9
+ supportsPlaybackRate as coreSupportsPlaybackRate,
10
+ calculateSeekableRange,
11
+ canSeekStream,
12
+ calculateLiveThresholds,
13
+ calculateIsNearLive,
14
+ isLiveContent,
15
+ // Time formatting from core
16
+ formatTime,
17
+ formatTimeDisplay,
18
+ } from "@livepeer-frameworks/player-core";
19
+ import { Slider } from "../ui/slider";
20
+ import SeekBar from "./SeekBar";
21
+ import {
22
+ ClosedCaptionsIcon,
23
+ FullscreenToggleIcon,
24
+ LiveIcon,
25
+ PlayPauseIcon,
26
+ SeekToLiveIcon,
27
+ SkipBackIcon,
28
+ SkipForwardIcon,
29
+ StatsIcon,
30
+ VolumeIcon,
31
+ SettingsIcon
32
+ } from "./Icons";
33
+ import type { MistStreamInfo, PlaybackMode } from "../types";
34
+
35
+ interface PlayerControlsProps {
36
+ currentTime: number;
37
+ duration: number;
38
+ isVisible?: boolean;
39
+ className?: string;
40
+ onSeek?: (time: number) => void;
41
+ showStatsButton?: boolean;
42
+ isStatsOpen?: boolean;
43
+ onStatsToggle?: () => void;
44
+ /** Live MistServer stream info - drives control visibility based on server metadata */
45
+ mistStreamInfo?: MistStreamInfo;
46
+ /** Disable all controls (e.g., while player is initializing) */
47
+ disabled?: boolean;
48
+ /** Current playback mode */
49
+ playbackMode?: PlaybackMode;
50
+ /** Callback when playback mode changes */
51
+ onModeChange?: (mode: PlaybackMode) => void;
52
+ /** Current source protocol type (e.g., 'whep', 'ws/video/mp4', 'html5/application/vnd.apple.mpegurl') */
53
+ sourceType?: string;
54
+ /** Content-type based live flag (for mode selector visibility, separate from seek bar isLive) */
55
+ isContentLive?: boolean;
56
+ /** Video element - passed from parent hook */
57
+ videoElement?: HTMLVideoElement | null;
58
+ /** Available quality levels - passed from parent hook */
59
+ qualities?: Array<{ id: string; label: string; bitrate?: number; width?: number; height?: number; isAuto?: boolean; active?: boolean }>;
60
+ /** Callback to select quality */
61
+ onSelectQuality?: (id: string) => void;
62
+ /** Is player muted */
63
+ isMuted?: boolean;
64
+ /** Current volume (0-1) */
65
+ volume?: number;
66
+ /** Callback for volume change */
67
+ onVolumeChange?: (volume: number) => void;
68
+ /** Toggle mute callback */
69
+ onToggleMute?: () => void;
70
+ /** Is playing */
71
+ isPlaying?: boolean;
72
+ /** Toggle play/pause callback */
73
+ onTogglePlay?: () => void;
74
+ /** Toggle fullscreen callback */
75
+ onToggleFullscreen?: () => void;
76
+ /** Is fullscreen */
77
+ isFullscreen?: boolean;
78
+ /** Is loop enabled */
79
+ isLoopEnabled?: boolean;
80
+ /** Toggle loop callback */
81
+ onToggleLoop?: () => void;
82
+ /** Jump to live edge callback */
83
+ onJumpToLive?: () => void;
84
+ }
85
+
86
+
87
+ const PlayerControls: React.FC<PlayerControlsProps> = ({
88
+ currentTime,
89
+ duration,
90
+ isVisible = true,
91
+ className,
92
+ onSeek,
93
+ mistStreamInfo,
94
+ disabled = false,
95
+ playbackMode = 'auto',
96
+ onModeChange,
97
+ sourceType,
98
+ isContentLive,
99
+ videoElement: propVideoElement,
100
+ qualities: propQualities = [],
101
+ onSelectQuality,
102
+ isMuted: propIsMuted,
103
+ volume: propVolume,
104
+ onVolumeChange,
105
+ onToggleMute,
106
+ isPlaying: propIsPlaying,
107
+ onTogglePlay,
108
+ onToggleFullscreen,
109
+ isFullscreen: propIsFullscreen,
110
+ isLoopEnabled: propIsLoopEnabled,
111
+ onToggleLoop,
112
+ onJumpToLive,
113
+ }) => {
114
+ // Context fallback - prefer props passed from parent over context
115
+ // Context provides UsePlayerControllerReturn which has state.videoElement and controller
116
+ const ctx = usePlayerContextOptional();
117
+ const contextVideo = ctx?.state?.videoElement;
118
+ const player = ctx?.controller;
119
+
120
+ // Robust video element detection - prefer prop, then context, then DOM query
121
+ const [video, setVideo] = useState<HTMLVideoElement | null>(null);
122
+ const videoCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
123
+
124
+ const findVideoElement = useCallback((): HTMLVideoElement | null => {
125
+ if (propVideoElement) return propVideoElement;
126
+ if (contextVideo) return contextVideo;
127
+ 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;
131
+ return domVideo;
132
+ }, [propVideoElement, contextVideo, player]);
133
+
134
+ useEffect(() => {
135
+ const updateVideo = () => {
136
+ const v = findVideoElement();
137
+ if (v && v !== video) {
138
+ setVideo(v);
139
+ }
140
+ };
141
+ updateVideo();
142
+ if (!video) {
143
+ videoCheckIntervalRef.current = setInterval(() => {
144
+ const v = findVideoElement();
145
+ if (v) {
146
+ setVideo(v);
147
+ if (videoCheckIntervalRef.current) {
148
+ clearInterval(videoCheckIntervalRef.current);
149
+ videoCheckIntervalRef.current = null;
150
+ }
151
+ }
152
+ }, 100);
153
+ setTimeout(() => {
154
+ if (videoCheckIntervalRef.current) {
155
+ clearInterval(videoCheckIntervalRef.current);
156
+ videoCheckIntervalRef.current = null;
157
+ }
158
+ }, 5000);
159
+ }
160
+ return () => {
161
+ if (videoCheckIntervalRef.current) {
162
+ clearInterval(videoCheckIntervalRef.current);
163
+ videoCheckIntervalRef.current = null;
164
+ }
165
+ };
166
+ }, [contextVideo, player, findVideoElement, video]);
167
+
168
+ const mistTracks = mistStreamInfo?.meta?.tracks;
169
+
170
+ // Quality selection priority:
171
+ // 1. Player-provided qualities (HLS.js/DASH.js levels with correct numeric indices)
172
+ // 2. Mist track metadata (for players that don't provide quality API)
173
+ // This fixes a critical bug where Mist track IDs (e.g., "a1", "v0") were passed to
174
+ // HLS/DASH players which expect numeric indices (e.g., "0", "1", "2")
175
+ // Quality levels - prefer props, then player API, then Mist tracks
176
+ const qualities = useMemo(() => {
177
+ // Priority 1: Props from parent (usePlayerController hook)
178
+ if (propQualities && propQualities.length > 0) {
179
+ return propQualities;
180
+ }
181
+
182
+ // Priority 2: Player's quality API
183
+ const playerQualities = player?.getQualities?.();
184
+ if (playerQualities && playerQualities.length > 0) {
185
+ return playerQualities;
186
+ }
187
+
188
+ // Fallback to Mist track metadata for players without quality API
189
+ if (mistTracks) {
190
+ return Object.entries(mistTracks)
191
+ .filter(([, t]) => t.type === 'video')
192
+ .map(([id, t]) => ({
193
+ id,
194
+ label: t.height ? `${t.height}p` : t.codec,
195
+ width: t.width,
196
+ height: t.height,
197
+ bitrate: t.bps,
198
+ }))
199
+ .sort((a, b) => (b.height || 0) - (a.height || 0));
200
+ }
201
+ return [];
202
+ }, [propQualities, player, mistTracks]);
203
+
204
+ const textTracks = player?.getTextTracks?.() ?? [];
205
+
206
+ // Internal state - used as fallback when props not provided
207
+ const [internalIsPlaying, setInternalIsPlaying] = useState(false);
208
+ const [internalIsMuted, setInternalIsMuted] = useState(false);
209
+ const [internalIsFullscreen, setInternalIsFullscreen] = useState(false);
210
+ const [hasAudio, setHasAudio] = useState(true);
211
+ const [buffered, setBuffered] = useState<TimeRanges | undefined>(undefined);
212
+ const [internalVolume, setInternalVolume] = useState<number>(() => {
213
+ if (!video) return 100;
214
+ return Math.round(video.volume * 100);
215
+ });
216
+ const [playbackRate, setPlaybackRate] = useState<number>(() => video?.playbackRate ?? 1);
217
+
218
+ // Derived state - prefer props over internal state
219
+ const isPlaying = propIsPlaying ?? internalIsPlaying;
220
+ const isMuted = propIsMuted ?? internalIsMuted;
221
+ const isFullscreen = propIsFullscreen ?? internalIsFullscreen;
222
+ const volumeValue = propVolume !== undefined ? Math.round(propVolume * 100) : internalVolume;
223
+ const [qualityValue, setQualityValue] = useState<string>("auto");
224
+ const [captionValue, setCaptionValue] = useState<string>("none");
225
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
226
+ // Hysteresis state for Live badge - prevents flip-flopping
227
+ const [isNearLiveState, setIsNearLiveState] = useState(true);
228
+
229
+ // Close settings menu when clicking outside
230
+ useEffect(() => {
231
+ if (!isSettingsOpen) return;
232
+
233
+ const handleWindowClick = (event: MouseEvent) => {
234
+ const target = event.target as HTMLElement;
235
+ if (target && !target.closest('.fw-settings-menu')) {
236
+ setIsSettingsOpen(false);
237
+ }
238
+ };
239
+
240
+ // Use setTimeout to avoid immediate close from the same click that opened it
241
+ const timeoutId = setTimeout(() => {
242
+ window.addEventListener('click', handleWindowClick);
243
+ }, 0);
244
+
245
+ return () => {
246
+ clearTimeout(timeoutId);
247
+ window.removeEventListener('click', handleWindowClick);
248
+ };
249
+ }, [isSettingsOpen]);
250
+
251
+ // 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]);
271
+
272
+ const isWebRTC = useMemo(() => isMediaStreamSource(video), [video]);
273
+
274
+ const supportsPlaybackRate = useMemo(() => coreSupportsPlaybackRate(video), [video]);
275
+
276
+ // 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]);
288
+ const controllerSeekableStart = player?.getSeekableStart?.();
289
+ const controllerLiveEdge = player?.getLiveEdge?.();
290
+ const useControllerRange = Number.isFinite(controllerSeekableStart) &&
291
+ Number.isFinite(controllerLiveEdge) &&
292
+ (controllerLiveEdge as number) >= (controllerSeekableStart as number) &&
293
+ ((controllerLiveEdge as number) > 0 || (controllerSeekableStart as number) > 0);
294
+ const seekableStart = useControllerRange ? (controllerSeekableStart as number) : calcSeekableStart;
295
+ const liveEdge = useControllerRange ? (controllerLiveEdge as number) : calcLiveEdge;
296
+
297
+ const hasDvrWindow = isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart;
298
+ const commitOnRelease = isLive;
299
+
300
+ // Live thresholds with buffer window scaling
301
+ const liveThresholds = useMemo(() =>
302
+ calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs),
303
+ [sourceType, isWebRTC, bufferWindowMs]);
304
+
305
+ // Can seek - prefer PlayerController's computed value (includes player-specific canSeek)
306
+ // Fall back to utility function when controller not available
307
+ const baseCanSeek = useMemo(() => {
308
+ // PlayerController already computes canSeek with player-specific logic
309
+ if (player && typeof (player as any).canSeekStream === 'function') {
310
+ return (player as any).canSeekStream();
311
+ }
312
+ // Fallback when no controller
313
+ return canSeekStream({
314
+ video,
315
+ isLive,
316
+ duration,
317
+ bufferWindowMs,
318
+ });
319
+ }, [video, isLive, duration, bufferWindowMs, player]);
320
+ const canSeek = baseCanSeek && (!isLive || hasDvrWindow);
321
+
322
+ // Hysteresis for live badge - using core calculation
323
+ useEffect(() => {
324
+ if (!isLive) {
325
+ setIsNearLiveState(true);
326
+ return;
327
+ }
328
+ const newState = calculateIsNearLive(currentTime, liveEdge, liveThresholds, isNearLiveState);
329
+ if (newState !== isNearLiveState) {
330
+ setIsNearLiveState(newState);
331
+ }
332
+ }, [isLive, liveEdge, currentTime, liveThresholds, isNearLiveState]);
333
+
334
+ // Track if we've already seeked to live on initial playback
335
+ const hasSeekToLiveRef = useRef(false);
336
+
337
+ // Sync internal state from video element (only when props not provided)
338
+ useEffect(() => {
339
+ if (!video) return;
340
+ const updatePlayingState = () => setInternalIsPlaying(!video.paused);
341
+ const updateMutedState = () => {
342
+ const muted = video.muted || video.volume === 0;
343
+ setInternalIsMuted(muted);
344
+ setInternalVolume(Math.round(video.volume * 100));
345
+ };
346
+ const updateFullscreenState = () => {
347
+ if (typeof document !== "undefined") setInternalIsFullscreen(!!document.fullscreenElement);
348
+ };
349
+ const updatePlaybackRate = () => setPlaybackRate(video.playbackRate);
350
+
351
+ updatePlayingState();
352
+ updateMutedState();
353
+ updateFullscreenState();
354
+ updatePlaybackRate();
355
+
356
+ video.addEventListener("play", updatePlayingState);
357
+ video.addEventListener("pause", updatePlayingState);
358
+ video.addEventListener("playing", updatePlayingState);
359
+ video.addEventListener("volumechange", updateMutedState);
360
+ video.addEventListener("ratechange", updatePlaybackRate);
361
+ if (typeof document !== "undefined") document.addEventListener("fullscreenchange", updateFullscreenState);
362
+
363
+ return () => {
364
+ video.removeEventListener("play", updatePlayingState);
365
+ video.removeEventListener("pause", updatePlayingState);
366
+ video.removeEventListener("playing", updatePlayingState);
367
+ video.removeEventListener("volumechange", updateMutedState);
368
+ video.removeEventListener("ratechange", updatePlaybackRate);
369
+ if (typeof document !== "undefined") document.removeEventListener("fullscreenchange", updateFullscreenState);
370
+ };
371
+ }, [video, isLive]);
372
+
373
+ // Reset the seek-to-live flag when video element changes (new stream)
374
+ useEffect(() => {
375
+ hasSeekToLiveRef.current = false;
376
+ }, [video]);
377
+
378
+ useEffect(() => {
379
+ const activeTrack = textTracks.find((track) => track.active);
380
+ setCaptionValue(activeTrack ? activeTrack.id : "none");
381
+ }, [textTracks]);
382
+
383
+ // Track buffered ranges for SeekBar
384
+ useEffect(() => {
385
+ if (!video) return;
386
+ const updateBuffered = () => {
387
+ const next = player?.getBufferedRanges?.() ?? video.buffered;
388
+ setBuffered(next);
389
+ };
390
+ updateBuffered();
391
+ video.addEventListener("progress", updateBuffered);
392
+ video.addEventListener("loadeddata", updateBuffered);
393
+ return () => {
394
+ video.removeEventListener("progress", updateBuffered);
395
+ video.removeEventListener("loadeddata", updateBuffered);
396
+ };
397
+ }, [video]);
398
+
399
+ useEffect(() => {
400
+ if (!video) { setHasAudio(true); return; }
401
+ const checkAudio = () => {
402
+ if (video.srcObject instanceof MediaStream) {
403
+ const audioTracks = video.srcObject.getAudioTracks();
404
+ setHasAudio(audioTracks.length > 0);
405
+ return;
406
+ }
407
+ const videoAny = video as any;
408
+ if (videoAny.audioTracks && videoAny.audioTracks.length !== undefined) {
409
+ setHasAudio(videoAny.audioTracks.length > 0);
410
+ return;
411
+ }
412
+ setHasAudio(true);
413
+ };
414
+ checkAudio();
415
+ video.addEventListener("loadedmetadata", checkAudio);
416
+ return () => video.removeEventListener("loadedmetadata", checkAudio);
417
+ }, [video]);
418
+
419
+ const handlePlayPause = () => {
420
+ if (disabled) return;
421
+ // Prefer prop callback from usePlayerController
422
+ if (onTogglePlay) {
423
+ onTogglePlay();
424
+ return;
425
+ }
426
+ // Fallback: direct video/player manipulation
427
+ if (!video && !player) return;
428
+ const isPaused = player?.isPaused?.() ?? video?.paused ?? true;
429
+ if (isPaused) {
430
+ if (player?.play) player.play().catch(() => {});
431
+ else if (video) video.play().catch(() => {});
432
+ } else {
433
+ if (player?.pause) player.pause();
434
+ else if (video) video.pause();
435
+ }
436
+ };
437
+
438
+ const handleSkipBack = () => {
439
+ const newTime = Math.max(0, currentTime - 10);
440
+ if (onSeek) {
441
+ onSeek(newTime);
442
+ return;
443
+ }
444
+ const v = findVideoElement();
445
+ if (v) v.currentTime = newTime;
446
+ };
447
+
448
+ const handleSkipForward = () => {
449
+ const maxTime = Number.isFinite(duration) ? duration : currentTime + 10;
450
+ const newTime = Math.min(maxTime, currentTime + 10);
451
+ if (onSeek) {
452
+ onSeek(newTime);
453
+ return;
454
+ }
455
+ const v = findVideoElement();
456
+ if (v) v.currentTime = newTime;
457
+ };
458
+
459
+ const handleMute = () => {
460
+ if (disabled) return;
461
+ // Prefer prop callback from usePlayerController
462
+ if (onToggleMute) {
463
+ onToggleMute();
464
+ return;
465
+ }
466
+ // Fallback: direct video/player manipulation
467
+ const v = video ?? document.querySelector('.fw-player-video') as HTMLVideoElement | null;
468
+ if (!v) return;
469
+ const nextMuted = !(player?.isMuted?.() ?? v.muted);
470
+ player?.setMuted?.(nextMuted);
471
+ v.muted = nextMuted;
472
+ setInternalIsMuted(nextMuted);
473
+ if (nextMuted) setInternalVolume(0);
474
+ else setInternalVolume(Math.round(v.volume * 100));
475
+ };
476
+
477
+ const handleVolumeChange = (value: number[]) => {
478
+ if (disabled) return;
479
+ const next = Math.max(0, Math.min(100, value[0] ?? 0));
480
+ // Prefer prop callback from usePlayerController
481
+ if (onVolumeChange) {
482
+ onVolumeChange(next / 100);
483
+ return;
484
+ }
485
+ // Fallback: direct video manipulation
486
+ const v = video ?? document.querySelector('.fw-player-video') as HTMLVideoElement | null;
487
+ if (!v) return;
488
+ v.volume = next / 100;
489
+ v.muted = next === 0;
490
+ setInternalVolume(next);
491
+ setInternalIsMuted(next === 0);
492
+ };
493
+
494
+ const handleFullscreen = () => {
495
+ if (disabled) return;
496
+ // Prefer prop callback from usePlayerController
497
+ if (onToggleFullscreen) {
498
+ onToggleFullscreen();
499
+ return;
500
+ }
501
+ // Fallback: direct DOM manipulation
502
+ if (typeof document === "undefined") return;
503
+ const container = document.querySelector('[data-player-container="true"]') as HTMLElement | null;
504
+ if (!container) return;
505
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
506
+ else container.requestFullscreen().catch(() => {});
507
+ };
508
+
509
+ const handleGoLive = () => {
510
+ if (disabled) return;
511
+ if (onJumpToLive) {
512
+ onJumpToLive();
513
+ return;
514
+ }
515
+ player?.jumpToLive?.();
516
+ };
517
+
518
+ const handleSpeedChange = (value: string) => {
519
+ if (disabled) return;
520
+ const rate = Number(value);
521
+ setPlaybackRate(rate);
522
+ // Use player API if available, fall back to direct video element
523
+ if (player?.setPlaybackRate) {
524
+ player.setPlaybackRate(rate);
525
+ } else {
526
+ const v = findVideoElement();
527
+ if (v) v.playbackRate = rate;
528
+ }
529
+ };
530
+
531
+ const handleQualityChange = (value: string) => {
532
+ if (disabled) return;
533
+ setQualityValue(value);
534
+ // Prefer prop callback from usePlayerController
535
+ if (onSelectQuality) {
536
+ onSelectQuality(value);
537
+ return;
538
+ }
539
+ // Fallback: direct player manipulation
540
+ player?.selectQuality?.(value);
541
+ };
542
+
543
+ const handleCaptionChange = (value: string) => {
544
+ if (disabled) return;
545
+ setCaptionValue(value);
546
+ if (value === "none") player?.selectTextTrack?.(null);
547
+ else player?.selectTextTrack?.(value);
548
+ };
549
+
550
+ // 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]);
559
+
560
+ const [isVolumeHovered, setIsVolumeHovered] = useState(false);
561
+ const [isVolumeFocused, setIsVolumeFocused] = useState(false);
562
+ const isVolumeExpanded = isVolumeHovered || isVolumeFocused;
563
+
564
+ return (
565
+ <div className={cn(
566
+ "fw-player-surface fw-controls-wrapper",
567
+ isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
568
+ )}>
569
+ {/* Bottom Row: Controls with SeekBar on top */}
570
+ <div
571
+ className="fw-control-bar pointer-events-auto"
572
+ onClick={(e) => e.stopPropagation()}
573
+ >
574
+ {/* SeekBar - sits directly on top of control buttons */}
575
+ {canSeek && (
576
+ <div className="fw-seek-wrapper">
577
+ <SeekBar
578
+ currentTime={currentTime}
579
+ duration={duration}
580
+ buffered={buffered}
581
+ disabled={disabled}
582
+ isLive={isLive}
583
+ seekableStart={seekableStart}
584
+ liveEdge={liveEdge}
585
+ commitOnRelease={commitOnRelease}
586
+ onSeek={(time) => {
587
+ if (onSeek) {
588
+ onSeek(time);
589
+ } else if (video) {
590
+ video.currentTime = time;
591
+ }
592
+ }}
593
+ />
594
+ </div>
595
+ )}
596
+
597
+ {/* Control buttons row */}
598
+ <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>
616
+
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}
647
+ >
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"
663
+ disabled={!hasAudio}
664
+ />
665
+ </div>
666
+ </div>
667
+
668
+ <div className="fw-control-group">
669
+ <span className="fw-time-display">
670
+ {timeDisplay}
671
+ </span>
672
+ </div>
673
+
674
+ {isLive && (
675
+ <div className="fw-control-group">
676
+ <button
677
+ 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")}
685
+ >
686
+ LIVE
687
+ {!isNearLiveState && hasDvrWindow && <SeekToLiveIcon size={10} />}
688
+ </button>
689
+ </div>
690
+ )}
691
+ </div>
692
+
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
+ ))}
726
+ </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
+ ))}
745
+ </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) => (
762
+ <button
763
+ key={q.id}
764
+ className={cn(
765
+ "fw-settings-list-item",
766
+ qualityValue === q.id && "fw-settings-list-item--active"
767
+ )}
768
+ onClick={() => { handleQualityChange(q.id); setIsSettingsOpen(false); }}
769
+ >
770
+ {q.label}
771
+ </button>
772
+ ))}
773
+ </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) => (
790
+ <button
791
+ key={t.id}
792
+ className={cn(
793
+ "fw-settings-list-item",
794
+ captionValue === t.id && "fw-settings-list-item--active"
795
+ )}
796
+ onClick={() => { handleCaptionChange(t.id); setIsSettingsOpen(false); }}
797
+ >
798
+ {t.label || t.id}
799
+ </button>
800
+ ))}
801
+ </div>
802
+ </div>
803
+ )}
804
+ </div>
805
+ )}
806
+ </div>
807
+
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>
812
+ </div>
813
+ </div>
814
+ </div>
815
+ </div>
816
+ </div>
817
+ );
818
+ };
819
+
820
+ export default PlayerControls;