@livepeer-frameworks/player-svelte 0.1.2 → 0.2.1

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 (56) hide show
  1. package/LICENSE.md +24 -0
  2. package/README.md +6 -2
  3. package/dist/DevModePanel.svelte +53 -16
  4. package/dist/IdleScreen.svelte +36 -28
  5. package/dist/LoadingScreen.svelte +107 -67
  6. package/dist/LoadingScreen.svelte.bak +702 -0
  7. package/dist/Player.svelte +200 -53
  8. package/dist/Player.svelte.d.ts +6 -1
  9. package/dist/PlayerControls.svelte +114 -32
  10. package/dist/PlayerControls.svelte.d.ts +3 -0
  11. package/dist/StreamStateOverlay.svelte +33 -21
  12. package/dist/SubtitleRenderer.svelte +2 -2
  13. package/dist/controls/FullscreenButton.svelte +26 -0
  14. package/dist/controls/FullscreenButton.svelte.d.ts +3 -0
  15. package/dist/controls/LiveBadge.svelte +23 -0
  16. package/dist/controls/LiveBadge.svelte.d.ts +3 -0
  17. package/dist/controls/PlayButton.svelte +26 -0
  18. package/dist/controls/PlayButton.svelte.d.ts +3 -0
  19. package/dist/controls/SettingsMenu.svelte +208 -0
  20. package/dist/controls/SettingsMenu.svelte.d.ts +28 -0
  21. package/dist/controls/SkipButton.svelte +33 -0
  22. package/dist/controls/SkipButton.svelte.d.ts +7 -0
  23. package/dist/controls/TimeDisplay.svelte +18 -0
  24. package/dist/controls/TimeDisplay.svelte.d.ts +3 -0
  25. package/dist/controls/VolumeControl.svelte +26 -0
  26. package/dist/controls/VolumeControl.svelte.d.ts +3 -0
  27. package/dist/controls/index.d.ts +7 -0
  28. package/dist/controls/index.js +7 -0
  29. package/dist/index.d.ts +3 -2
  30. package/dist/index.js +3 -1
  31. package/dist/stores/i18n.d.ts +3 -0
  32. package/dist/stores/i18n.js +4 -0
  33. package/dist/stores/index.d.ts +1 -0
  34. package/dist/stores/index.js +2 -0
  35. package/dist/stores/playerController.d.ts +2 -0
  36. package/dist/stores/playerController.js +4 -0
  37. package/package.json +19 -19
  38. package/src/DevModePanel.svelte +53 -16
  39. package/src/IdleScreen.svelte +12 -4
  40. package/src/LoadingScreen.svelte +90 -50
  41. package/src/LoadingScreen.svelte.bak +702 -0
  42. package/src/Player.svelte +200 -53
  43. package/src/PlayerControls.svelte +114 -32
  44. package/src/StreamStateOverlay.svelte +17 -5
  45. package/src/controls/FullscreenButton.svelte +26 -0
  46. package/src/controls/LiveBadge.svelte +23 -0
  47. package/src/controls/PlayButton.svelte +26 -0
  48. package/src/controls/SettingsMenu.svelte +208 -0
  49. package/src/controls/SkipButton.svelte +33 -0
  50. package/src/controls/TimeDisplay.svelte +18 -0
  51. package/src/controls/VolumeControl.svelte +26 -0
  52. package/src/controls/index.ts +7 -0
  53. package/src/index.ts +10 -0
  54. package/src/stores/i18n.ts +7 -0
  55. package/src/stores/index.ts +3 -0
  56. package/src/stores/playerController.ts +7 -0
package/src/Player.svelte CHANGED
@@ -3,7 +3,7 @@
3
3
  Thin wrapper over PlayerController from @livepeer-frameworks/player-core
4
4
  -->
5
5
  <script lang="ts">
6
- import { onMount } from "svelte";
6
+ import { onMount, setContext, type Snippet } from "svelte";
7
7
  import IdleScreen from "./IdleScreen.svelte";
8
8
  import SubtitleRenderer from "./SubtitleRenderer.svelte";
9
9
  import PlayerControls from "./PlayerControls.svelte";
