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