@livepeer-frameworks/player-svelte 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/DevModePanel.svelte +266 -127
  2. package/dist/DevModePanel.svelte.d.ts +1 -1
  3. package/dist/DvdLogo.svelte +17 -21
  4. package/dist/Icons.svelte +5 -3
  5. package/dist/Icons.svelte.d.ts +6 -19
  6. package/dist/IdleScreen.svelte +277 -186
  7. package/dist/IdleScreen.svelte.d.ts +1 -1
  8. package/dist/LoadingScreen.svelte +190 -162
  9. package/dist/Player.svelte +244 -111
  10. package/dist/Player.svelte.d.ts +1 -1
  11. package/dist/PlayerControls.svelte +263 -168
  12. package/dist/PlayerControls.svelte.d.ts +1 -1
  13. package/dist/SeekBar.svelte +61 -35
  14. package/dist/SkipIndicator.svelte +4 -4
  15. package/dist/SkipIndicator.svelte.d.ts +1 -1
  16. package/dist/SpeedIndicator.svelte +1 -1
  17. package/dist/StatsPanel.svelte +76 -57
  18. package/dist/StatsPanel.svelte.d.ts +1 -1
  19. package/dist/StreamStateOverlay.svelte +143 -107
  20. package/dist/StreamStateOverlay.svelte.d.ts +1 -1
  21. package/dist/SubtitleRenderer.svelte +46 -43
  22. package/dist/ThumbnailOverlay.svelte +22 -19
  23. package/dist/TitleOverlay.svelte +6 -11
  24. package/dist/components/VolumeIcons.svelte +12 -6
  25. package/dist/global.d.ts +3 -3
  26. package/dist/icons/FullscreenExitIcon.svelte +1 -5
  27. package/dist/icons/FullscreenIcon.svelte +1 -5
  28. package/dist/icons/PauseIcon.svelte +1 -5
  29. package/dist/icons/PictureInPictureIcon.svelte +12 -6
  30. package/dist/icons/PlayIcon.svelte +1 -5
  31. package/dist/icons/SeekToLiveIcon.svelte +1 -5
  32. package/dist/icons/SettingsIcon.svelte +1 -5
  33. package/dist/icons/SkipBackIcon.svelte +1 -5
  34. package/dist/icons/SkipForwardIcon.svelte +1 -5
  35. package/dist/icons/StatsIcon.svelte +1 -5
  36. package/dist/icons/VolumeOffIcon.svelte +1 -5
  37. package/dist/icons/VolumeUpIcon.svelte +1 -5
  38. package/dist/icons/index.d.ts +12 -12
  39. package/dist/icons/index.js +12 -12
  40. package/dist/index.d.ts +24 -24
  41. package/dist/index.js +21 -21
  42. package/dist/stores/index.d.ts +6 -6
  43. package/dist/stores/index.js +6 -6
  44. package/dist/stores/playbackQuality.d.ts +2 -2
  45. package/dist/stores/playbackQuality.js +7 -7
  46. package/dist/stores/playerContext.d.ts +2 -2
  47. package/dist/stores/playerContext.js +17 -17
  48. package/dist/stores/playerController.d.ts +13 -4
  49. package/dist/stores/playerController.js +80 -56
  50. package/dist/stores/playerSelection.d.ts +2 -2
  51. package/dist/stores/playerSelection.js +7 -7
  52. package/dist/stores/streamState.d.ts +2 -2
  53. package/dist/stores/streamState.js +56 -56
  54. package/dist/stores/viewerEndpoints.d.ts +3 -3
  55. package/dist/stores/viewerEndpoints.js +21 -21
  56. package/dist/types.d.ts +1 -1
  57. package/dist/ui/Badge.svelte +9 -10
  58. package/dist/ui/Badge.svelte.d.ts +8 -29
  59. package/dist/ui/Button.svelte +16 -16
  60. package/dist/ui/Button.svelte.d.ts +8 -29
  61. package/dist/ui/Slider.svelte +21 -55
  62. package/dist/ui/badge.js +1 -1
  63. package/dist/ui/button.js +2 -2
  64. package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte +5 -7
  65. package/dist/ui/context-menu/ContextMenuCheckboxItem.svelte.d.ts +6 -27
  66. package/dist/ui/context-menu/ContextMenuContent.svelte +2 -9
  67. package/dist/ui/context-menu/ContextMenuItem.svelte +1 -5
  68. package/dist/ui/context-menu/ContextMenuLabel.svelte +1 -5
  69. package/dist/ui/context-menu/ContextMenuRadioItem.svelte +5 -7
  70. package/dist/ui/context-menu/ContextMenuRadioItem.svelte.d.ts +6 -27
  71. package/dist/ui/context-menu/ContextMenuSeparator.svelte +2 -8
  72. package/dist/ui/context-menu/ContextMenuShortcut.svelte +2 -12
  73. package/dist/ui/context-menu/ContextMenuSubContent.svelte +1 -5
  74. package/package.json +15 -7
  75. package/src/DevModePanel.svelte +1 -0
  76. package/src/Icons.svelte +5 -3
  77. package/src/IdleScreen.svelte +21 -14
  78. package/src/LoadingScreen.svelte +20 -13
  79. package/src/Player.svelte +48 -2
  80. package/src/PlayerControls.svelte +36 -17
  81. package/src/SeekBar.svelte +33 -0
  82. package/src/StreamStateOverlay.svelte +2 -2
  83. package/src/stores/playerController.ts +39 -1
  84. package/src/stores/viewerEndpoints.ts +1 -1
  85. package/src/ui/Badge.svelte +7 -4
  86. package/src/ui/Button.svelte +13 -13
  87. package/src/ui/context-menu/ContextMenuCheckboxItem.svelte +4 -2
  88. package/src/ui/context-menu/ContextMenuRadioItem.svelte +4 -2