@@ -23,6 +23,8 @@
23
23
  import { StatsIcon, SettingsIcon, PictureInPictureIcon } from "./icons";
24
24
  import {
25
25
  cn,
26
+ themeOverridesToStyle,
27
+ resolveTheme,
26
28
  type PlaybackMode,
27
29
  type ContentEndpoints,
28
30
  type PlayerState,
@@ -30,11 +32,15 @@
30
32
  type ContentType,
31
33
  type EndpointInfo,
32
34
  type PlayerMetadata,
35
+ type FwThemePreset,
36
+ type FwThemeOverrides,
37
+ type FwLocale,
33
38
  } from "@livepeer-frameworks/player-core";
34
39
  import {
35
40
  createPlayerControllerStore,
36
41
  type PlayerControllerStore,
37
42
  } from "./stores/playerController";
43
+ import { localeStore, translatorStore } from "./stores/i18n";
38
44
  import type { SkipDirection } from "./SkipIndicator.svelte";
39
45
 
40
46
  // Props - aligned with React Player
@@ -57,9 +63,13 @@
57
63
  forceType?: string;
58
64
  forceSource?: number;
59
65
  playbackMode?: PlaybackMode;
66
+ theme?: FwThemePreset;
67
+ themeOverrides?: FwThemeOverrides;
68
+ locale?: FwLocale;
60
69
  };
61
70
  onStateChange?: (state: PlayerState, context?: PlayerStateContext) => void;
62
71
  onMetadata?: (metadata: PlayerMetadata) => void;
72
+ children?: Snippet;
63
73
  }
64
74
 
65
75
  let {
@@ -70,6 +80,7 @@
70
80
  options = {},
71
81
  onStateChange = undefined,
72
82
  onMetadata = undefined,
83
+ children = undefined,
73
84
  }: Props = $props();
74
85
 
75
86
  // ============================================================================
@@ -79,6 +90,64 @@
79
90
  let isDevPanelOpen = $state(false);
80
91
  let skipDirection: SkipDirection = $state(null);
81
92
 
93
+ let activeTheme = $state<FwThemePreset>(options?.theme ?? "default");
94
+ let activeLocale = $state<FwLocale>(options?.locale ?? "en");
95
+
96
+ // Sync locale state to i18n store and provide translator context
97
+ $effect(() => {
98
+ localeStore.set(activeLocale);
99
+ });
100
+ setContext("fw-translator", translatorStore);
101
+
102
+ // Provide context for composable controls (reactive getters)
103
+ setContext("fw-player-controller", {
104
+ get isPlaying() {
105
+ return storeState.isPlaying;
106
+ },
107
+ get isPaused() {
108
+ return storeState.isPaused;
109
+ },
110
+ get isMuted() {
111
+ return storeState.isMuted;
112
+ },
113
+ get volume() {
114
+ return storeState.volume;
115
+ },
116
+ get currentTime() {
117
+ return storeState.currentTime;
118
+ },
119
+ get duration() {
120
+ return storeState.duration;
121
+ },
122
+ get isFullscreen() {
123
+ return storeState.isFullscreen;
124
+ },
125
+ get isEffectivelyLive() {
126
+ return storeState.isEffectivelyLive;
127
+ },
128
+ get isBuffering() {
129
+ return storeState.isBuffering;
130
+ },
131
+ get isLoopEnabled() {
132
+ return storeState.isLoopEnabled;
133
+ },
134
+ get error() {
135
+ return storeState.error;
136
+ },
137
+ get qualities() {
138
+ return playerStore?.getQualities() ?? [];
139
+ },
140
+ togglePlay: () => playerStore?.togglePlay(),
141
+ toggleMute: () => playerStore?.toggleMute(),
142
+ toggleFullscreen: () => playerStore?.toggleFullscreen(),
143
+ toggleLoop: () => playerStore?.toggleLoop(),
144
+ setVolume: (v: number) => playerStore?.setVolume(v),
145
+ jumpToLive: () => playerStore?.jumpToLive(),
146
+ seek: (t: number) => playerStore?.seek(t),
147
+ selectQuality: (id: string) => playerStore?.selectQuality(id),
148
+ getQualities: () => playerStore?.getQualities() ?? [],
149
+ });
150
+
82
151
  // Playback mode preference (persistent)
