@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.
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types/components/DevModePanel.d.ts +47 -0
- package/dist/types/components/DvdLogo.d.ts +4 -0
- package/dist/types/components/Icons.d.ts +33 -0
- package/dist/types/components/IdleScreen.d.ts +16 -0
- package/dist/types/components/LoadingScreen.d.ts +6 -0
- package/dist/types/components/LogoOverlay.d.ts +11 -0
- package/dist/types/components/Player.d.ts +11 -0
- package/dist/types/components/PlayerControls.d.ts +60 -0
- package/dist/types/components/PlayerErrorBoundary.d.ts +23 -0
- package/dist/types/components/SeekBar.d.ts +33 -0
- package/dist/types/components/SkipIndicator.d.ts +14 -0
- package/dist/types/components/SpeedIndicator.d.ts +12 -0
- package/dist/types/components/StatsPanel.d.ts +31 -0
- package/dist/types/components/StreamStateOverlay.d.ts +24 -0
- package/dist/types/components/SubtitleRenderer.d.ts +69 -0
- package/dist/types/components/ThumbnailOverlay.d.ts +4 -0
- package/dist/types/components/TitleOverlay.d.ts +13 -0
- package/dist/types/components/players/DashJsPlayer.d.ts +18 -0
- package/dist/types/components/players/HlsJsPlayer.d.ts +18 -0
- package/dist/types/components/players/MewsWsPlayer/index.d.ts +18 -0
- package/dist/types/components/players/MistPlayer.d.ts +20 -0
- package/dist/types/components/players/MistWebRTCPlayer/index.d.ts +20 -0
- package/dist/types/components/players/NativePlayer.d.ts +19 -0
- package/dist/types/components/players/VideoJsPlayer.d.ts +18 -0
- package/dist/types/context/PlayerContext.d.ts +40 -0
- package/dist/types/context/index.d.ts +5 -0
- package/dist/types/hooks/useMetaTrack.d.ts +54 -0
- package/dist/types/hooks/usePlaybackQuality.d.ts +42 -0
- package/dist/types/hooks/usePlayerController.d.ts +163 -0
- package/dist/types/hooks/usePlayerSelection.d.ts +47 -0
- package/dist/types/hooks/useStreamState.d.ts +27 -0
- package/dist/types/hooks/useTelemetry.d.ts +57 -0
- package/dist/types/hooks/useViewerEndpoints.d.ts +14 -0
- package/dist/types/index.d.ts +33 -0
- package/dist/types/types.d.ts +94 -0
- package/dist/types/ui/badge.d.ts +9 -0
- package/dist/types/ui/button.d.ts +11 -0
- package/dist/types/ui/context-menu.d.ts +27 -0
- package/dist/types/ui/select.d.ts +10 -0
- package/dist/types/ui/slider.d.ts +13 -0
- package/package.json +71 -0
- package/src/assets/logomark.svg +56 -0
- package/src/components/DevModePanel.tsx +822 -0
- package/src/components/DvdLogo.tsx +201 -0
- package/src/components/Icons.tsx +282 -0
- package/src/components/IdleScreen.tsx +664 -0
- package/src/components/LoadingScreen.tsx +710 -0
- package/src/components/LogoOverlay.tsx +75 -0
- package/src/components/Player.tsx +419 -0
- package/src/components/PlayerControls.tsx +820 -0
- package/src/components/PlayerErrorBoundary.tsx +70 -0
- package/src/components/SeekBar.tsx +291 -0
- package/src/components/SkipIndicator.tsx +113 -0
- package/src/components/SpeedIndicator.tsx +57 -0
- package/src/components/StatsPanel.tsx +150 -0
- package/src/components/StreamStateOverlay.tsx +200 -0
- package/src/components/SubtitleRenderer.tsx +235 -0
- package/src/components/ThumbnailOverlay.tsx +90 -0
- package/src/components/TitleOverlay.tsx +48 -0
- package/src/components/players/DashJsPlayer.tsx +56 -0
- package/src/components/players/HlsJsPlayer.tsx +56 -0
- package/src/components/players/MewsWsPlayer/index.tsx +56 -0
- package/src/components/players/MistPlayer.tsx +60 -0
- package/src/components/players/MistWebRTCPlayer/index.tsx +59 -0
- package/src/components/players/NativePlayer.tsx +58 -0
- package/src/components/players/VideoJsPlayer.tsx +56 -0
- package/src/context/PlayerContext.tsx +71 -0
- package/src/context/index.ts +11 -0
- package/src/global.d.ts +4 -0
- package/src/hooks/useMetaTrack.ts +187 -0
- package/src/hooks/usePlaybackQuality.ts +126 -0
- package/src/hooks/usePlayerController.ts +525 -0
- package/src/hooks/usePlayerSelection.ts +117 -0
- package/src/hooks/useStreamState.ts +381 -0
- package/src/hooks/useTelemetry.ts +138 -0
- package/src/hooks/useViewerEndpoints.ts +120 -0
- package/src/index.tsx +75 -0
- package/src/player.css +2 -0
- package/src/types.ts +135 -0
- package/src/ui/badge.tsx +27 -0
- package/src/ui/button.tsx +47 -0
- package/src/ui/context-menu.tsx +193 -0
- package/src/ui/select.tsx +105 -0
- 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;
|