@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
@@ -2,11 +2,7 @@
2
2
  import { ContextMenu } from "bits-ui";
3
3
  import { cn } from "@livepeer-frameworks/player-core";
4
4
 
5
- let {
6
- children,
7
- class: className,
8
- ...rest
9
- }: { children?: any; class?: string } = $props();
5
+ let { children, class: className, ...rest }: { children?: any; class?: string } = $props();
10
6
  </script>
11
7
 
12
8
  <ContextMenu.SubContent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livepeer-frameworks/player-svelte",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Svelte 5 components for FrameWorks streaming player",
6
6
  "svelte": "./dist/index.js",
@@ -13,6 +13,7 @@
13
13
  ],
14
14
  "exports": {
15
15
  ".": {
16
+ "source": "./src/index.ts",
16
17
  "svelte": "./dist/index.js",
17
18
  "import": "./dist/index.js",
18
19
  "types": "./dist/index.d.ts"
@@ -22,22 +23,29 @@
22
23
  "scripts": {
23
24
  "build": "svelte-package -i src -o dist",
24
25
  "build:watch": "svelte-package -i src -o dist --watch",
25
- "type-check": "svelte-check"
26
+ "type-check": "svelte-check",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "test:coverage": "vitest run --coverage"
26
30
  },
27
31
  "dependencies": {
28
32
  "@livepeer-frameworks/player-core": "workspace:*",
29
- "bits-ui": "^2.14.4"
33
+ "bits-ui": "^2.15.5"
30
34
  },
31
35
  "peerDependencies": {
32
36
  "svelte": "^5.0.0"
33
37
  },
34
38
  "devDependencies": {
35
39
  "@sveltejs/package": "^2.3.7",
36
- "@sveltejs/vite-plugin-svelte": "^4.0.0",
37
- "svelte": "^5.33.5",
38
- "svelte-check": "^4.0.0",
40
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
41
+ "@testing-library/svelte": "^5.2.7",
42
+ "@vitest/coverage-v8": "^4.0.18",
43
+ "jsdom": "^28.0.0",
44
+ "svelte": "^5.50.0",
45
+ "svelte-check": "^4.3.6",
39
46
  "typescript": "^5.9.2",
40
- "vite": "^5.4.0"
47
+ "vite": "^7.3.1",
48
+ "vitest": "^4.0.18"
41
49
  },
42
50
  "keywords": [
43
51
  "svelte",
@@ -385,6 +385,7 @@
385
385
  {#if shouldShow}
386
386
  <div
387
387
  class="fw-dev-combo"
388
+ role="listitem"
388
389
  onmouseenter={(e) => handleComboHover(index, e)}
389
390
  onmouseleave={() => (hoveredComboIndex = null)}
390
391
  >
package/src/Icons.svelte CHANGED
@@ -10,7 +10,7 @@
10
10
  <PlayIcon size={24} />
11
11
  <VolumeIcon isMuted={false} />
12
12
  -->
13
- <script context="module" lang="ts">
13
+ <script module lang="ts">
14
14
  export interface IconProps {
15
15
  size?: number;
16
16
  color?: string;
@@ -19,9 +19,11 @@
19
19
  </script>
20
20
 
21
21
  <script lang="ts">
22
- // This file exports components via context="module" script
22
+ import type { Snippet } from "svelte";
23
+ // This file exports components via module script
23
24
  // The default export is a placeholder - use the named exports
25
+ let { children }: { children?: Snippet } = $props();
24
26
  </script>
25
27
 
26
28
  <!-- This component itself is not rendered - use the exported icon components below -->
27
- <slot />
29
+ {@render children?.()}
@@ -387,7 +387,7 @@
387
387
  animation-duration: {particle.duration}s;
388
388
  animation-delay: {particle.delay}s;
389
389
  "
390
- />
390
+ ></div>
391
391
  {/each}
392
392
 
393
393
  <!-- Animated bubbles -->
@@ -402,7 +402,7 @@
402
402
  background: {bubble.color};
403
403
  opacity: {bubble.opacity};
404
404
  "
405
- />
405
+ ></div>
406
406
  {/each}
407
407
 
408
408
  <!-- Center logo with push-away effect -->
@@ -415,18 +415,19 @@
415
415
  class="logo-pulse"
416
416
  class:hovered={isHovered}
417
417
  style="width: {logoSize * 1.4}px; height: {logoSize * 1.4}px;"
418
- />
418
+ ></div>
419
419
 
420
420
  <!-- Logo image -->
421
- <img
422
- src={logomarkAsset}
423
- alt="Logo"
424
- class="logo-image"
425
- class:hovered={isHovered}
426
- style="width: {logoSize}px; height: {logoSize}px;"
427
- onclick={handleLogoClick}
428
- draggable="false"
429
- />
421
+ <button type="button" class="logo-button" onclick={handleLogoClick} aria-label="Logo">
422
+ <img
423
+ src={logomarkAsset}
424
+ alt=""
425
+ class="logo-image"
426
+ class:hovered={isHovered}
427
+ style="width: {logoSize}px; height: {logoSize}px;"
428
+ draggable="false"
429
+ />
430
+ </button>
430
431
  </div>
431
432
 
432
433
  <!-- Bouncing DVD Logo -->
@@ -515,7 +516,7 @@
515
516
  <!-- Progress bar -->
516
517
  {#if showProgress}
517
518
  <div class="progress-bar">
518
- <div class="progress-fill" style="width: {Math.min(100, percentage ?? 0)}%;" />
519
+ <div class="progress-fill" style="width: {Math.min(100, percentage ?? 0)};"></div>
519
520
  </div>
520
521
  {/if}
521
522
 
@@ -526,7 +527,7 @@
526
527
  </div>
527
528
 
528
529
  <!-- Subtle overlay texture -->
529
- <div class="overlay-texture" />
530
+ <div class="overlay-texture"></div>
530
531
  </div>
531
532
 
532
533
  <style>
@@ -686,6 +687,12 @@
686
687
  transform: scale(1.2);
687
688
  }
688
689
 
690
+ .logo-button {
691
+ all: unset;
692
+ cursor: pointer;
693
+ display: block;
694
+ }
695
+
689
696
  .logo-image {
690
697
  position: relative;
691
698
  z-index: 1;
@@ -387,7 +387,7 @@
387
387
  animation-duration: {particle.duration}s;
388
388
  animation-delay: {particle.delay}s;
389
389
  "
390
- />
390
+ ></div>
391
391
  {/each}
392
392
 
393
393
  <!-- Animated bubbles -->
@@ -402,7 +402,7 @@
402
402
  background: {bubble.color};
403
403
  opacity: {bubble.opacity};
404
404
  "
405
- />
405
+ ></div>
406
406
  {/each}
407
407
 
408
408
  <!-- Center logo with push-away effect -->
@@ -415,18 +415,19 @@
415
415
  class="logo-pulse"
416
416
  class:hovered={isHovered}
417
417
  style="width: {logoSize * 1.4}px; height: {logoSize * 1.4}px;"
418
- />
418
+ ></div>
419
419
 
420
420
  <!-- Logo image -->
421
- <img
422
- src={effectiveLogoSrc}
423
- alt="Logo"
424
- class="logo-image"
425
- class:hovered={isHovered}
426
- style="width: {logoSize}px; height: {logoSize}px;"
427
- onclick={handleLogoClick}
428
- draggable="false"
429
- />
421
+ <button type="button" class="logo-button" onclick={handleLogoClick} aria-label="Logo">
422
+ <img
423
+ src={effectiveLogoSrc}
424
+ alt=""
425
+ class="logo-image"
426
+ class:hovered={isHovered}
427
+ style="width: {logoSize}px; height: {logoSize}px;"
428
+ draggable="false"
429
+ />
430
+ </button>
430
431
  </div>
431
432
 
432
433
  <!-- Bouncing DVD Logo -->
@@ -438,7 +439,7 @@
438
439
  </div>
439
440
 
440
441
  <!-- Subtle overlay texture -->
441
- <div class="overlay-texture" />
442
+ <div class="overlay-texture"></div>
442
443
  </div>
443
444
 
444
445
  <style>
@@ -605,6 +606,12 @@
605
606
  transform: scale(1.2);
606
607
  }
607
608
 
609
+ .logo-button {
610
+ all: unset;
611
+ cursor: pointer;
612
+ display: block;
613
+ }
614
+
608
615
  .logo-image {
609
616
  position: relative;
610
617
  z-index: 1;
package/src/Player.svelte CHANGED
@@ -80,7 +80,12 @@
80
80
  let skipDirection: SkipDirection = $state(null);
81
81
 
82
82
  // Playback mode preference (persistent)
83
- let devPlaybackMode: PlaybackMode = $state(options?.playbackMode || "auto");
83
+ let devPlaybackMode: PlaybackMode = $state("auto");
84
+ $effect(() => {
85
+ if (options?.playbackMode) {
86
+ devPlaybackMode = options.playbackMode;
87
+ }
88
+ });
84
89
 
85
90
  // Container ref
86
91
  let containerRef: HTMLElement | undefined = $state();
@@ -119,6 +124,7 @@
119
124
  currentSourceInfo: null as { url: string; type: string } | null,
120
125
  playbackQuality: null as any,
121
126
  subtitlesEnabled: false,
127
+ toast: null as { message: string; timestamp: number } | null,
122
128
  });
123
129
 
124
130
  // Track if we've already attached to prevent double-attach race
@@ -197,6 +203,15 @@
197
203
  }
198
204
  });
199
205
 
206
+ // Auto-dismiss toast after 3 seconds
207
+ $effect(() => {
208
+ if (!storeState.toast) return;
209
+ const timer = setTimeout(() => {
210
+ playerStore?.dismissToast();
211
+ }, 3000);
212
+ return () => clearTimeout(timer);
213
+ });
214
+
200
215
  // ============================================================================
201
216
  // Dev Mode Callbacks
202
217
  // ============================================================================
@@ -277,7 +292,8 @@
277
292
  options?.devMode && "flex"
278
293
  )}