83
152
  let devPlaybackMode: PlaybackMode = $state("auto");
84
153
  $effect(() => {
@@ -87,6 +156,33 @@
87
156
  }
88
157
  });
89
158
 
159
+ // Error fade-out: keep overlay visible while it animates out
160
+ let displayedError: string | null = $state(null);
161
+ let displayedIsPassive = $state(false);
162
+ let isErrorDismissing = $state(false);
163
+ let errorDismissTimer: ReturnType<typeof setTimeout> | null = null;
164
+ $effect(() => {
165
+ const error = storeState.error;
166
+ const passive = storeState.isPassiveError;
167
+ if (error) {
168
+ if (errorDismissTimer) {
169
+ clearTimeout(errorDismissTimer);
170
+ errorDismissTimer = null;
171
+ }
172
+ displayedError = error;
173
+ displayedIsPassive = passive;
174
+ isErrorDismissing = false;
175
+ } else if (displayedError) {
176
+ isErrorDismissing = true;
177
+ errorDismissTimer = setTimeout(() => {
178
+ displayedError = null;
179
+ displayedIsPassive = false;
180
+ isErrorDismissing = false;
181
+ errorDismissTimer = null;
182
+ }, 300);
183
+ }
184
+ });
185
+
90
186
  // Container ref
91
187
  let containerRef: HTMLElement | undefined = $state();
92
188
  let playerRootRef: HTMLDivElement | undefined = $state();
@@ -275,9 +371,9 @@
275
371
  let waitingMessage = $derived(
276
372
  options?.gatewayUrl
277
373
  ? storeState.state === "gateway_loading"
278
- ? "Resolving viewing endpoint..."
279
- : "Waiting for endpoint..."
280
- : "Waiting for endpoint..."
374
+ ? $translatorStore("resolvingEndpoint")
375
+ : $translatorStore("waitingForStream")
376
+ : $translatorStore("waitingForStream")
281
377
  );
282
378
  </script>
283
379
 
@@ -292,6 +388,16 @@
292
388
  options?.devMode && "flex"
293
389
  )}
294
390
  data-player-container="true"
391
+ data-theme={activeTheme && activeTheme !== "default" ? activeTheme : undefined}
392
+ style={(() => {
393
+ const presetOverrides = activeTheme ? resolveTheme(activeTheme) : null;
394
+ const merged = { ...presetOverrides, ...options?.themeOverrides };
395
+ return Object.keys(merged).length > 0
396
+ ? Object.entries(themeOverridesToStyle(merged))
397
+ .map(([k, v]) => `${k}: ${v}`)
398
+ .join("; ")
399
+ : undefined;
400
+ })()}
295
401
  role="region"
296
402
  aria-label="Video player"
297
403
  onmouseenter={() => playerStore?.handleMouseEnter()}
@@ -380,7 +486,7 @@
380
486
  status={storeState.isEffectivelyLive ? storeState.streamState?.status : undefined}
381
487
  message={storeState.isEffectivelyLive
382
488
  ? storeState.streamState?.message
383
- : "Loading video..."}
489
+ : $translatorStore("loading")}
384
490
  percentage={storeState.isEffectivelyLive
385
491
  ? storeState.streamState?.percentage
386
492
  : undefined}
@@ -389,63 +495,72 @@
389
495
 
390
496
  <!-- Buffering spinner -->