@@ -1,35 +1,35 @@
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
- isMediaStreamSource,
10
- supportsPlaybackRate as coreSupportsPlaybackRate,
11
- calculateSeekableRange,
12
- canSeekStream,
13
- calculateLiveThresholds,
14
- calculateIsNearLive,
15
- isLiveContent,
16
- // Time formatting from core
17
- formatTimeDisplay,
18
- } from '@livepeer-frameworks/player-core';
19
- import SeekBar from './SeekBar.svelte';
20
- import Slider from './ui/Slider.svelte';
21
- import VolumeIcons from './components/VolumeIcons.svelte';
22
- import {
23
- StatsIcon,
24
- SettingsIcon,
25
- PlayIcon,
26
- PauseIcon,
27
- SkipBackIcon,
28
- SkipForwardIcon,
29
- FullscreenIcon,
30
- FullscreenExitIcon,
31
- SeekToLiveIcon,
32
- } from './icons';
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
+ isMediaStreamSource,
10
+ supportsPlaybackRate as coreSupportsPlaybackRate,
11
+ calculateSeekableRange,
12
+ canSeekStream,
13
+ calculateLiveThresholds,
14
+ calculateIsNearLive,
15
+ isLiveContent,
16
+ // Time formatting from core
17
+ formatTimeDisplay,
18
+ } from "@livepeer-frameworks/player-core";
19
+ import SeekBar from "./SeekBar.svelte";
20
+ import Slider from "./ui/Slider.svelte";
21
+ import VolumeIcons from "./components/VolumeIcons.svelte";
22
+ import {
23
+ StatsIcon,
24
+ SettingsIcon,
25
+ PlayIcon,
26
+ PauseIcon,
27
+ SkipBackIcon,
28
+ SkipForwardIcon,
29
+ FullscreenIcon,
30
+ FullscreenExitIcon,
31
+ SeekToLiveIcon,
32
+ } from "./icons";
33
33
 
34
34
  // Props - aligned with React PlayerControls
