@livepeer-frameworks/player-svelte 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 (169) hide show
  1. package/dist/DevModePanel.svelte +650 -0
  2. package/dist/DevModePanel.svelte.d.ts +31 -0
  3. package/dist/DvdLogo.svelte +213 -0
  4. package/dist/DvdLogo.svelte.d.ts +7 -0
  5. package/dist/Icons.svelte +27 -0
  6. package/dist/Icons.svelte.d.ts +25 -0
  7. package/dist/IdleScreen.svelte +752 -0
  8. package/dist/IdleScreen.svelte.d.ts +11 -0
  9. package/dist/LoadingScreen.svelte +689 -0
  10. package/dist/LoadingScreen.svelte.d.ts +7 -0
  11. package/dist/Player.svelte +482 -0
  12. package/dist/Player.svelte.d.ts +26 -0
  13. package/dist/PlayerControls.svelte +739 -0
  14. package/dist/PlayerControls.svelte.d.ts +20 -0
  15. package/dist/SeekBar.svelte +274 -0
  16. package/dist/SeekBar.svelte.d.ts +25 -0
  17. package/dist/SkipIndicator.svelte +95 -0
  18. package/dist/SkipIndicator.svelte.d.ts +14 -0
  19. package/dist/SpeedIndicator.svelte +38 -0
  20. package/dist/SpeedIndicator.svelte.d.ts +8 -0
  21. package/dist/StatsPanel.svelte +155 -0
  22. package/dist/StatsPanel.svelte.d.ts +27 -0
  23. package/dist/StreamStateOverlay.svelte +266 -0
  24. package/dist/StreamStateOverlay.svelte.d.ts +18 -0
  25. package/dist/SubtitleRenderer.svelte +234 -0
  26. package/dist/SubtitleRenderer.svelte.d.ts +41 -0
  27. package/dist/ThumbnailOverlay.svelte +96 -0
  28. package/dist/ThumbnailOverlay.svelte.d.ts +11 -0
  29. package/dist/TitleOverlay.svelte +47 -0
  30. package/dist/TitleOverlay.svelte.d.ts +9 -0
  31. package/dist/assets/logomark.svg +56 -0
  32. package/dist/components/VolumeIcons.svelte +53 -0
  33. package/dist/components/VolumeIcons.svelte.d.ts +10 -0
  34. package/dist/global.d.ts +15 -0
  35. package/dist/icons/FullscreenExitIcon.svelte +33 -0
  36. package/dist/icons/FullscreenExitIcon.svelte.d.ts +8 -0
  37. package/dist/icons/FullscreenIcon.svelte +33 -0
  38. package/dist/icons/FullscreenIcon.svelte.d.ts +8 -0
  39. package/dist/icons/PauseIcon.svelte +28 -0
  40. package/dist/icons/PauseIcon.svelte.d.ts +8 -0
  41. package/dist/icons/PictureInPictureIcon.svelte +28 -0
  42. package/dist/icons/PictureInPictureIcon.svelte.d.ts +8 -0
  43. package/dist/icons/PlayIcon.svelte +27 -0
  44. package/dist/icons/PlayIcon.svelte.d.ts +8 -0
  45. package/dist/icons/SeekToLiveIcon.svelte +30 -0
  46. package/dist/icons/SeekToLiveIcon.svelte.d.ts +8 -0
  47. package/dist/icons/SettingsIcon.svelte +40 -0
  48. package/dist/icons/SettingsIcon.svelte.d.ts +8 -0
  49. package/dist/icons/SkipBackIcon.svelte +32 -0
  50. package/dist/icons/SkipBackIcon.svelte.d.ts +8 -0
  51. package/dist/icons/SkipForwardIcon.svelte +32 -0
  52. package/dist/icons/SkipForwardIcon.svelte.d.ts +8 -0
  53. package/dist/icons/StatsIcon.svelte +29 -0
  54. package/dist/icons/StatsIcon.svelte.d.ts +8 -0
  55. package/dist/icons/VolumeOffIcon.svelte +29 -0
  56. package/dist/icons/VolumeOffIcon.svelte.d.ts +8 -0
  57. package/dist/icons/VolumeUpIcon.svelte +34 -0
  58. package/dist/icons/VolumeUpIcon.svelte.d.ts +8 -0
  59. package/dist/icons/index.d.ts +17 -0
  60. package/dist/icons/index.js +17 -0
  61. package/dist/index.d.ts +50 -0
  62. package/dist/index.js +54 -0
  63. package/dist/player.css +2 -0
  64. package/dist/stores/index.d.ts +15 -0
  65. package/dist/stores/index.js +21 -0
  66. package/dist/stores/playbackQuality.d.ts +43 -0
  67. package/dist/stores/playbackQuality.js +107 -0
  68. package/dist/stores/playerContext.d.ts +73 -0
  69. package/dist/stores/playerContext.js +166 -0
  70. package/dist/stores/playerController.d.ts +178 -0
  71. package/dist/stores/playerController.js +358 -0
  72. package/dist/stores/playerSelection.d.ts +84 -0
  73. package/dist/stores/playerSelection.js +159 -0
  74. package/dist/stores/streamState.d.ts +44 -0
  75. package/dist/stores/streamState.js +314 -0
  76. package/dist/stores/viewerEndpoints.d.ts +48 -0
  77. package/dist/stores/viewerEndpoints.js +178 -0
  78. package/dist/types.d.ts +4 -0
  79. package/dist/types.js +4 -0
  80. package/dist/ui/Badge.svelte +21 -0
  81. package/dist/ui/Badge.svelte.d.ts +32 -0
  82. package/dist/ui/Button.svelte +42 -0
  83. package/dist/ui/Button.svelte.d.ts +35 -0
  84. package/dist/ui/Slider.svelte +100 -0
  85. package/dist/ui/Slider.svelte.d.ts +17 -0
  86. package/dist/ui/badge.d.ts +6 -0
  87. package/dist/ui/badge.js +10 -0
  88. package/dist/ui/button.d.ts +8 -0
  89. package/dist/ui/button.js +21 -0
  90. package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte +34 -0
  91. package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte.d.ts +31 -0
  92. package/dist/ui/context-menu/ContextMenuContent.svelte +17 -0
  93. package/dist/ui/context-menu/ContextMenuContent.svelte.d.ts +7 -0
  94. package/dist/ui/context-menu/ContextMenuItem.svelte +22 -0
  95. package/dist/ui/context-menu/ContextMenuItem.svelte.d.ts +8 -0
  96. package/dist/ui/context-menu/ContextMenuLabel.svelte +22 -0
  97. package/dist/ui/context-menu/ContextMenuLabel.svelte.d.ts +8 -0
  98. package/dist/ui/context-menu/ContextMenuPortal.svelte +11 -0
  99. package/dist/ui/context-menu/ContextMenuPortal.svelte.d.ts +6 -0
  100. package/dist/ui/context-menu/ContextMenuRadioItem.svelte +21 -0
  101. package/dist/ui/context-menu/ContextMenuRadioItem.svelte.d.ts +31 -0
  102. package/dist/ui/context-menu/ContextMenuSeparator.svelte +14 -0
  103. package/dist/ui/context-menu/ContextMenuSeparator.svelte.d.ts +6 -0
  104. package/dist/ui/context-menu/ContextMenuShortcut.svelte +19 -0
  105. package/dist/ui/context-menu/ContextMenuShortcut.svelte.d.ts +7 -0
  106. package/dist/ui/context-menu/ContextMenuSubContent.svelte +20 -0
  107. package/dist/ui/context-menu/ContextMenuSubContent.svelte.d.ts +7 -0
  108. package/dist/ui/context-menu/ContextMenuSubTrigger.svelte +34 -0
  109. package/dist/ui/context-menu/ContextMenuSubTrigger.svelte.d.ts +8 -0
  110. package/dist/ui/context-menu/index.d.ts +17 -0
  111. package/dist/ui/context-menu/index.js +17 -0
  112. package/package.json +51 -0
  113. package/src/DevModePanel.svelte +650 -0
  114. package/src/DvdLogo.svelte +213 -0
  115. package/src/Icons.svelte +27 -0
  116. package/src/IdleScreen.svelte +739 -0
  117. package/src/LoadingScreen.svelte +674 -0
  118. package/src/Player.svelte +483 -0
  119. package/src/PlayerControls.svelte +752 -0
  120. package/src/SeekBar.svelte +274 -0
  121. package/src/SkipIndicator.svelte +95 -0
  122. package/src/SpeedIndicator.svelte +37 -0
  123. package/src/StatsPanel.svelte +155 -0
  124. package/src/StreamStateOverlay.svelte +266 -0
  125. package/src/SubtitleRenderer.svelte +234 -0
  126. package/src/ThumbnailOverlay.svelte +96 -0
  127. package/src/TitleOverlay.svelte +47 -0
  128. package/src/assets/logomark.svg +56 -0
  129. package/src/components/VolumeIcons.svelte +53 -0
  130. package/src/global.d.ts +15 -0
  131. package/src/icons/FullscreenExitIcon.svelte +33 -0
  132. package/src/icons/FullscreenIcon.svelte +33 -0
  133. package/src/icons/PauseIcon.svelte +28 -0
  134. package/src/icons/PictureInPictureIcon.svelte +28 -0
  135. package/src/icons/PlayIcon.svelte +27 -0
  136. package/src/icons/SeekToLiveIcon.svelte +30 -0
  137. package/src/icons/SettingsIcon.svelte +40 -0
  138. package/src/icons/SkipBackIcon.svelte +32 -0
  139. package/src/icons/SkipForwardIcon.svelte +32 -0
  140. package/src/icons/StatsIcon.svelte +29 -0
  141. package/src/icons/VolumeOffIcon.svelte +29 -0
  142. package/src/icons/VolumeUpIcon.svelte +34 -0
  143. package/src/icons/index.ts +18 -0
  144. package/src/index.ts +84 -0
  145. package/src/player.css +2 -0
  146. package/src/stores/index.ts +88 -0
  147. package/src/stores/playbackQuality.ts +137 -0
  148. package/src/stores/playerContext.ts +221 -0
  149. package/src/stores/playerController.ts +568 -0
  150. package/src/stores/playerSelection.ts +216 -0
  151. package/src/stores/streamState.ts +367 -0
  152. package/src/stores/viewerEndpoints.ts +224 -0
  153. package/src/types.ts +6 -0
  154. package/src/ui/Badge.svelte +21 -0
  155. package/src/ui/Button.svelte +42 -0
  156. package/src/ui/Slider.svelte +100 -0
  157. package/src/ui/badge.ts +20 -0
  158. package/src/ui/button.ts +35 -0
  159. package/src/ui/context-menu/ContextMenuCheckboxItem.svelte +34 -0
  160. package/src/ui/context-menu/ContextMenuContent.svelte +17 -0
  161. package/src/ui/context-menu/ContextMenuItem.svelte +22 -0
  162. package/src/ui/context-menu/ContextMenuLabel.svelte +22 -0
  163. package/src/ui/context-menu/ContextMenuPortal.svelte +11 -0
  164. package/src/ui/context-menu/ContextMenuRadioItem.svelte +21 -0
  165. package/src/ui/context-menu/ContextMenuSeparator.svelte +14 -0
  166. package/src/ui/context-menu/ContextMenuShortcut.svelte +19 -0
  167. package/src/ui/context-menu/ContextMenuSubContent.svelte +20 -0
  168. package/src/ui/context-menu/ContextMenuSubTrigger.svelte +34 -0
  169. package/src/ui/context-menu/index.ts +36 -0