391
497
  {#if showBufferingSpinner}
498
+ <div role="status" aria-live="polite" class="fw-buffering-overlay">
499
+ <div class="fw-buffering-pill">
500
+ <div class="fw-buffering-spinner"></div>
501
+ <span>{$translatorStore("buffering")}</span>
502
+ </div>
503
+ </div>
504
+ {/if}
505
+
506
+ <!-- Passive error toast (non-blocking) -->
507
+ {#if displayedError && !storeState.shouldShowIdleScreen && displayedIsPassive}
392
508
  <div
393
- class="absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm z-20"
509
+ class={cn(
510
+ "absolute bottom-20 left-1/2 -translate-x-1/2 z-30 transition-opacity duration-300",
511
+ isErrorDismissing
512
+ ? "opacity-0"
513
+ : "animate-in fade-in slide-in-from-bottom-2 duration-200"
514
+ )}
515
+ role="status"
516
+ aria-live="polite"
394
517
  >
395
518
  <div
396
- class="flex items-center gap-3 rounded-lg border border-white/10 bg-black/70 px-4 py-3 text-sm text-white shadow-lg"
519
+ class="flex items-center gap-2 rounded-lg border border-yellow-500/30 bg-black/80 px-4 py-2 text-sm text-white shadow-lg backdrop-blur-sm"
397
520
  >
398
- <div
399
- class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
400
- ></div>
401
- <span>Buffering...</span>
521
+ <span class="text-yellow-400 text-xs font-semibold uppercase"
522
+ >{$translatorStore("warning")}</span
523
+ >
524
+ <span>{displayedError}</span>
525
+ <button
526
+ type="button"
527
+ onclick={() => playerStore?.clearError()}
528
+ class="ml-2 text-white/60 hover:text-white"
529
+ aria-label={$translatorStore("dismiss")}
530
+ >
531
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
532
+ <path
533
+ d="M9 3L3 9M3 3L9 9"
534
+ stroke="currentColor"
535
+ stroke-width="1.5"
536
+ stroke-linecap="round"
537
+ />
538
+ </svg>
539
+ </button>
402
540
  </div>
403
541
  </div>
404
542
  {/if}
405
543
 
406
- <!-- Error overlay -->
407
- {#if storeState.error && !storeState.shouldShowIdleScreen}
544
+ <!-- Fatal error overlay (blocking) — auto-dismisses on playback resume -->
545
+ {#if displayedError && !storeState.shouldShowIdleScreen && !displayedIsPassive}
408
546
  <div
409
547
  role="alert"
410
548
  aria-live="assertive"
411
549
  class={cn(
412
- "fw-error-overlay",
413
- storeState.isPassiveError
414
- ? "fw-error-overlay--passive"
415
- : "fw-error-overlay--fullscreen"
550
+ "fw-error-overlay fw-error-overlay--fullscreen transition-opacity duration-300",
551
+ isErrorDismissing && "opacity-0"
416
552
  )}
417
553
  >
418
- <div
419
- class={cn(
420
- "fw-error-popup",
421
- storeState.isPassiveError
422
- ? "fw-error-popup--passive"
423
- : "fw-error-popup--fullscreen"
424
- )}
425
- >
426
- <div
427
- class={cn(
428
- "fw-error-header",
429
- storeState.isPassiveError
430
- ? "fw-error-header--warning"
431
- : "fw-error-header--error"
432
- )}
433
- >
434
- <span
435
- class={cn(
436
- "fw-error-title",
437
- storeState.isPassiveError
438
- ? "fw-error-title--warning"
439
- : "fw-error-title--error"
440
- )}
554
+ <div class="fw-error-popup fw-error-popup--fullscreen">
555
+ <div class="fw-error-header fw-error-header--error">
556
+ <span class="fw-error-title fw-error-title--error"
557
+ >{$translatorStore("error")}</span
441
558
  >
442
- {storeState.isPassiveError ? "Warning" : "Error"}
443
- </span>
444
559
  <button
445
560
  type="button"
446
561
  class="fw-error-close"
447
562
  onclick={() => playerStore?.clearError()}
448
- aria-label="Dismiss"
563
+ aria-label={$translatorStore("dismiss")}
449
564
  >
450
565
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
451
566
  <path
@@ -458,7 +573,7 @@
458
573
  </button>
459
574
  </div>
460
575
  <div class="fw-error-body">
461
- <p class="fw-error-message">Playback issue</p>
576
+ <p class="fw-error-message">{displayedError}</p>
462
577
  </div>
463
578
  <div class="fw-error-actions">
464
579
  <button
@@ -468,9 +583,33 @@
468
583
  playerStore?.clearError();
469
584
  playerStore?.retry();
470
585
  }}
471
- aria-label="Retry playback"
586
+ aria-label={$translatorStore("retry")}
587
+ >
588
+ {$translatorStore("retry")}
589
+ </button>
590
+ {#if playerStore?.getController()?.canAttemptFallback()}
591
+ <button
592
+ type="button"
593
+ class="fw-error-btn fw-error-btn--secondary"
594
+ onclick={() => {
595
+ playerStore?.clearError();
596
+ playerStore?.getController()?.retryWithFallback();
597
+ }}
598
+ aria-label={$translatorStore("tryNext")}
599
+ >
600
+ {$translatorStore("tryNext")}
601
+ </button>
602
+ {/if}
603
+ <button
604
+ type="button"
605
+ class="fw-error-btn fw-error-btn--secondary"
606
+ onclick={() => {
607
+ playerStore?.clearError();
608
+ playerStore?.reload();
609
+ }}
610
+ aria-label={$translatorStore("reloadPlayer")}
472
611
  >
473
- Retry
612
+ {$translatorStore("reloadPlayer")}
474
613
  </button>
475
614
  </div>
476
615
  </div>
@@ -492,7 +631,7 @@
492
631
  type="button"
493
632
  onclick={() => playerStore?.dismissToast()}
494
633
  class="ml-2 text-white/60 hover:text-white"
495
- aria-label="Dismiss"
634
+ aria-label={$translatorStore("dismiss")}
496
635
  >
497
636
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
498
637
  <path
@@ -507,8 +646,10 @@
507
646
  </div>
508
647
  {/if}
509
648
 
510
- <!-- Player controls -->
511
- {#if !useStockControls}
649
+ <!-- Player controls — custom children or default -->
650
+ {#if children}
651
+ {@render children()}
652
+ {:else if !useStockControls}
512
653
  <PlayerControls
513
654
  currentTime={storeState.currentTime}
514
655
  duration={storeState.duration}
@@ -524,6 +665,10 @@
524
665
  onStatsToggle={() => (isStatsOpen = !isStatsOpen)}
525
666
  isContentLive={storeState.isEffectivelyLive}
526
667
  onJumpToLive={() => playerStore?.getController()?.jumpToLive()}
668
+ {activeLocale}
669
+ onLocaleChange={(l) => {
670
+ activeLocale = l;
671
+ }}
527
672
  />
528
673
  {/if}
529
674
  </div>
@@ -569,7 +714,7 @@
569
714
  }}
570
715
  >
571
716
  <StatsIcon size={14} class="opacity-70 flex-shrink-0 mr-2" />
572
- {isStatsOpen ? "Hide Stats" : "Stats"}
717
+ {isStatsOpen ? $translatorStore("hideStats") : $translatorStore("showStats")}
573
718
  </ContextMenuItem>
574
719
  {#if options?.devMode}
575
720
  <ContextMenuSeparator />
@@ -579,13 +724,13 @@
579
724
  }}
580
725
  >
581
726
  <SettingsIcon size={14} class="opacity-70 flex-shrink-0 mr-2" />
582
- {isDevPanelOpen ? "Hide Settings" : "Settings"}
727
+ {isDevPanelOpen ? $translatorStore("hideSettings") : $translatorStore("settings")}
583
728
  </ContextMenuItem>
584
729
  {/if}
585
730
  <ContextMenuSeparator />
586
731
  <ContextMenuItem onSelect={() => playerStore?.togglePiP()}>
587
732
  <PictureInPictureIcon size={14} class="opacity-70 flex-shrink-0 mr-2" />
588
- Picture-in-Picture
733
+ {$translatorStore("pictureInPicture")}
589
734
  </ContextMenuItem>
590
735
  <ContextMenuItem onSelect={() => playerStore?.toggleLoop()}>
591
736
  <svg
@@ -604,7 +749,9 @@
604
749
  <polyline points="7 23 3 19 7 15"></polyline>
605
750
  <path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
606
751
  </svg>
607
- {storeState.isLoopEnabled ? "Disable Loop" : "Enable Loop"}
752
+ {storeState.isLoopEnabled
753
+ ? $translatorStore("disableLoop")
754
+ : $translatorStore("enableLoop")}
608
755
  </ContextMenuItem>
609
756
  </ContextMenuContent>
610
757
  </ContextMenuPortal>