35
35
  interface Props {
@@ -58,7 +58,7 @@
58
58
  onseek = undefined,
59
59
  mistStreamInfo = undefined,
60
60
  disabled = false,
61
- playbackMode = 'auto',
61
+ playbackMode = "auto",
62
62
  onModeChange = undefined,
63
63
  sourceType = undefined,
64
64
  showStatsButton = false,
@@ -75,8 +75,10 @@
75
75
  function findVideoElement(): HTMLVideoElement | null {
76
76
  const player = globalPlayerManager.getCurrentPlayer();
77
77
  if (player?.getVideoElement?.()) return player.getVideoElement();
78
- return document.querySelector('[data-player-container="true"] video') as HTMLVideoElement | null
79
- ?? document.querySelector('.fw-player-container video') as HTMLVideoElement | null;
78
+ return (
79
+ (document.querySelector('[data-player-container="true"] video') as HTMLVideoElement | null) ??
80
+ (document.querySelector(".fw-player-container video") as HTMLVideoElement | null)
81
+ );
80
82
  }
81
83
 
82
84
  $effect(() => {
@@ -118,8 +120,8 @@
118
120
  let isNearLiveState = $state(true);
119
121
  let buffered: TimeRanges | undefined = $state(undefined);
120
122
  let _hasSeekToLive = false; // Track if we've auto-seeked to live
121
- let qualityValue = $state('auto');
122
- let captionValue = $state('none');
123
+ let qualityValue = $state("auto");
124
+ let captionValue = $state("none");
123
125
 
124
126
  // Text tracks from player
125
127
  let textTracks = $derived.by(() => {
@@ -142,7 +144,7 @@
142
144
  const mistTracks = mistStreamInfo?.meta?.tracks;
143
145
  if (mistTracks) {
144
146
  return Object.entries(mistTracks)
145
- .filter(([, t]) => t.type === 'video')
147
+ .filter(([, t]) => t.type === "video")
146
148
  .map(([id, t]) => ({
147
149
  id,
148
150
  label: t.height ? `${t.height}p` : t.codec,
@@ -159,17 +161,20 @@
159
161
  let isVolumeHovered = $state(false);
160
162
  let isVolumeFocused = $state(false);
161
163
  let isVolumeExpanded = $derived(isVolumeHovered || isVolumeFocused);
164
+ let volumeGroupRef: HTMLDivElement | null = $state(null);
162
165
 
163
166
  // Derived values - using centralized core utilities
164
167
  let isLive = $derived(isLiveContent(isContentLive, mistStreamInfo, duration));
165
168
  let isWebRTC = $derived(isMediaStreamSource(video));
166
169
  let supportsPlaybackRate = $derived(coreSupportsPlaybackRate(video));
167
- function deriveBufferWindowMs(tracks?: Record<string, { firstms?: number; lastms?: number }>): number | undefined {
170
+ function deriveBufferWindowMs(
171
+ tracks?: Record<string, { firstms?: number; lastms?: number }>
172
+ ): number | undefined {
168
173
  if (!tracks) return undefined;
169
174
  const list = Object.values(tracks);
170
175
  if (list.length === 0) return undefined;
171
- const firstmsValues = list.map(t => t.firstms).filter((v): v is number => v !== undefined);
172
- const lastmsValues = list.map(t => t.lastms).filter((v): v is number => v !== undefined);
176
+ const firstmsValues = list.map((t) => t.firstms).filter((v): v is number => v !== undefined);
177
+ const lastmsValues = list.map((t) => t.lastms).filter((v): v is number => v !== undefined);
173
178
  if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
174
179
  const firstms = Math.max(...firstmsValues);
175
180
  const lastms = Math.min(...lastmsValues);
@@ -179,37 +184,59 @@
179
184
  }
180
185
 
181
186
  let bufferWindowMs = $derived(
182
- mistStreamInfo?.meta?.buffer_window
183
- ?? deriveBufferWindowMs(mistStreamInfo?.meta?.tracks as Record<string, { firstms?: number; lastms?: number }> | undefined)
187
+ mistStreamInfo?.meta?.buffer_window ??
188
+ deriveBufferWindowMs(
189
+ mistStreamInfo?.meta?.tracks as
190
+ | Record<string, { firstms?: number; lastms?: number }>
191
+ | undefined
192
+ )
184
193
  );
185
194
 
186
195
  function getPlayerSeekableRange(): { seekableStart: number; liveEdge: number } | null {
187
196
  const player = globalPlayerManager.getCurrentPlayer();
188
- if (player && typeof (player as any).getSeekableRange === 'function') {
197
+ if (player && typeof (player as any).getSeekableRange === "function") {
189
198
  const range = (player as any).getSeekableRange();
190
- if (range && Number.isFinite(range.start) && Number.isFinite(range.end) && range.end >= range.start) {
199
+ if (
200
+ range &&
201
+ Number.isFinite(range.start) &&
202
+ Number.isFinite(range.end) &&
203
+ range.end >= range.start
204
+ ) {
191
205
  return { seekableStart: range.start, liveEdge: range.end };
192
206
  }
193
207
  }
194
208
  return null;
195
209
  }
196
210
 
197
- const allowMediaStreamDvr = isMediaStreamSource(video)
198
- && (bufferWindowMs !== undefined && bufferWindowMs > 0)
199
- && (sourceType !== 'whep' && sourceType !== 'webrtc');
211
+ let allowMediaStreamDvr = $derived(
212
+ isMediaStreamSource(video) &&
213
+ bufferWindowMs !== undefined &&
214
+ bufferWindowMs > 0 &&
215
+ sourceType !== "whep" &&
216
+ sourceType !== "webrtc"
217
+ );
200
218
 
201
219
  // Seekable range using core calculation (allow player override)
202
- let seekableRange = $derived.by(() => getPlayerSeekableRange() ?? calculateSeekableRange({
203
- isLive,
204
- video,
205
- mistStreamInfo,
206
- currentTime,
207
- duration,
208
- allowMediaStreamDvr,
209
- }));
220
+ let seekableRange = $derived.by(
221
+ () =>
222
+ getPlayerSeekableRange() ??
223
+ calculateSeekableRange({
224
+ isLive,
225
+ video,
226
+ mistStreamInfo,
227
+ currentTime,
228
+ duration,
229
+ allowMediaStreamDvr,
230
+ })
231
+ );
210
232
  let seekableStart = $derived(seekableRange.seekableStart);
211
233
  let liveEdge = $derived(seekableRange.liveEdge);
212
- let hasDvrWindow = $derived(isLive && Number.isFinite(liveEdge) && Number.isFinite(seekableStart) && liveEdge > seekableStart);
234
+ let hasDvrWindow = $derived(
235
+ isLive &&
236
+ Number.isFinite(liveEdge) &&
237
+ Number.isFinite(seekableStart) &&
238
+ liveEdge > seekableStart
239
+ );
213
240
  let commitOnRelease = $derived(isLive);
214
241
 
215
242
  // Live thresholds with buffer window scaling
@@ -219,7 +246,7 @@
219
246
  let baseCanSeek = $derived.by(() => {
220
247
  // Check if current player has canSeek method
221
248
  const player = globalPlayerManager.getCurrentPlayer();
222
- if (player && typeof (player as any).canSeek === 'function') {
249
+ if (player && typeof (player as any).canSeek === "function") {
223
250
  return (player as any).canSeek();
224
251
  }
225
252
  // Fallback to core utility logic
@@ -249,7 +276,9 @@
249
276
  function updateFullscreenState() {
250
277
  isFullscreen = !!document.fullscreenElement;
251
278
  }
252
- function updatePlaybackRate() { playbackRate = video!.playbackRate; }
279
+ function updatePlaybackRate() {
280
+ playbackRate = video!.playbackRate;
281
+ }
253
282
  function updateBuffered() {
254
283
  const player = globalPlayerManager.getCurrentPlayer();
255
284
  buffered = player?.getBufferedRanges?.() ?? video!.buffered;
@@ -261,24 +290,24 @@
261
290
  updatePlaybackRate();
262
291
  updateBuffered();
263
292
 
264
- video.addEventListener('play', updatePlayingState);
265
- video.addEventListener('pause', updatePlayingState);
266
- video.addEventListener('playing', updatePlayingState);
267
- video.addEventListener('volumechange', updateMutedState);
268
- video.addEventListener('ratechange', updatePlaybackRate);
269
- video.addEventListener('progress', updateBuffered);
270
- video.addEventListener('loadeddata', updateBuffered);
271
- document.addEventListener('fullscreenchange', updateFullscreenState);
293
+ video.addEventListener("play", updatePlayingState);
294
+ video.addEventListener("pause", updatePlayingState);
295
+ video.addEventListener("playing", updatePlayingState);
296
+ video.addEventListener("volumechange", updateMutedState);
297
+ video.addEventListener("ratechange", updatePlaybackRate);
298
+ video.addEventListener("progress", updateBuffered);
299
+ video.addEventListener("loadeddata", updateBuffered);
300
+ document.addEventListener("fullscreenchange", updateFullscreenState);
272
301
 
273
302
  return () => {
274
- video!.removeEventListener('play', updatePlayingState);
275
- video!.removeEventListener('pause', updatePlayingState);
276
- video!.removeEventListener('playing', updatePlayingState);
277
- video!.removeEventListener('volumechange', updateMutedState);
278
- video!.removeEventListener('ratechange', updatePlaybackRate);
279
- video!.removeEventListener('progress', updateBuffered);
280
- video!.removeEventListener('loadeddata', updateBuffered);
281
- document.removeEventListener('fullscreenchange', updateFullscreenState);
303
+ video!.removeEventListener("play", updatePlayingState);
304
+ video!.removeEventListener("pause", updatePlayingState);
305
+ video!.removeEventListener("playing", updatePlayingState);
306
+ video!.removeEventListener("volumechange", updateMutedState);
307
+ video!.removeEventListener("ratechange", updatePlaybackRate);
308
+ video!.removeEventListener("progress", updateBuffered);
309
+ video!.removeEventListener("loadeddata", updateBuffered);
310
+ document.removeEventListener("fullscreenchange", updateFullscreenState);
282
311
  };
283
312
  });
284
313
 
@@ -299,14 +328,16 @@
299
328
  });
300
329
 
301
330
  // Time display - using core formatTimeDisplay
302
- let timeDisplay = $derived(formatTimeDisplay({
303
- isLive,
304
- currentTime,
305
- duration,
306
- liveEdge,
307
- seekableStart,
308
- unixoffset: mistStreamInfo?.unixoffset,
309
- }));
331
+ let timeDisplay = $derived(
332
+ formatTimeDisplay({
333
+ isLive,
334
+ currentTime,
335
+ duration,
336
+ liveEdge,
337
+ seekableStart,
338
+ unixoffset: mistStreamInfo?.unixoffset,
339
+ })
340
+ );
310
341
 
311
342
  // Seek value for slider
312
343
  let _seekValue = $derived.by(() => {
@@ -358,36 +389,42 @@
358
389
  function handleMute() {
359
390
  if (disabled) return;
360
391
  const player = globalPlayerManager.getCurrentPlayer();
361
- const v = player?.getVideoElement?.() ?? video;
362
- if (!v) return;
363
- const nextMuted = !(player?.isMuted?.() ?? v.muted);
364
- player?.setMuted?.(nextMuted);
365
- v.muted = nextMuted;
366
- isMuted = nextMuted;
367
- if (nextMuted) {
368
- volumeValue = 0;
392
+ if (player?.setMuted) {
393
+ const currentlyMuted = player.isMuted?.() ?? video?.muted ?? false;
394
+ player.setMuted(!currentlyMuted);
369
395
  } else {
370
- volumeValue = Math.round((Number.isFinite(v.volume) ? v.volume : 1) * 100);
396
+ // Fallback: direct video manipulation
397
+ const v = video;
398
+ if (!v) return;
399
+ v.muted = !v.muted;
371
400
  }
372
401
  }
373
402
 
374
403
  function handleVolumeChange(val: number) {
375
404
  if (disabled) return;
376
- const player = globalPlayerManager.getCurrentPlayer();
377
- const v = player?.getVideoElement?.() ?? video;
378
- if (!v) return;
379
- // Validate: clamp to 0-100, handle NaN/Infinity (matches React implementation)
380
405
  const next = Math.max(0, Math.min(100, val ?? 100));
381
406
  if (!Number.isFinite(next)) return;
382
- v.volume = next / 100;
383
- v.muted = next === 0;
407
+
408
+ const player = globalPlayerManager.getCurrentPlayer();
409
+ if (player?.setVolume) {
410
+ // Use core controller which handles mute/unmute logic
411
+ player.setVolume(next / 100);
412
+ } else {
413
+ // Fallback: direct video manipulation
414
+ const v = video;
415
+ if (!v) return;
416
+ v.volume = next / 100;
417
+ v.muted = next === 0;
418
+ }
384
419
  volumeValue = next;
385
420
  isMuted = next === 0;
386
421
  }
387
422
 
388
423
  function handleFullscreen() {
389
424
  if (disabled) return;
390
- const container = document.querySelector('[data-player-container="true"]') as HTMLElement | null;
425
+ const container = document.querySelector(
426
+ '[data-player-container="true"]'
427
+ ) as HTMLElement | null;
391
428
  if (!container) return;
392
429
  if (document.fullscreenElement) {
393
430
  document.exitFullscreen().catch(() => {});
@@ -424,7 +461,7 @@
424
461
  function handleCaptionChange(value: string) {
425
462
  if (disabled) return;
426
463
  captionValue = value;
427
- if (value === 'none') {
464
+ if (value === "none") {
428
465
  globalPlayerManager.getCurrentPlayer()?.selectTextTrack?.(null);
429
466
  } else {
430
467
  globalPlayerManager.getCurrentPlayer()?.selectTextTrack?.(value);
@@ -432,35 +469,57 @@
432
469
  showSettingsMenu = false;
433
470
  }
434
471
 
472
+ // Non-passive wheel listener for volume control
473
+ $effect(() => {
474
+ if (!volumeGroupRef) return;
475
+ const handler = (e: WheelEvent) => {
476
+ if (disabled || !hasAudio) return;
477
+ e.preventDefault();
478
+ const delta = e.deltaY < 0 ? 5 : -5;
479
+ handleVolumeChange(volumeValue + delta);
480
+ };
481
+ volumeGroupRef.addEventListener("wheel", handler, { passive: false });
482
+ return () => volumeGroupRef?.removeEventListener("wheel", handler);
483
+ });
484
+
435
485
  // Close menu when clicking outside - with debounce to prevent immediate close from same click
436
486
  $effect(() => {
437
487
  if (!showSettingsMenu) return;
438
488
 
439
489
  const handleClick = (e: MouseEvent) => {
440
490
  const target = e.target as HTMLElement;
441
- if (!target.closest('.fw-settings-menu')) {
491
+ if (!target.closest(".fw-settings-menu")) {
442
492
  showSettingsMenu = false;
443
493
  }
444
494
  };
445
495
 
446
496
  // Debounce to prevent immediate close from the same click that opened the menu
447
497
  const timeout = setTimeout(() => {
448
- window.addEventListener('click', handleClick);
498
+ window.addEventListener("click", handleClick);
449
499
  }, 0);
450
500
 
451
501
  return () => {
452
502
  clearTimeout(timeout);
453
- window.removeEventListener('click', handleClick);
503
+ window.removeEventListener("click", handleClick);
454
504
  };
455
505
  });
456
506
  </script>
457
507
 
458
- <div class={cn(
459
- 'fw-player-surface fw-controls-wrapper',
460
- isVisible ? 'fw-controls-wrapper--visible' : 'fw-controls-wrapper--hidden'
461
- )}>
462
- <!-- Control bar -->
463
- <div class="fw-control-bar pointer-events-auto" onclick={(e) => e.stopPropagation()}>
508
+ <div
509
+ class={cn(
510
+ "fw-player-surface fw-controls-wrapper",
511
+ isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
512
+ )}
513
+ >
514
+ <!-- Control bar -->
515
+ <div
516
+ class="fw-control-bar pointer-events-auto"
517
+ role="toolbar"
518
+ aria-label="Media controls"
519
+ tabindex="-1"
520
+ onclick={(e) => e.stopPropagation()}
521
+ onkeydown={(e) => e.stopPropagation()}
522
+ >
464
523
  <!-- Seek bar -->
465
524
  {#if canSeek}
466
525
  <div class="fw-seek-wrapper">
@@ -469,7 +528,7 @@
469
528
  {duration}
470
529
  {buffered}
471
530
  {disabled}
472
- isLive={isLive}
531
+ {isLive}
473
532
  {seekableStart}
474
533
  {liveEdge}
475
534
  {commitOnRelease}
@@ -485,11 +544,17 @@
485
544
  {/if}
486
545
 
487
546
  <!-- Control buttons -->
488
- <div class="fw-controls-row">
547
+ <div class="fw-controls-row">
489
548
  <!-- Left: Play, Skip, Volume, Time, Live -->
490
549
  <div class="fw-controls-left">
491
550
  <div class="fw-control-group">
492
- <button type="button" class="fw-btn-flush" aria-label={isPlaying ? 'Pause' : 'Play'} onclick={handlePlayPause} disabled={disabled}>
551
+ <button
552
+ type="button"
553
+ class="fw-btn-flush"
554
+ aria-label={isPlaying ? "Pause" : "Play"}
555
+ onclick={handlePlayPause}
556
+ {disabled}
557
+ >
493
558
  {#if isPlaying}
494
559
  <PauseIcon size={18} />
495
560
  {:else}
@@ -497,10 +562,22 @@
497
562
  {/if}
498
563
  </button>
499
564
  {#if canSeek}
500
- <button type="button" class="fw-btn-flush hidden sm:flex" aria-label="Skip back 10s" onclick={handleSkipBack} disabled={disabled}>
565
+ <button
566
+ type="button"
567
+ class="fw-btn-flush hidden sm:flex"
568
+ aria-label="Skip back 10s"
569
+ onclick={handleSkipBack}
570
+ {disabled}
571
+ >
501
572
  <SkipBackIcon size={16} />
502
573
  </button>
503
- <button type="button" class="fw-btn-flush hidden sm:flex" aria-label="Skip forward 10s" onclick={handleSkipForward} disabled={disabled}>
574
+ <button
575
+ type="button"
576
+ class="fw-btn-flush hidden sm:flex"
577
+ aria-label="Skip forward 10s"
578
+ onclick={handleSkipForward}
579
+ {disabled}
580
+ >
504
581
  <SkipForwardIcon size={16} />
505
582
  </button>
506
583
  {/if}
@@ -508,38 +585,42 @@
508
585
 
509
586
  <!-- Volume -->
510
587
  <div
588
+ bind:this={volumeGroupRef}
511
589
  class={cn(
512
- 'fw-volume-group',
513
- isVolumeExpanded && 'fw-volume-group--expanded',
514
- !hasAudio && 'fw-volume-group--disabled'
590
+ "fw-volume-group",
591
+ isVolumeExpanded && "fw-volume-group--expanded",
592
+ !hasAudio && "fw-volume-group--disabled"
515
593
  )}
594
+ role="group"
595
+ aria-label="Volume controls"
516
596
  onmouseenter={() => hasAudio && (isVolumeHovered = true)}
517
- onmouseleave={() => { isVolumeHovered = false; isVolumeFocused = false; }}
518
- onfocuscapture={() => hasAudio && (isVolumeFocused = true)}
519
- onblurcapture={(e) => {
520
- if (!e.currentTarget.contains(e.relatedTarget as Node)) isVolumeFocused = false;
597
+ onmouseleave={() => {
598
+ isVolumeHovered = false;
599
+ isVolumeFocused = false;
521
600
  }}
522
- onclick={(e) => {
523
- if (disabled) return;
524
- if (hasAudio && e.target === e.currentTarget) {
525
- handleMute();
526
- }
601
+ onfocusin={() => hasAudio && (isVolumeFocused = true)}
602
+ onfocusout={(e) => {
603
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) isVolumeFocused = false;
527
604
  }}
528
605
  >
529
606
  <button
530
607
  type="button"
531
608
  class="fw-volume-btn"
532
- aria-label={!hasAudio ? 'No audio' : (isMuted ? 'Unmute' : 'Mute')}
533
- title={!hasAudio ? 'No audio' : (isMuted ? 'Unmute' : 'Mute')}
609
+ aria-label={!hasAudio ? "No audio" : isMuted ? "Unmute" : "Mute"}
610
+ title={!hasAudio ? "No audio" : isMuted ? "Unmute" : "Mute"}
534
611
  onclick={handleMute}
535
612
  disabled={!hasAudio}
536
613
  >
537
- <VolumeIcons isMuted={isMuted} volume={volumeValue / 100} size={16} />
614
+ <VolumeIcons {isMuted} volume={volumeValue / 100} size={16} />
538
615
  </button>
539
- <div class={cn(
540
- 'fw-volume-slider-wrapper',
541
- isVolumeExpanded ? 'fw-volume-slider-wrapper--expanded' : 'fw-volume-slider-wrapper--collapsed'
542
- )}>
616
+ <div
617
+ class={cn(
618
+ "fw-volume-slider-wrapper",
619
+ isVolumeExpanded
620
+ ? "fw-volume-slider-wrapper--expanded"
621
+ : "fw-volume-slider-wrapper--collapsed"
622
+ )}
623
+ >
543
624
  <Slider
544
625
  min={0}
545
626
  max={100}
@@ -567,10 +648,14 @@
567
648
  onclick={handleGoLive}
568
649
  disabled={!hasDvrWindow || isNearLiveState}
569
650
  class={cn(
570
- 'fw-live-badge',
571
- (!hasDvrWindow || isNearLiveState) ? 'fw-live-badge--active' : 'fw-live-badge--behind'
651
+ "fw-live-badge",
652
+ !hasDvrWindow || isNearLiveState ? "fw-live-badge--active" : "fw-live-badge--behind"
572
653
  )}
573
- title={!hasDvrWindow ? 'Live only' : (isNearLiveState ? 'At live edge' : 'Jump to live')}
654
+ title={!hasDvrWindow
655
+ ? "Live only"
656
+ : isNearLiveState
657
+ ? "At live edge"
658
+ : "Jump to live"}
574
659
  >
575
660
  LIVE
576
661
  {#if !isNearLiveState && hasDvrWindow}
@@ -587,11 +672,11 @@
587
672
  <div class="fw-control-group">
588
673
  <button
589
674
  type="button"
590
- class={cn('fw-btn-flush', isStatsOpen && 'fw-btn-flush--active')}
675
+ class={cn("fw-btn-flush", isStatsOpen && "fw-btn-flush--active")}
591
676
  aria-label="Toggle stats"
592
677
  title="Stats"
593
678
  onclick={onStatsToggle}
594
- disabled={disabled}
679
+ {disabled}
595
680
  >
596
681
  <StatsIcon size={16} />
597
682
  </button>
@@ -600,11 +685,11 @@
600
685
  <div class="fw-control-group relative">
601
686
  <button
602
687
  type="button"
603
- class={cn('fw-btn-flush group', showSettingsMenu && 'fw-btn-flush--active')}
688
+ class={cn("fw-btn-flush group", showSettingsMenu && "fw-btn-flush--active")}
604
689
  aria-label="Settings"
605
690
  title="Settings"
606
- onclick={() => showSettingsMenu = !showSettingsMenu}
607
- disabled={disabled}
691
+ onclick={() => (showSettingsMenu = !showSettingsMenu)}
692
+ {disabled}
608
693
  >
609
694
  <SettingsIcon size={16} class="transition-transform group-hover:rotate-90" />
610
695
  </button>
@@ -616,16 +701,19 @@
616
701
  <div class="fw-settings-section">
617
702
  <div class="fw-settings-label">Mode</div>
618
703
  <div class="fw-settings-options">
619
- {#each ['auto', 'low-latency', 'quality'] as mode}
704
+ {#each ["auto", "low-latency", "quality"] as mode}
620
705
  <button
621
706
  type="button"
622
707
  class={cn(
623
- 'fw-settings-btn',
624
- playbackMode === mode && 'fw-settings-btn--active'
708
+ "fw-settings-btn",
709
+ playbackMode === mode && "fw-settings-btn--active"
625
710
  )}
626
- onclick={() => { onModeChange(mode as PlaybackMode); showSettingsMenu = false; }}
711
+ onclick={() => {
712
+ onModeChange(mode as PlaybackMode);
713
+ showSettingsMenu = false;
714
+ }}
627
715
  >
628
- {mode === 'low-latency' ? 'Fast' : mode === 'quality' ? 'Stable' : 'Auto'}
716
+ {mode === "low-latency" ? "Fast" : mode === "quality" ? "Stable" : "Auto"}
629
717
  </button>
630
718
  {/each}
631
719
  </div>
@@ -641,11 +729,11 @@
641
729
  <button
642
730
  type="button"
643
731
  class={cn(
644
- 'fw-settings-btn',
645
- playbackRate === rate && 'fw-settings-btn--active'
732
+ "fw-settings-btn",
733
+ playbackRate === rate && "fw-settings-btn--active"
646
734
  )}
647
735
  onclick={() => handleSpeedSelect(rate)}
648
- disabled={disabled}
736
+ {disabled}
649
737
  >
650
738
  {rate}x
651
739
  </button>
@@ -661,18 +749,18 @@
661
749
  <div class="fw-settings-list">
662
750
  <button
663
751
  class={cn(
664
- 'fw-settings-list-item',
665
- qualityValue === 'auto' && 'fw-settings-list-item--active'
752
+ "fw-settings-list-item",
753
+ qualityValue === "auto" && "fw-settings-list-item--active"
666
754
  )}
667
- onclick={() => handleQualityChange('auto')}
755
+ onclick={() => handleQualityChange("auto")}
668
756
  >
669
757
  Auto
670
758
  </button>
671
759
  {#each qualities as q}
672
760
  <button
673
761
  class={cn(
674
- 'fw-settings-list-item',
675
- qualityValue === q.id && 'fw-settings-list-item--active'
762
+ "fw-settings-list-item",
763
+ qualityValue === q.id && "fw-settings-list-item--active"
676
764
  )}
677
765
  onclick={() => handleQualityChange(q.id)}
678
766
  >
@@ -690,18 +778,18 @@
690
778
  <div class="fw-settings-list">
691
779
  <button
692
780
  class={cn(
693
- 'fw-settings-list-item',
694
- captionValue === 'none' && 'fw-settings-list-item--active'
781
+ "fw-settings-list-item",
782
+ captionValue === "none" && "fw-settings-list-item--active"
695
783
  )}
696
- onclick={() => handleCaptionChange('none')}
784
+ onclick={() => handleCaptionChange("none")}
697
785
  >
698
786
  Off
699
787
  </button>
700
788
  {#each textTracks as t}
701
789
  <button
702
790
  class={cn(
703
- 'fw-settings-list-item',
704
- captionValue === t.id && 'fw-settings-list-item--active'
791
+ "fw-settings-list-item",
792
+ captionValue === t.id && "fw-settings-list-item--active"
705
793
  )}
706
794
  onclick={() => handleCaptionChange(t.id)}
707
795
  >
@@ -715,15 +803,22 @@
715
803
  {/if}
716
804
  </div>
717
805
 
718
- <div class="fw-control-group">
719
- <button type="button" class="fw-btn-flush" aria-label="Toggle fullscreen" title="Fullscreen" onclick={handleFullscreen} disabled={disabled}>
720
- {#if isFullscreen}
721
- <FullscreenExitIcon size={16} />
722
- {:else}
723
- <FullscreenIcon size={16} />
724
- {/if}
725
- </button>
726
- </div>
806
+ <div class="fw-control-group">
807
+ <button
808
+ type="button"
809
+ class="fw-btn-flush"
810
+ aria-label="Toggle fullscreen"
811
+ title="Fullscreen"
812
+ onclick={handleFullscreen}
813
+ {disabled}
814
+ >
815
+ {#if isFullscreen}
816
+ <FullscreenExitIcon size={16} />
817
+ {:else}
818
+ <FullscreenIcon size={16} />
819
+ {/if}
820
+ </button>
821
+ </div>
727
822
  </div>
728
823
  </div>
729
824
  </div>