279
294
  data-player-container="true"
280
- tabindex="0"
295
+ role="region"
296
+ aria-label="Video player"
281
297
  onmouseenter={() => playerStore?.handleMouseEnter()}
282
298
  onmouseleave={() => playerStore?.handleMouseLeave()}
283
299
  onmousemove={() => playerStore?.handleMouseMove()}
@@ -461,6 +477,36 @@
461
477
  </div>
462
478
  {/if}
463
479
 
480
+ <!-- Toast notification -->
481
+ {#if storeState.toast}
482
+ <div
483
+ class="absolute bottom-20 left-1/2 -translate-x-1/2 z-30 animate-in fade-in slide-in-from-bottom-2 duration-200"
484
+ role="status"
485
+ aria-live="polite"
486
+ >
487
+ <div
488
+ class="flex items-center gap-2 rounded-lg border border-white/10 bg-black/80 px-4 py-2 text-sm text-white shadow-lg backdrop-blur-sm"
489
+ >
490
+ <span>{storeState.toast.message}</span>
491
+ <button
492
+ type="button"
493
+ onclick={() => playerStore?.dismissToast()}
494
+ class="ml-2 text-white/60 hover:text-white"
495
+ aria-label="Dismiss"
496
+ >
497
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
498
+ <path
499
+ d="M9 3L3 9M3 3L9 9"
500
+ stroke="currentColor"
501
+ stroke-width="1.5"
502
+ stroke-linecap="round"
503
+ />
504
+ </svg>
505
+ </button>
506
+ </div>
507
+ </div>
508
+ {/if}
509
+
464
510
  <!-- Player controls -->
465
511
  {#if !useStockControls}
466
512
  <PlayerControls
@@ -161,6 +161,7 @@
161
161
  let isVolumeHovered = $state(false);
162
162
  let isVolumeFocused = $state(false);
163
163
  let isVolumeExpanded = $derived(isVolumeHovered || isVolumeFocused);
164
+ let volumeGroupRef: HTMLDivElement | null = $state(null);
164
165
 
165
166
  // Derived values - using centralized core utilities
166
167
  let isLive = $derived(isLiveContent(isContentLive, mistStreamInfo, duration));
@@ -207,12 +208,13 @@
207
208
  return null;
208
209
  }
209
210
 
210
- const allowMediaStreamDvr =
211
+ let allowMediaStreamDvr = $derived(
211
212
  isMediaStreamSource(video) &&
212
- bufferWindowMs !== undefined &&
213
- bufferWindowMs > 0 &&
214
- sourceType !== "whep" &&
215
- sourceType !== "webrtc";
213
+ bufferWindowMs !== undefined &&
214
+ bufferWindowMs > 0 &&
215
+ sourceType !== "whep" &&
216
+ sourceType !== "webrtc"
217
+ );
216
218
 
217
219
  // Seekable range using core calculation (allow player override)
218
220
  let seekableRange = $derived.by(
@@ -387,9 +389,9 @@
387
389
  function handleMute() {
388
390
  if (disabled) return;
389
391
  const player = globalPlayerManager.getCurrentPlayer();
390
- if (player?.toggleMute) {
391
- // Use core controller which handles volume restore
392
- player.toggleMute();
392
+ if (player?.setMuted) {
393
+ const currentlyMuted = player.isMuted?.() ?? video?.muted ?? false;
394
+ player.setMuted(!currentlyMuted);
393
395
  } else {
394
396
  // Fallback: direct video manipulation
395
397
  const v = video;
@@ -467,6 +469,19 @@
467
469
  showSettingsMenu = false;
468
470
  }
469
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
+
470
485
  // Close menu when clicking outside - with debounce to prevent immediate close from same click
471
486
  $effect(() => {
472
487
  if (!showSettingsMenu) return;
@@ -497,7 +512,14 @@
497
512
  )}
498
513
  >
499
514
  <!-- Control bar -->
500
- <div class="fw-control-bar pointer-events-auto" onclick={(e) => e.stopPropagation()}>
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
+ >
501
523
  <!-- Seek bar -->
502
524
  {#if canSeek}
503
525
  <div class="fw-seek-wrapper">
@@ -563,26 +585,23 @@
563
585
 
564
586
  <!-- Volume -->
565
587
  <div
588
+ bind:this={volumeGroupRef}
566
589
  class={cn(
567
590
  "fw-volume-group",
568
591
  isVolumeExpanded && "fw-volume-group--expanded",
569
592
  !hasAudio && "fw-volume-group--disabled"
570
593
  )}
594
+ role="group"
595
+ aria-label="Volume controls"
571
596
  onmouseenter={() => hasAudio && (isVolumeHovered = true)}
572
597
  onmouseleave={() => {
573
598
  isVolumeHovered = false;
574
599
  isVolumeFocused = false;
575
600
  }}
576
- onfocuscapture={() => hasAudio && (isVolumeFocused = true)}
577
- onblurcapture={(e) => {
601
+ onfocusin={() => hasAudio && (isVolumeFocused = true)}
602
+ onfocusout={(e) => {
578
603
  if (!e.currentTarget.contains(e.relatedTarget as Node)) isVolumeFocused = false;
579
604
  }}
580
- onclick={(e) => {
581
- if (disabled) return;
582
- if (hasAudio && e.target === e.currentTarget) {
583
- handleMute();
584
- }
585
- }}
586
605
  >
587
606
  <button
588
607
  type="button"
@@ -214,6 +214,38 @@
214
214
  let ariaValueText = $derived(
215
215
  isLive ? formatLiveTime(displayTime, effectiveLiveEdge) : formatTime(displayTime)
216
216
  );
217
+
218
+ // Handle keyboard navigation for accessibility
219
+ function handleKeyDown(e: KeyboardEvent) {
220
+ if (disabled) return;
221
+ const step = e.shiftKey ? 10 : 5; // 5s default, 10s with shift
222
+ const rangeEnd = isLive ? effectiveLiveEdge : duration;
223
+ const rangeStart = isLive ? seekableStart : 0;
224
+
225
+ let newTime: number | null = null;
226
+ switch (e.key) {
227
+ case "ArrowLeft":
228
+ case "ArrowDown":
229
+ newTime = Math.max(rangeStart, currentTime - step);
230
+ break;
231
+ case "ArrowRight":
232
+ case "ArrowUp":
233
+ newTime = Math.min(rangeEnd, currentTime + step);
234
+ break;
235
+ case "Home":
236
+ newTime = rangeStart;
237
+ break;
238
+ case "End":
239
+ newTime = rangeEnd;
240
+ break;
241
+ default:
242
+ return;
243
+ }
244
+ if (newTime !== null) {
245
+ e.preventDefault();
246
+ onseek?.(newTime);
247
+ }
248
+ }
217
249
  </script>
218
250
 
219
251
  <div
@@ -231,6 +263,7 @@
231
263
  onmousemove={handleMouseMove}
232
264
  onclick={handleClick}
233
265
  onmousedown={handleMouseDown}
266
+ onkeydown={handleKeyDown}
234
267
  role="slider"
235
268
  aria-label="Seek"
236
269
  aria-valuemin={isLive ? seekableStart : 0}
@@ -129,7 +129,7 @@
129
129
  {#if showProgress && percentage !== undefined}
130
130
  <div style="margin-top: 0.75rem;">
131
131
  <div class="progress-bar">
132
- <div class="progress-fill" style="width: {Math.min(100, percentage)}%;" />
132
+ <div class="progress-fill" style="width: {Math.min(100, percentage)};"></div>
133
133
  </div>
134
134
  <p
135
135
  style="margin-top: 0.375rem; font-size: 0.75rem; font-family: monospace; color: hsl(var(--tn-fg-dark, 233 23% 60%));"
@@ -158,7 +158,7 @@
158
158
  <!-- Polling indicator for non-error states -->
159
159
  {#if !showRetry}
160
160
  <div class="polling-indicator">
161
- <span class="polling-dot" />
161
+ <span class="polling-dot"></span>
162
162
  <span>Checking stream status...</span>
163
163
  </div>
164
164
  {/if}
@@ -12,6 +12,7 @@ import {
12
12
  type PlaybackQuality,
13
13
  type ContentEndpoints,
14
14
  type ContentMetadata,
15
+ type ClassifiedError,
15
16
  } from "@livepeer-frameworks/player-core";
16
17
 
17
18
  // ============================================================================
@@ -50,6 +51,8 @@ export interface PlayerControllerState {
50
51
  volume: number;
51
52
  /** Error text */
52
53
  error: string | null;
54
+ /** Error details for debugging */
55
+ errorDetails: ClassifiedError["details"] | null;
53
56
  /** Is passive error */
54
57
  isPassiveError: boolean;
55
58
  /** Has playback ever started */
@@ -80,6 +83,8 @@ export interface PlayerControllerState {
80
83
  playbackQuality: PlaybackQuality | null;
81
84
  /** Subtitles enabled */
82
85
  subtitlesEnabled: boolean;
86
+ /** Toast message to display (auto-dismisses) */
87
+ toast: { message: string; timestamp: number } | null;
83
88
  }
84
89
 
85
90
  export interface PlayerControllerStore extends Readable<PlayerControllerState> {
@@ -115,6 +120,8 @@ export interface PlayerControllerStore extends Readable<PlayerControllerState> {
115
120
  toggleSubtitles: () => void;
116
121
  /** Clear error */
117
122
  clearError: () => void;
123
+ /** Dismiss toast notification */
124
+ dismissToast: () => void;
118
125
  /** Retry playback */
119
126
  retry: () => Promise<void>;
120
127
  /** Reload player */
@@ -158,6 +165,7 @@ const initialState: PlayerControllerState = {
158
165
  isMuted: true,
159
166
  volume: 1,
160
167
  error: null,
168
+ errorDetails: null,
161
169
  isPassiveError: false,
162
170
  hasPlaybackStarted: false,
163
171
  isHoldingSpeed: false,
@@ -173,6 +181,7 @@ const initialState: PlayerControllerState = {
173
181
  currentSourceInfo: null,
174
182
  playbackQuality: null,
175
183
  subtitlesEnabled: false,
184
+ toast: null,
176
185
  };
177
186
 
178
187
  // ============================================================================
@@ -401,6 +410,25 @@ export function createPlayerControllerStore(
401
410
  })
402
411
  );
403
412
 
413
+ // Error handling events - show toasts/modals
414
+ unsubscribers.push(
415
+ controller.on("protocolSwapped", (data) => {
416
+ const message = `Switched to ${data.toProtocol}`;
417
+ store.update((prev) => ({ ...prev, toast: { message, timestamp: Date.now() } }));
418
+ })
419
+ );
420
+
421
+ unsubscribers.push(
422
+ controller.on("playbackFailed", (data) => {
423
+ store.update((prev) => ({
424
+ ...prev,
425
+ error: data.message,
426
+ errorDetails: data.details ?? null,
427
+ isPassiveError: false,
428
+ }));
429
+ })
430
+ );
431
+
404
432
  // Set initial loop state
405
433
  store.update((prev) => ({
406
434
  ...prev,
@@ -483,7 +511,16 @@ export function createPlayerControllerStore(
483
511
 
484
512
  function clearError(): void {
485
513
  controller?.clearError();
486
- store.update((prev) => ({ ...prev, error: null, isPassiveError: false }));
514
+ store.update((prev) => ({
515
+ ...prev,
516
+ error: null,
517
+ errorDetails: null,
518
+ isPassiveError: false,
519
+ }));
520
+ }
521
+
522
+ function dismissToast(): void {
523
+ store.update((prev) => ({ ...prev, toast: null }));
487
524
  }
488
525
 
489
526
  async function retry(): Promise<void> {
@@ -549,6 +586,7 @@ export function createPlayerControllerStore(
549
586
  togglePiP,
550
587
  toggleSubtitles,
551
588
  clearError,
589
+ dismissToast,
552
590
  retry,
553
591
  reload,
554
592
  getQualities,
@@ -151,7 +151,7 @@ export function createEndpointResolver(options: ViewerEndpointsOptions): ViewerE
151
151
  } catch {}
152
152
  }
153
153
  if (fallbacks) {
154
- fallbacks.forEach((fb: any) => {
154
+ fallbacks.forEach((fb: typeof primary) => {
155
155
  if (fb.outputs && typeof fb.outputs === "string") {
156
156
  try {
157
157
  fb.outputs = JSON.parse(fb.outputs);
@@ -1,17 +1,20 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from "svelte";
2
3
  import { cn } from "@livepeer-frameworks/player-core";
3
4
  import { badgeVariants, type BadgeVariant } from "./badge";
4
5
 
5
- type $$Props = {
6
+ type Props = {
6
7
  variant?: BadgeVariant;
7
8
  class?: string;
8
- } & Record<string, any>;
9
+ children?: Snippet;
10
+ [key: string]: unknown;
11
+ };
9
12
 
10
- let { variant = "default", class: className = "", ...rest }: $$Props = $props();
13
+ let { variant = "default", class: className = "", children, ...rest }: Props = $props();
11
14
 
12
15
  let mergedClasses = $derived(cn(badgeVariants(variant, className)));
13
16
  </script>
14
17
 
15
18
  <div class={mergedClasses} {...rest}>
16
- <slot />
19
+ {@render children?.()}
17
20
  </div>