@@ -0,0 +1,739 @@
1
+ <script lang="ts">
2
+ import {
3
+ cn,
4
+ globalPlayerManager,
5
+ type MistStreamInfo,
6
+ type PlaybackMode,
7
+ // Seeking utilities from core
8
+ SPEED_PRESETS,
9
+ getLatencyTier,
10
+ isMediaStreamSource,
11
+ supportsPlaybackRate as coreSupportsPlaybackRate,
12
+ calculateSeekableRange,
13
+ canSeekStream,
14
+ calculateLiveThresholds,
15
+ calculateIsNearLive,
16
+ isLiveContent,
17
+ // Time formatting from core
18
+ formatTime,
19
+ formatTimeDisplay,
20
+ } from '@livepeer-frameworks/player-core';
21
+ import SeekBar from './SeekBar.svelte';
22
+ import Slider from './ui/Slider.svelte';
23
+ import VolumeIcons from './components/VolumeIcons.svelte';
24
+ import {
25
+ StatsIcon,
26
+ SettingsIcon,
27
+ PlayIcon,
28
+ PauseIcon,
29
+ SkipBackIcon,
30
+ SkipForwardIcon,
31
+ FullscreenIcon,
32
+ FullscreenExitIcon,
33
+ SeekToLiveIcon,
34
+ } from './icons';
35
+
36
+ // Props - aligned with React PlayerControls
37
+ interface Props {
38
+ currentTime: number;
39
+ duration: number;
40
+ isVisible?: boolean;
41
+ onseek?: (time: number) => void;
42
+ mistStreamInfo?: MistStreamInfo;
43
+ disabled?: boolean;
44
+ playbackMode?: PlaybackMode;
45
+ onModeChange?: (mode: PlaybackMode) => void;
46
+ sourceType?: string;
47
+ showStatsButton?: boolean;
48
+ isStatsOpen?: boolean;
49
+ onStatsToggle?: () => void;
50
+ /** Content-type based live flag (for mode selector visibility, separate from seek bar isLive) */
51
+ isContentLive?: boolean;
52
+ }
53
+
54
+ let {
55
+ currentTime,
56
+ duration,
57
+ isVisible = true,
58
+ onseek = undefined,
59
+ mistStreamInfo = undefined,
60
+ disabled = false,
61
+ playbackMode = 'auto',
62
+ onModeChange = undefined,
63
+ sourceType = undefined,
64
+ showStatsButton = false,
65
+ isStatsOpen = false,
66
+ onStatsToggle = undefined,
67
+ isContentLive = undefined,
68
+ }: Props = $props();
69
+
70
+ // Video element discovery
71
+ let video: HTMLVideoElement | null = $state(null);
72
+ let videoCheckInterval: ReturnType<typeof setInterval> | null = null;
73
+
74
+ function findVideoElement(): HTMLVideoElement | null {
75
+ const player = globalPlayerManager.getCurrentPlayer();
76
+ if (player?.getVideoElement?.()) return player.getVideoElement();
77
+ return document.querySelector('[data-player-container="true"] video') as HTMLVideoElement | null
78
+ ?? document.querySelector('.fw-player-container video') as HTMLVideoElement | null;
79
+ }
80
+
81
+ $effect(() => {
82
+ video = findVideoElement();
83
+ if (!video) {
84
+ videoCheckInterval = setInterval(() => {
85
+ const v = findVideoElement();
86
+ if (v) {
87
+ video = v;
88
+ if (videoCheckInterval) {
89
+ clearInterval(videoCheckInterval);
90
+ videoCheckInterval = null;
91
+ }
92
+ }
93
+ }, 100);
94
+ setTimeout(() => {
95
+ if (videoCheckInterval) {
96
+ clearInterval(videoCheckInterval);
97
+ videoCheckInterval = null;
98
+ }
99
+ }, 5000);
100
+ }
101
+ return () => {
102
+ if (videoCheckInterval) {
103
+ clearInterval(videoCheckInterval);
104
+ videoCheckInterval = null;
105
+ }
106
+ };
107
+ });
108
+
109
+ // Local state
110
+ let isPlaying = $state(false);
111
+ let isMuted = $state(false);
112
+ let isFullscreen = $state(false);
113
+ let hasAudio = $state(true);
114
+ let volumeValue = $state(100);
115
+ let playbackRate = $state(1);
116
+ let showSettingsMenu = $state(false);
117
+ let isNearLiveState = $state(true);
118
+ let buffered: TimeRanges | undefined = $state(undefined);
119
+ let hasSeekToLive = false; // Track if we've auto-seeked to live
120
+ let qualityValue = $state('auto');
121
+ let captionValue = $state('none');
122
+
123
+ // Text tracks from player
124
+ let textTracks = $derived.by(() => {
125
+ return globalPlayerManager.getCurrentPlayer()?.getTextTracks?.() ?? [];
126
+ });
127
+
128
+ // Quality selection priority:
129
+ // 1. Player-provided qualities (HLS.js/DASH.js levels with correct numeric indices)
130
+ // 2. Mist track metadata (for players that don't provide quality API)
131
+ // This fixes a critical bug where Mist track IDs (e.g., "a1", "v0") were passed to
132
+ // HLS/DASH players which expect numeric indices (e.g., "0", "1", "2")
133
+ let qualities = $derived.by(() => {
134
+ // Try player's quality API first - this returns properly indexed levels
135
+ const playerQualities = globalPlayerManager.getCurrentPlayer()?.getQualities?.();
136
+ if (playerQualities && playerQualities.length > 0) {
137
+ return playerQualities;
138
+ }
139
+
140
+ // Fallback to Mist track metadata for players without quality API
141
+ const mistTracks = mistStreamInfo?.meta?.tracks;
142
+ if (mistTracks) {
143
+ return Object.entries(mistTracks)
144
+ .filter(([, t]) => t.type === 'video')
145
+ .map(([id, t]) => ({
146
+ id,
147
+ label: t.height ? `${t.height}p` : t.codec,
148
+ width: t.width,
149
+ height: t.height,
150
+ bitrate: t.bps,
151
+ }))
152
+ .sort((a, b) => (b.height || 0) - (a.height || 0));
153
+ }
154
+ return [];
155
+ });
156
+
157
+ // Hover state for volume
158
+ let isVolumeHovered = $state(false);
159
+ let isVolumeFocused = $state(false);
160
+ let isVolumeExpanded = $derived(isVolumeHovered || isVolumeFocused);
161
+
162
+ // Derived values - using centralized core utilities
163
+ let isLive = $derived(isLiveContent(isContentLive, mistStreamInfo, duration));
164
+ let isWebRTC = $derived(isMediaStreamSource(video));
165
+ let supportsPlaybackRate = $derived(coreSupportsPlaybackRate(video));
166
+ function deriveBufferWindowMs(tracks?: Record<string, { firstms?: number; lastms?: number }>): number | undefined {
167
+ if (!tracks) return undefined;
168
+ const list = Object.values(tracks);
169
+ if (list.length === 0) return undefined;
170
+ const firstmsValues = list.map(t => t.firstms).filter((v): v is number => v !== undefined);
171
+ const lastmsValues = list.map(t => t.lastms).filter((v): v is number => v !== undefined);
172
+ if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
173
+ const firstms = Math.max(...firstmsValues);
174
+ const lastms = Math.min(...lastmsValues);
175
+ const window = lastms - firstms;
176
+ if (!Number.isFinite(window) || window <= 0) return undefined;
177
+ return window;
178
+ }
179
+
180
+ let bufferWindowMs = $derived(
181
+ mistStreamInfo?.meta?.buffer_window
182
+ ?? deriveBufferWindowMs(mistStreamInfo?.meta?.tracks as Record<string, { firstms?: number; lastms?: number }> | undefined)
183
+ );
184
+
185
+ function getPlayerSeekableRange(): { seekableStart: number; liveEdge: number } | null {
186
+ const player = globalPlayerManager.getCurrentPlayer();
187
+ if (player && typeof (player as any).getSeekableRange === 'function') {
188
+ const range = (player as any).getSeekableRange();
189
+ if (range && Number.isFinite(range.start) && Number.isFinite(range.end) && range.end >= range.start) {
190
+ return { seekableStart: range.start, liveEdge: range.end };
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ // Seekable range using core calculation (allow player override)
197
+ let seekableRange = $derived.by(() => getPlayerSeekableRange() ?? calculateSeekableRange({
198
+ isLive,
199
+ video,
200
+ mistStreamInfo,
201
+ currentTime,
202
+ duration,
203
+ }));
204
+ let seekableStart = $derived(seekableRange.seekableStart);
205
+ let liveEdge = $derived(seekableRange.liveEdge);
206
+ let hasDvrWindow = $derived(isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart);
207
+ let commitOnRelease = $derived(isLive);
208
+
209
+ // Live thresholds with buffer window scaling
210
+ let liveThresholds = $derived(calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs));
211
+
212
+ // Can seek - check player's canSeek method first (for WebCodecs, MEWS server-side seeking)
213
+ let canSeek = $derived.by(() => {
214
+ // Check if current player has canSeek method
215
+ const player = globalPlayerManager.getCurrentPlayer();
216
+ if (player && typeof (player as any).canSeek === 'function') {
217
+ return (player as any).canSeek();
218
+ }
219
+ // Fallback to core utility logic
220
+ return canSeekStream({
221
+ video,
222
+ isLive,
223
+ duration,
224
+ bufferWindowMs,
225
+ });
226
+ });
227
+
228
+ // Update state from video events
229
+ $effect(() => {
230
+ if (!video) return;
231
+
232
+ function updatePlayingState() { isPlaying = !video!.paused; }
233
+ function updateMutedState() {
234
+ isMuted = video!.muted || video!.volume === 0;
235
+ const vol = video!.volume;
236
+ volumeValue = Number.isFinite(vol) ? Math.round(vol * 100) : 100;
237
+ }
238
+ function updateFullscreenState() {
239
+ isFullscreen = !!document.fullscreenElement;
240
+ }
241
+ function updatePlaybackRate() { playbackRate = video!.playbackRate; }
242
+ function updateBuffered() {
243
+ const player = globalPlayerManager.getCurrentPlayer();
244
+ buffered = player?.getBufferedRanges?.() ?? video!.buffered;
245
+ }
246
+
247
+ updatePlayingState();
248
+ updateMutedState();
249
+ updateFullscreenState();
250
+ updatePlaybackRate();
251
+ updateBuffered();
252
+
253
+ video.addEventListener('play', updatePlayingState);
254
+ video.addEventListener('pause', updatePlayingState);
255
+ video.addEventListener('playing', updatePlayingState);
256
+ video.addEventListener('volumechange', updateMutedState);
257
+ video.addEventListener('ratechange', updatePlaybackRate);
258
+ video.addEventListener('progress', updateBuffered);
259
+ video.addEventListener('loadeddata', updateBuffered);
260
+ document.addEventListener('fullscreenchange', updateFullscreenState);
261
+
262
+ return () => {
263
+ video!.removeEventListener('play', updatePlayingState);
264
+ video!.removeEventListener('pause', updatePlayingState);
265
+ video!.removeEventListener('playing', updatePlayingState);
266
+ video!.removeEventListener('volumechange', updateMutedState);
267
+ video!.removeEventListener('ratechange', updatePlaybackRate);
268
+ video!.removeEventListener('progress', updateBuffered);
269
+ video!.removeEventListener('loadeddata', updateBuffered);
270
+ document.removeEventListener('fullscreenchange', updateFullscreenState);
271
+ };
272
+ });
273
+
274
+ // Reset seek-to-live flag when video element changes
275
+ $effect(() => {
276
+ if (video) {
277
+ hasSeekToLive = false;
278
+ }
279
+ });
280
+
281
+ // Hysteresis for live badge - using core calculation
282
+ $effect(() => {
283
+ if (!isLive) {
284
+ isNearLiveState = true; // Always "at live" for VOD
285
+ return;
286
+ }
287
+ isNearLiveState = calculateIsNearLive(currentTime, liveEdge, liveThresholds, isNearLiveState);
288
+ });
289
+
290
+ // Time display - using core formatTimeDisplay
291
+ let timeDisplay = $derived(formatTimeDisplay({
292
+ isLive,
293
+ currentTime,
294
+ duration,
295
+ liveEdge,
296
+ seekableStart,
297
+ unixoffset: mistStreamInfo?.unixoffset,
298
+ }));
299
+
300
+ // Seek value for slider
301
+ let seekValue = $derived.by(() => {
302
+ if (isLive) {
303
+ const window = liveEdge - seekableStart;
304
+ if (window <= 0) return 1000;
305
+ return ((currentTime - seekableStart) / window) * 1000;
306
+ }
307
+ return Number.isFinite(duration) && duration > 0 ? (currentTime / duration) * 1000 : 0;
308
+ });
309
+
310
+ // Handlers
311
+ function handlePlayPause() {
312
+ if (disabled) return;
313
+ const player = globalPlayerManager.getCurrentPlayer();
314
+ const v = player?.getVideoElement?.() ?? video;
315
+ if (!v && !player) return;
316
+ const paused = player?.isPaused?.() ?? v?.paused ?? true;
317
+ if (paused) {
318
+ player?.play?.().catch(() => {});
319
+ v?.play?.().catch(() => {});
320
+ } else {
321
+ player?.pause?.();
322
+ v?.pause?.();
323
+ }
324
+ }
325
+
326
+ function handleSkipBack() {
327
+ const newTime = Math.max(0, currentTime - 10);
328
+ if (onseek) {
329
+ onseek(newTime);
330
+ return;
331
+ }
332
+ const v = findVideoElement();
333
+ if (v) v.currentTime = newTime;
334
+ }
335
+
336
+ function handleSkipForward() {
337
+ const maxTime = Number.isFinite(duration) ? duration : currentTime + 10;
338
+ const newTime = Math.min(maxTime, currentTime + 10);
339
+ if (onseek) {
340
+ onseek(newTime);
341
+ return;
342
+ }
343
+ const v = findVideoElement();
344
+ if (v) v.currentTime = newTime;
345
+ }
346
+
347
+ function handleMute() {
348
+ if (disabled) return;
349
+ const player = globalPlayerManager.getCurrentPlayer();
350
+ const v = player?.getVideoElement?.() ?? video;
351
+ if (!v) return;
352
+ const nextMuted = !(player?.isMuted?.() ?? v.muted);
353
+ player?.setMuted?.(nextMuted);
354
+ v.muted = nextMuted;
355
+ isMuted = nextMuted;
356
+ if (nextMuted) {
357
+ volumeValue = 0;
358
+ } else {
359
+ volumeValue = Math.round((Number.isFinite(v.volume) ? v.volume : 1) * 100);
360
+ }
361
+ }
362
+
363
+ function handleVolumeChange(val: number) {
364
+ if (disabled) return;
365
+ const player = globalPlayerManager.getCurrentPlayer();
366
+ const v = player?.getVideoElement?.() ?? video;
367
+ if (!v) return;
368
+ // Validate: clamp to 0-100, handle NaN/Infinity (matches React implementation)
369
+ const next = Math.max(0, Math.min(100, val ?? 100));
370
+ if (!Number.isFinite(next)) return;
371
+ v.volume = next / 100;
372
+ v.muted = next === 0;
373
+ volumeValue = next;
374
+ isMuted = next === 0;
375
+ }
376
+
377
+ function handleSeekChange(val: number) {
378
+ if (disabled || !video) return;
379
+ if (isLive) {
380
+ const window = liveEdge - seekableStart;
381
+ const newTime = seekableStart + (val / 1000) * window;
382
+ if (onseek) {
383
+ onseek(newTime);
384
+ } else {
385
+ video.currentTime = newTime;
386
+ }
387
+ } else if (Number.isFinite(duration)) {
388
+ const newTime = (val / 1000) * duration;
389
+ if (onseek) {
390
+ onseek(newTime);
391
+ } else {
392
+ video.currentTime = newTime;
393
+ }
394
+ }
395
+ }
396
+
397
+ function handleFullscreen() {
398
+ if (disabled) return;
399
+ const container = document.querySelector('[data-player-container="true"]') as HTMLElement | null;
400
+ if (!container) return;
401
+ if (document.fullscreenElement) {
402
+ document.exitFullscreen().catch(() => {});
403
+ } else {
404
+ container.requestFullscreen().catch(() => {});
405
+ }
406
+ }
407
+
408
+ function handleGoLive() {
409
+ if (disabled || !video) return;
410
+ globalPlayerManager.getCurrentPlayer()?.jumpToLive?.();
411
+ }
412
+
413
+ function handleSpeedSelect(rate: number) {
414
+ if (disabled) return;
415
+ // Use findVideoElement for robust detection
416
+ const v = findVideoElement();
417
+ if (!v) return;
418
+ v.playbackRate = rate;
419
+ showSettingsMenu = false;
420
+ }
421
+
422
+ function handleQualityChange(value: string) {
423
+ if (disabled) return;
424
+ qualityValue = value;
425
+ globalPlayerManager.getCurrentPlayer()?.selectQuality?.(value);
426
+ showSettingsMenu = false;
427
+ }
428
+
429
+ function handleCaptionChange(value: string) {
430
+ if (disabled) return;
431
+ captionValue = value;
432
+ if (value === 'none') {
433
+ globalPlayerManager.getCurrentPlayer()?.selectTextTrack?.(null);
434
+ } else {
435
+ globalPlayerManager.getCurrentPlayer()?.selectTextTrack?.(value);
436
+ }
437
+ showSettingsMenu = false;
438
+ }
439
+
440
+ // Close menu when clicking outside - with debounce to prevent immediate close from same click
441
+ $effect(() => {
442
+ if (!showSettingsMenu) return;
443
+
444
+ const handleClick = (e: MouseEvent) => {
445
+ const target = e.target as HTMLElement;
446
+ if (!target.closest('.fw-settings-menu')) {
447
+ showSettingsMenu = false;
448
+ }
449
+ };
450
+
451
+ // Debounce to prevent immediate close from the same click that opened the menu
452
+ const timeout = setTimeout(() => {
453
+ window.addEventListener('click', handleClick);
454
+ }, 0);
455
+
456
+ return () => {
457
+ clearTimeout(timeout);
458
+ window.removeEventListener('click', handleClick);
459
+ };
460
+ });
461
+ </script>
462
+
463
+ <div class={cn(
464
+ 'fw-player-surface fw-controls-wrapper',
465
+ isVisible ? 'fw-controls-wrapper--visible' : 'fw-controls-wrapper--hidden'
466
+ )}>
467
+ <!-- Control bar -->
468
+ <div class="fw-control-bar pointer-events-auto" onclick={(e) => e.stopPropagation()}>
469
+ <!-- Seek bar -->
470
+ {#if canSeek}
471
+ <div class="fw-seek-wrapper">
472
+ <SeekBar
473
+ {currentTime}
474
+ {duration}
475
+ {buffered}
476
+ {disabled}
477
+ isLive={isLive}
478
+ {seekableStart}
479
+ {liveEdge}
480
+ {commitOnRelease}
481
+ onseek={(time) => {
482
+ if (onseek) {
483
+ onseek(time);
484
+ } else if (video) {
485
+ video.currentTime = time;
486
+ }
487
+ }}
488
+ />
489
+ </div>
490
+ {/if}
491
+
492
+ <!-- Control buttons -->
493
+ <div class="fw-controls-row">
494
+ <!-- Left: Play, Skip, Volume, Time, Live -->
495
+ <div class="fw-controls-left">
496
+ <div class="fw-control-group">
497
+ <button type="button" class="fw-btn-flush" aria-label={isPlaying ? 'Pause' : 'Play'} onclick={handlePlayPause} disabled={disabled}>
498
+ {#if isPlaying}
499
+ <PauseIcon size={18} />
500
+ {:else}
501
+ <PlayIcon size={18} />
502
+ {/if}
503
+ </button>
504
+ {#if canSeek}
505
+ <button type="button" class="fw-btn-flush hidden sm:flex" aria-label="Skip back 10s" onclick={handleSkipBack} disabled={disabled}>
506
+ <SkipBackIcon size={16} />
507
+ </button>
508
+ <button type="button" class="fw-btn-flush hidden sm:flex" aria-label="Skip forward 10s" onclick={handleSkipForward} disabled={disabled}>
509
+ <SkipForwardIcon size={16} />
510
+ </button>
511
+ {/if}
512
+ </div>
513
+
514
+ <!-- Volume -->
515
+ <div
516
+ class={cn(
517
+ 'fw-volume-group',
518
+ isVolumeExpanded && 'fw-volume-group--expanded',
519
+ !hasAudio && 'fw-volume-group--disabled'
520
+ )}
521
+ onmouseenter={() => hasAudio && (isVolumeHovered = true)}
522
+ onmouseleave={() => { isVolumeHovered = false; isVolumeFocused = false; }}
523
+ onfocuscapture={() => hasAudio && (isVolumeFocused = true)}
524
+ onblurcapture={(e) => {
525
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) isVolumeFocused = false;
526
+ }}
527
+ onclick={(e) => {
528
+ if (disabled) return;
529
+ if (hasAudio && e.target === e.currentTarget) {
530
+ handleMute();
531
+ }
532
+ }}
533
+ >
534
+ <button
535
+ type="button"
536
+ class="fw-volume-btn"
537
+ aria-label={!hasAudio ? 'No audio' : (isMuted ? 'Unmute' : 'Mute')}
538
+ title={!hasAudio ? 'No audio' : (isMuted ? 'Unmute' : 'Mute')}
539
+ onclick={handleMute}
540
+ disabled={!hasAudio}
541
+ >
542
+ <VolumeIcons isMuted={isMuted} volume={volumeValue / 100} size={16} />
543
+ </button>
544
+ <div class={cn(
545
+ 'fw-volume-slider-wrapper',
546
+ isVolumeExpanded ? 'fw-volume-slider-wrapper--expanded' : 'fw-volume-slider-wrapper--collapsed'
547
+ )}>
548
+ <Slider
549
+ min={0}
550
+ max={100}
551
+ step={1}
552
+ value={isMuted ? 0 : volumeValue}
553
+ oninput={handleVolumeChange}
554
+ orientation="horizontal"
555
+ className="w-full"
556
+ aria-label="Volume"
557
+ disabled={!hasAudio}
558
+ />
559
+ </div>
560
+ </div>
561
+
562
+ <div class="fw-control-group">
563
+ <span class="fw-time-display">
564
+ {timeDisplay}
565
+ </span>
566
+ </div>
567
+
568
+ {#if isLive}
569
+ <div class="fw-control-group">
570
+ <button
571
+ type="button"
572
+ onclick={handleGoLive}
573
+ disabled={!hasDvrWindow || isNearLiveState}
574
+ class={cn(
575
+ 'fw-live-badge',
576
+ (!hasDvrWindow || isNearLiveState) ? 'fw-live-badge--active' : 'fw-live-badge--behind',
577
+ !hasDvrWindow && 'fw-live-badge--nodvr'
578
+ )}
579
+ title={!hasDvrWindow ? 'Live only (DVR disabled)' : (isNearLiveState ? 'At live edge' : 'Jump to live')}
580
+ >
581
+ LIVE
582
+ {#if !hasDvrWindow}
583
+ <span class="fw-live-badge__nodvr">NO DVR</span>
584
+ {/if}
585
+ {#if !isNearLiveState && hasDvrWindow}
586
+ <SeekToLiveIcon size={10} />
587
+ {/if}
588
+ </button>
589
+ </div>
590
+ {/if}
591
+ </div>
592
+
593
+ <!-- Right: Stats, Settings, Fullscreen -->
594
+ <div class="fw-controls-right">
595
+ {#if showStatsButton}
596
+ <div class="fw-control-group">
597
+ <button
598
+ type="button"
599
+ class={cn('fw-btn-flush', isStatsOpen && 'fw-btn-flush--active')}
600
+ aria-label="Toggle stats"
601
+ title="Stats"
602
+ onclick={onStatsToggle}
603
+ disabled={disabled}
604
+ >
605
+ <StatsIcon size={16} />
606
+ </button>
607
+ </div>
608
+ {/if}
609
+ <div class="fw-control-group relative">
610
+ <button
611
+ type="button"
612
+ class={cn('fw-btn-flush group', showSettingsMenu && 'fw-btn-flush--active')}
613
+ aria-label="Settings"
614
+ title="Settings"
615
+ onclick={() => showSettingsMenu = !showSettingsMenu}
616
+ disabled={disabled}
617
+ >
618
+ <SettingsIcon size={16} class="transition-transform group-hover:rotate-90" />
619
+ </button>
620
+
621
+ {#if showSettingsMenu}
622
+ <div class="fw-player-surface fw-settings-menu">
623
+ <!-- Playback Mode - only show for live content (not VOD/clips) -->
624
+ {#if onModeChange && isContentLive !== false}
625
+ <div class="fw-settings-section">
626
+ <div class="fw-settings-label">Mode</div>
627
+ <div class="fw-settings-options">
628
+ {#each ['auto', 'low-latency', 'quality'] as mode}
629
+ <button
630
+ type="button"
631
+ class={cn(
632
+ 'fw-settings-btn',
633
+ playbackMode === mode && 'fw-settings-btn--active'
634
+ )}
635
+ onclick={() => { onModeChange(mode as PlaybackMode); showSettingsMenu = false; }}
636
+ >
637
+ {mode === 'low-latency' ? 'Fast' : mode === 'quality' ? 'Stable' : 'Auto'}
638
+ </button>
639
+ {/each}
640
+ </div>
641
+ </div>
642
+ {/if}
643
+
644
+ <!-- Speed (hidden for WebRTC MediaStream) -->
645
+ {#if supportsPlaybackRate}
646
+ <div class="fw-settings-section">
647
+ <div class="fw-settings-label">Speed</div>
648
+ <div class="fw-settings-options fw-settings-options--wrap">
649
+ {#each SPEED_PRESETS as rate}
650
+ <button
651
+ type="button"
652
+ class={cn(
653
+ 'fw-settings-btn',
654
+ playbackRate === rate && 'fw-settings-btn--active'
655
+ )}
656
+ onclick={() => handleSpeedSelect(rate)}
657
+ disabled={disabled}
658
+ >
659
+ {rate}x
660
+ </button>
661
+ {/each}
662
+ </div>
663
+ </div>
664
+ {/if}
665
+
666
+ <!-- Quality -->
667
+ {#if qualities.length > 0}
668
+ <div class="fw-settings-section">
669
+ <div class="fw-settings-label">Quality</div>
670
+ <div class="fw-settings-list">
671
+ <button
672
+ class={cn(
673
+ 'fw-settings-list-item',
674
+ qualityValue === 'auto' && 'fw-settings-list-item--active'
675
+ )}
676
+ onclick={() => handleQualityChange('auto')}
677
+ >
678
+ Auto
679
+ </button>
680
+ {#each qualities as q}
681
+ <button
682
+ class={cn(
683
+ 'fw-settings-list-item',
684
+ qualityValue === q.id && 'fw-settings-list-item--active'
685
+ )}
686
+ onclick={() => handleQualityChange(q.id)}
687
+ >
688
+ {q.label}
689
+ </button>
690
+ {/each}
691
+ </div>
692
+ </div>
693
+ {/if}
694
+
695
+ <!-- Captions -->
696
+ {#if textTracks.length > 0}
697
+ <div class="fw-settings-section">
698
+ <div class="fw-settings-label">Captions</div>
699
+ <div class="fw-settings-list">
700
+ <button
701
+ class={cn(
702
+ 'fw-settings-list-item',
703
+ captionValue === 'none' && 'fw-settings-list-item--active'
704
+ )}
705
+ onclick={() => handleCaptionChange('none')}
706
+ >
707
+ Off
708
+ </button>
709
+ {#each textTracks as t}
710
+ <button
711
+ class={cn(
712
+ 'fw-settings-list-item',
713
+ captionValue === t.id && 'fw-settings-list-item--active'
714
+ )}
715
+ onclick={() => handleCaptionChange(t.id)}
716
+ >
717
+ {t.label || t.id}
718
+ </button>
719
+ {/each}
720
+ </div>
721
+ </div>
722
+ {/if}
723
+ </div>
724
+ {/if}
725
+ </div>
726
+
727
+ <div class="fw-control-group">
728
+ <button type="button" class="fw-btn-flush" aria-label="Toggle fullscreen" title="Fullscreen" onclick={handleFullscreen} disabled={disabled}>
729
+ {#if isFullscreen}
730
+ <FullscreenExitIcon size={16} />
731
+ {:else}
732
+ <FullscreenIcon size={16} />
733
+ {/if}
734
+ </button>
735
+ </div>
736
+ </div>
737
+ </div>
738
+ </div>
739
+ </div>