@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
@@ -1,9 +1,13 @@
1
1
  <script lang="ts">
2
+ import { getContext } from "svelte";
3
+ import type { Readable } from "svelte/store";
2
4
  import {
3
5
  cn,
4
6
  globalPlayerManager,
7
+ createTranslator,
5
8
  type MistStreamInfo,
6
9
  type PlaybackMode,
10
+ type TranslateFn,
7
11
  // Seeking utilities from core
8
12
  SPEED_PRESETS,
9
13
  isMediaStreamSource,
@@ -15,7 +19,10 @@
15
19
  isLiveContent,
16
20
  // Time formatting from core
17
21
  formatTimeDisplay,
22
+ getAvailableLocales,
23
+ getLocaleDisplayName,
18
24
  } from "@livepeer-frameworks/player-core";
25
+ import type { FwLocale } from "@livepeer-frameworks/player-core";
19
26
  import SeekBar from "./SeekBar.svelte";
20
27
  import Slider from "./ui/Slider.svelte";
21
28
  import VolumeIcons from "./components/VolumeIcons.svelte";
@@ -31,6 +38,11 @@
31
38
  SeekToLiveIcon,
32
39
  } from "./icons";
33
40
 
41
+ // i18n: get translator from context, fall back to default English
42
+ const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
43
+ const fallbackT = createTranslator({ locale: "en" });
44
+ let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
45
+
34
46
  // Props - aligned with React PlayerControls
35
47
  interface Props {
36
48
  currentTime: number;
@@ -49,6 +61,8 @@
49
61
  isContentLive?: boolean;
50
62
  /** Jump to live edge callback */
51
63
  onJumpToLive?: () => void;
64
+ activeLocale?: FwLocale;
65
+ onLocaleChange?: (locale: FwLocale) => void;
52
66
  }
53
67
 
54
68
  let {
@@ -66,6 +80,8 @@
66
80
  onStatsToggle = undefined,
67
81
  isContentLive = undefined,
68
82
  onJumpToLive = undefined,
83
+ activeLocale = undefined,
84
+ onLocaleChange = undefined,
69
85
  }: Props = $props();
70
86
 
71
87
  // Video element discovery
@@ -123,6 +139,46 @@
123
139
  let qualityValue = $state("auto");
124
140
  let captionValue = $state("none");
125
141
 
142
+ // Audio detection: trust MistServer metadata first, then DOM fallback
143
+ $effect(() => {
144
+ // Primary: trust MistServer stream metadata (matches ddvtech embed approach)
145
+ if (mistStreamInfo?.hasAudio !== undefined) {
146
+ hasAudio = mistStreamInfo.hasAudio;
147
+ return;
148
+ }
149
+
150
+ if (!video) {
151
+ hasAudio = true;
152
+ return;
153
+ }
154
+
155
+ const checkAudio = () => {
156
+ if (video!.srcObject instanceof MediaStream) {
157
+ hasAudio = video!.srcObject.getAudioTracks().length > 0;
158
+ return;
159
+ }
160
+ const videoAny = video as any;
161
+ if (videoAny.audioTracks && videoAny.audioTracks.length !== undefined) {
162
+ hasAudio = videoAny.audioTracks.length > 0;
163
+ return;
164
+ }
165
+ hasAudio = true;
166
+ };
167
+ checkAudio();
168
+ video.addEventListener("loadedmetadata", checkAudio);
169
+ // Safari: audioTracks may be populated after loadedmetadata for HLS streams
170
+ const audioTracks = (video as any).audioTracks;
171
+ if (audioTracks?.addEventListener) {
172
+ audioTracks.addEventListener("addtrack", checkAudio);
173
+ }
174
+ return () => {
175
+ video!.removeEventListener("loadedmetadata", checkAudio);
176
+ if (audioTracks?.removeEventListener) {
177
+ audioTracks.removeEventListener("addtrack", checkAudio);
178
+ }
179
+ };
180
+ });
181
+
126
182
  // Text tracks from player
127
183
  let textTracks = $derived.by(() => {
128
184
  return globalPlayerManager.getCurrentPlayer()?.getTextTracks?.() ?? [];
@@ -507,7 +563,7 @@
507
563
 
508
564
  <div
509
565
  class={cn(
510
- "fw-player-surface fw-controls-wrapper",
566
+ "fw-controls-wrapper",
511
567
  isVisible ? "fw-controls-wrapper--visible" : "fw-controls-wrapper--hidden"
512
568
  )}
513
569
  >
@@ -551,7 +607,7 @@
551
607
  <button
552
608
  type="button"
553
609
  class="fw-btn-flush"
554
- aria-label={isPlaying ? "Pause" : "Play"}
610
+ aria-label={isPlaying ? t("pause") : t("play")}
555
611
  onclick={handlePlayPause}
556
612
  {disabled}
557
613
  >
@@ -565,7 +621,7 @@
565
621
  <button
566
622
  type="button"
567
623
  class="fw-btn-flush hidden sm:flex"
568
- aria-label="Skip back 10s"
624
+ aria-label={t("skipBackward")}
569
625
  onclick={handleSkipBack}
570
626
  {disabled}
571
627
  >
@@ -574,7 +630,7 @@
574
630
  <button
575
631
  type="button"
576
632
  class="fw-btn-flush hidden sm:flex"
577
- aria-label="Skip forward 10s"
633
+ aria-label={t("skipForward")}
578
634
  onclick={handleSkipForward}
579
635
  {disabled}
580
636
  >
@@ -592,7 +648,7 @@
592
648
  !hasAudio && "fw-volume-group--disabled"
593
649
  )}
594
650
  role="group"
595
- aria-label="Volume controls"
651
+ aria-label={t("volume")}
596
652
  onmouseenter={() => hasAudio && (isVolumeHovered = true)}
597
653
  onmouseleave={() => {
598
654
  isVolumeHovered = false;
@@ -602,12 +658,17 @@
602
658
  onfocusout={(e) => {
603
659
  if (!e.currentTarget.contains(e.relatedTarget as Node)) isVolumeFocused = false;
604
660
  }}
661
+ onpointerup={(e) => {
662
+ if (hasAudio && e.target === e.currentTarget) {
663
+ handleMute();
664
+ }
665
+ }}
605
666
  >
606
667
  <button
607
668
  type="button"
608
669
  class="fw-volume-btn"
609
- aria-label={!hasAudio ? "No audio" : isMuted ? "Unmute" : "Mute"}
610
- title={!hasAudio ? "No audio" : isMuted ? "Unmute" : "Mute"}
670
+ aria-label={!hasAudio ? t("muted") : isMuted ? t("unmute") : t("mute")}
671
+ title={!hasAudio ? t("muted") : isMuted ? t("unmute") : t("mute")}
611
672
  onclick={handleMute}
612
673
  disabled={!hasAudio}
613
674
  >
@@ -629,7 +690,7 @@
629
690
  oninput={handleVolumeChange}
630
691
  orientation="horizontal"
631
692
  className="w-full"
632
- aria-label="Volume"
693
+ aria-label={t("volume")}
633
694
  disabled={!hasAudio}
634
695
  />
635
696
  </div>
@@ -651,13 +712,9 @@
651
712
  "fw-live-badge",
652
713
  !hasDvrWindow || isNearLiveState ? "fw-live-badge--active" : "fw-live-badge--behind"
653
714
  )}
654
- title={!hasDvrWindow
655
- ? "Live only"
656
- : isNearLiveState
657
- ? "At live edge"
658
- : "Jump to live"}
715
+ title={t("live")}
659
716
  >
660
- LIVE
717
+ {t("live").toUpperCase()}
661
718
  {#if !isNearLiveState && hasDvrWindow}
662
719
  <SeekToLiveIcon size={10} />
663
720
  {/if}
@@ -673,8 +730,8 @@
673
730
  <button
674
731
  type="button"
675
732
  class={cn("fw-btn-flush", isStatsOpen && "fw-btn-flush--active")}
676
- aria-label="Toggle stats"
677
- title="Stats"
733
+ aria-label={t("showStats")}
734
+ title={t("showStats")}
678
735
  onclick={onStatsToggle}
679
736
  {disabled}
680
737
  >
@@ -686,8 +743,8 @@
686
743
  <button
687
744
  type="button"
688
745
  class={cn("fw-btn-flush group", showSettingsMenu && "fw-btn-flush--active")}
689
- aria-label="Settings"
690
- title="Settings"
746
+ aria-label={t("settings")}
747
+ title={t("settings")}
691
748
  onclick={() => (showSettingsMenu = !showSettingsMenu)}
692
749
  {disabled}
693
750
  >
@@ -695,11 +752,11 @@
695
752
  </button>
696
753
 
697
754
  {#if showSettingsMenu}
698
- <div class="fw-player-surface fw-settings-menu">
755
+ <div class="fw-settings-menu">
699
756
  <!-- Playback Mode - only show for live content (not VOD/clips) -->
700
757
  {#if onModeChange && isContentLive !== false}
701
758
  <div class="fw-settings-section">
702
- <div class="fw-settings-label">Mode</div>
759
+ <div class="fw-settings-label">{t("mode")}</div>
703
760
  <div class="fw-settings-options">
704
761
  {#each ["auto", "low-latency", "quality"] as mode}
705
762
  <button
@@ -713,7 +770,11 @@
713
770
  showSettingsMenu = false;
714
771
  }}
715
772
  >
716
- {mode === "low-latency" ? "Fast" : mode === "quality" ? "Stable" : "Auto"}
773
+ {mode === "low-latency"
774
+ ? t("fast")
775
+ : mode === "quality"
776
+ ? t("stable")
777
+ : t("auto")}
717
778
  </button>
718
779
  {/each}
719
780
  </div>
@@ -723,7 +784,7 @@
723
784
  <!-- Speed (hidden for WebRTC MediaStream) -->
724
785
  {#if supportsPlaybackRate}
725
786
  <div class="fw-settings-section">
726
- <div class="fw-settings-label">Speed</div>
787
+ <div class="fw-settings-label">{t("speed")}</div>
727
788
  <div class="fw-settings-options fw-settings-options--wrap">
728
789
  {#each SPEED_PRESETS as rate}
729
790
  <button
@@ -745,7 +806,7 @@
745
806
  <!-- Quality -->
746
807
  {#if qualities.length > 0}
747
808
  <div class="fw-settings-section">
748
- <div class="fw-settings-label">Quality</div>
809
+ <div class="fw-settings-label">{t("quality")}</div>
749
810
  <div class="fw-settings-list">
750
811
  <button
751
812
  class={cn(
@@ -754,7 +815,7 @@
754
815
  )}
755
816
  onclick={() => handleQualityChange("auto")}
756
817
  >
757
- Auto
818
+ {t("auto")}
758
819
  </button>
759
820
  {#each qualities as q}
760
821
  <button
@@ -774,7 +835,7 @@
774
835
  <!-- Captions -->
775
836
  {#if textTracks.length > 0}
776
837
  <div class="fw-settings-section">
777
- <div class="fw-settings-label">Captions</div>
838
+ <div class="fw-settings-label">{t("captions")}</div>
778
839
  <div class="fw-settings-list">
779
840
  <button
780
841
  class={cn(
@@ -783,17 +844,38 @@
783
844
  )}
784
845
  onclick={() => handleCaptionChange("none")}
785
846
  >
786
- Off
847
+ {t("captionsOff")}
787
848
  </button>
788
- {#each textTracks as t}
849
+ {#each textTracks as tt}
789
850
  <button
790
851
  class={cn(
791
852
  "fw-settings-list-item",
792
- captionValue === t.id && "fw-settings-list-item--active"
853
+ captionValue === tt.id && "fw-settings-list-item--active"
854
+ )}
855
+ onclick={() => handleCaptionChange(tt.id)}
856
+ >
857
+ {tt.label || tt.id}
858
+ </button>
859
+ {/each}
860
+ </div>
861
+ </div>
862
+ {/if}
863
+
864
+ <!-- Locale -->
865
+ {#if onLocaleChange}
866
+ <div class="fw-settings-section">
867
+ <div class="fw-settings-label">{t("language")}</div>
868
+ <div class="fw-settings-list">
869
+ {#each getAvailableLocales() as loc}
870
+ <button
871
+ type="button"
872
+ class={cn(
873
+ "fw-settings-list-item",
874
+ activeLocale === loc && "fw-settings-list-item--active"
793
875
  )}
794
- onclick={() => handleCaptionChange(t.id)}
876
+ onclick={() => onLocaleChange(loc)}
795
877
  >
796
- {t.label || t.id}
878
+ {getLocaleDisplayName(loc)}
797
879
  </button>
798
880
  {/each}
799
881
  </div>
@@ -807,8 +889,8 @@
807
889
  <button
808
890
  type="button"
809
891
  class="fw-btn-flush"
810
- aria-label="Toggle fullscreen"
811
- title="Fullscreen"
892
+ aria-label={isFullscreen ? t("exitFullscreen") : t("fullscreen")}
893
+ title={t("fullscreen")}
812
894
  onclick={handleFullscreen}
813
895
  {disabled}
814
896
  >
@@ -10,6 +10,9 @@
10
10
  - Retry button for errors
11
11
  -->
12
12
  <script lang="ts">
13
+ import { getContext } from "svelte";
14
+ import type { Readable } from "svelte/store";
15
+ import { createTranslator, type TranslateFn } from "@livepeer-frameworks/player-core";
13
16
  import type { StreamStatus } from "@livepeer-frameworks/player-core";
14
17
 
15
18
  interface Props {
@@ -36,6 +39,10 @@
36
39
  class: className = "",
37
40
  }: Props = $props();
38
41
 
42
+ const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
43
+ const fallbackT = createTranslator({ locale: "en" });
44
+ let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
45
+
39
46
  // Computed states
40
47
  let showRetry = $derived(status === "ERROR" || status === "INVALID" || status === "OFFLINE");
41
48
  let showProgress = $derived(status === "INITIALIZING" && percentage !== undefined);
@@ -143,7 +150,7 @@
143
150
  <p
144
151
  style="margin-top: 0.5rem; font-size: 0.75rem; color: hsl(var(--tn-fg-dark, 233 23% 60%));"
145
152
  >
146
- The stream will start when the broadcaster goes live
153
+ {t("broadcasterGoLive")}
147
154
  </p>
148
155
  {/if}
149
156
 
@@ -151,7 +158,7 @@
151
158
  <p
152
159
  style="margin-top: 0.5rem; font-size: 0.75rem; color: hsl(var(--tn-fg-dark, 233 23% 60%));"
153
160
  >
154
- Please wait while the stream prepares...
161
+ {t("streamPreparing")}
155
162
  </p>
156
163
  {/if}
157
164
 
@@ -159,7 +166,7 @@
159
166
  {#if !showRetry}
160
167
  <div class="polling-indicator">
161
168
  <span class="polling-dot"></span>
162
- <span>Checking stream status...</span>
169
+ <span>{t("checkingStatus")}</span>
163
170
  </div>
164
171
  {/if}
165
172
  </div>
@@ -167,8 +174,13 @@
167
174
  <!-- Slab actions - flush retry button -->
168
175
  {#if showRetry && onRetry}
169
176
  <div class="slab-actions">
170
- <button type="button" class="btn-flush" onclick={onRetry} aria-label="Retry connection">
171
- Retry Connection
177
+ <button
178
+ type="button"
179
+ class="btn-flush"
180
+ onclick={onRetry}
181
+ aria-label={t("retryConnection")}
182
+ >
183
+ {t("retryConnection")}
172
184
  </button>
173
185
  </div>
174
186
  {/if}
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { getContext } from "svelte";
3
+ import type { Readable } from "svelte/store";
4
+ import { createTranslator, type TranslateFn } from "@livepeer-frameworks/player-core";
5
+ import { FullscreenIcon, FullscreenExitIcon } from "../icons";
6
+
7
+ let pc: any = getContext("fw-player-controller");
8
+ const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
9
+ const fallbackT = createTranslator({ locale: "en" });
10
+ let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
11
+ </script>
12
+
13
+ <button
14
+ type="button"
15
+ class="fw-btn-flush"
16
+ aria-label={pc?.isFullscreen ? t("exitFullscreen") : t("fullscreen")}
17
+ aria-pressed={pc?.isFullscreen ?? false}
18
+ title={pc?.isFullscreen ? t("exitFullscreen") : t("fullscreen")}
19
+ onclick={() => pc?.toggleFullscreen()}
20
+ >
21
+ {#if pc?.isFullscreen}
22
+ <FullscreenExitIcon size={16} />
23
+ {:else}
24
+ <FullscreenIcon size={16} />
25
+ {/if}
26
+ </button>
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import { getContext } from "svelte";
3
+ import type { Readable } from "svelte/store";
4
+ import { createTranslator, type TranslateFn } from "@livepeer-frameworks/player-core";
5
+ import { SeekToLiveIcon } from "../icons";
6
+
7
+ let pc: any = getContext("fw-player-controller");
8
+ const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
9
+ const fallbackT = createTranslator({ locale: "en" });
10
+ let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
11
+ </script>
12
+
13
+ {#if pc?.isEffectivelyLive}
14
+ <button
15
+ type="button"
16
+ class="fw-live-badge fw-live-badge--active"
17
+ onclick={() => pc?.jumpToLive()}
18
+ aria-label={t("live")}
19
+ >
20
+ {t("live").toUpperCase()}
21
+ <SeekToLiveIcon size={10} />
22
+ </button>
23
+ {/if}
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { getContext } from "svelte";
3
+ import type { Readable } from "svelte/store";
4
+ import { createTranslator, type TranslateFn } from "@livepeer-frameworks/player-core";
5
+ import { PlayIcon, PauseIcon } from "../icons";
6
+
7
+ let pc: any = getContext("fw-player-controller");
8
+ const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
9
+ const fallbackT = createTranslator({ locale: "en" });
10
+ let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
11
+ </script>
12
+
13
+ <button
14
+ type="button"
15
+ class="fw-btn-flush"
16
+ aria-label={pc?.isPlaying ? t("pause") : t("play")}
17
+ aria-pressed={pc?.isPlaying ?? false}
18
+ title={pc?.isPlaying ? t("pause") : t("play")}
19
+ onclick={() => pc?.togglePlay()}
20
+ >
21
+ {#if pc?.isPlaying}
22
+ <PauseIcon size={18} />
23
+ {:else}
24
+ <PlayIcon size={18} />
25
+ {/if}
26
+ </button>
@@ -0,0 +1,208 @@
1
+ <script lang="ts">
2
+ import { getContext } from "svelte";
3
+ import type { Readable } from "svelte/store";
4
+ import { SettingsIcon } from "../icons";
5
+ import {
6
+ SPEED_PRESETS,
7
+ getAvailableLocales,
8
+ getLocaleDisplayName,
9
+ createTranslator,
10
+ type TranslateFn,
11
+ } from "@livepeer-frameworks/player-core";
12
+ import type { FwLocale } from "@livepeer-frameworks/player-core";
13
+
14
+ interface Props {
15
+ qualities?: Array<{ id: string; label: string; active?: boolean }>;
16
+ activeQuality?: string;
17
+ onSelectQuality?: (id: string) => void;
18
+ textTracks?: Array<{ id: string; label: string; active?: boolean }>;
19
+ activeCaption?: string;
20
+ onSelectCaption?: (id: string) => void;
21
+ playbackRate?: number;
22
+ onSpeedChange?: (rate: number) => void;
23
+ supportsSpeed?: boolean;
24
+ playbackMode?: "auto" | "low-latency" | "quality";
25
+ onModeChange?: (mode: "auto" | "low-latency" | "quality") => void;
26
+ showModeSelector?: boolean;
27
+ activeLocale?: FwLocale;
28
+ onLocaleChange?: (locale: FwLocale) => void;
29
+ }
30
+
31
+ let {
32
+ qualities: propQualities,
33
+ activeQuality,
34
+ onSelectQuality,
35
+ textTracks: propTextTracks,
36
+ activeCaption,
37
+ onSelectCaption,
38
+ playbackRate = 1,
39
+ onSpeedChange,
40
+ supportsSpeed = true,
41
+ playbackMode,
42
+ onModeChange,
43
+ showModeSelector = false,
44
+ activeLocale = undefined,
45
+ onLocaleChange = undefined,
46
+ }: Props = $props();
47
+
48
+ let availableLocales = getAvailableLocales();
49
+
50
+ let controller: any = getContext("fw-player-controller");
51
+ const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
52
+ const fallbackT = createTranslator({ locale: "en" });
53
+ let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
54
+ let isOpen = $state(false);
55
+
56
+ let qualities = $derived(propQualities ?? controller?.getQualities?.() ?? []);
57
+ let qualityValue = $derived(activeQuality ?? qualities.find((q: any) => q.active)?.id ?? "auto");
58
+ let textTracks = $derived(propTextTracks ?? []);
59
+ let captionValue = $derived(activeCaption ?? textTracks.find((t: any) => t.active)?.id ?? "none");
60
+
61
+ function selectQuality(id: string) {
62
+ if (onSelectQuality) onSelectQuality(id);
63
+ else controller?.selectQuality?.(id);
64
+ isOpen = false;
65
+ }
66
+
67
+ function handleKeyDown(e: KeyboardEvent) {
68
+ if (e.key === "Escape") {
69
+ isOpen = false;
70
+ e.preventDefault();
71
+ return;
72
+ }
73
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
74
+ e.preventDefault();
75
+ const menu = e.currentTarget as HTMLElement;
76
+ const items = menu.querySelectorAll<HTMLButtonElement>("button");
77
+ if (!items.length) return;
78
+ const current = Array.from(items).indexOf(document.activeElement as HTMLButtonElement);
79
+ const next =
80
+ e.key === "ArrowDown"
81
+ ? (current + 1) % items.length
82
+ : (current - 1 + items.length) % items.length;
83
+ items[next]?.focus();
84
+ }
85
+ }
86
+ </script>
87
+
88
+ <div class="fw-control-group" style="position: relative">
89
+ <button
90
+ type="button"
91
+ class="fw-btn-flush group"
92
+ class:fw-btn-flush--active={isOpen}
93
+ aria-label={t("settings")}
94
+ title={t("settings")}
95
+ onclick={() => (isOpen = !isOpen)}
96
+ >
97
+ <SettingsIcon size={16} />
98
+ </button>
99
+
100
+ {#if isOpen}
101
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
102
+ <div class="fw-settings-menu" role="menu" aria-label={t("settings")} onkeydown={handleKeyDown}>
103
+ {#if showModeSelector && onModeChange}
104
+ <div class="fw-settings-section">
105
+ <div class="fw-settings-label">{t("mode")}</div>
106
+ <div class="fw-settings-options">
107
+ {#each ["auto", "low-latency", "quality"] as mode}
108
+ <button
109
+ class="fw-settings-btn"
110
+ class:fw-settings-btn--active={playbackMode === mode}
111
+ onclick={() => {
112
+ onModeChange?.(mode as any);
113
+ isOpen = false;
114
+ }}
115
+ >
116
+ {mode === "low-latency" ? t("fast") : mode === "quality" ? t("stable") : t("auto")}
117
+ </button>
118
+ {/each}
119
+ </div>
120
+ </div>
121
+ {/if}
122
+
123
+ {#if supportsSpeed}
124
+ <div class="fw-settings-section">
125
+ <div class="fw-settings-label">{t("speed")}</div>
126
+ <div class="fw-settings-options fw-settings-options--wrap">
127
+ {#each SPEED_PRESETS as rate}
128
+ <button
129
+ class="fw-settings-btn"
130
+ class:fw-settings-btn--active={playbackRate === rate}
131
+ onclick={() => {
132
+ onSpeedChange?.(rate);
133
+ isOpen = false;
134
+ }}
135
+ >
136
+ {rate}x
137
+ </button>
138
+ {/each}
139
+ </div>
140
+ </div>
141
+ {/if}
142
+
143
+ {#if qualities.length > 0}
144
+ <div class="fw-settings-section">
145
+ <div class="fw-settings-label">{t("quality")}</div>
146
+ <div class="fw-settings-list">
147
+ <button
148
+ class="fw-settings-list-item"
149
+ class:fw-settings-list-item--active={qualityValue === "auto"}
150
+ onclick={() => selectQuality("auto")}>{t("auto")}</button
151
+ >
152
+ {#each qualities as q}
153
+ <button
154
+ class="fw-settings-list-item"
155
+ class:fw-settings-list-item--active={qualityValue === q.id}
156
+ onclick={() => selectQuality(q.id)}>{q.label}</button
157
+ >
158
+ {/each}
159
+ </div>
160
+ </div>
161
+ {/if}
162
+
163
+ {#if textTracks.length > 0}
164
+ <div class="fw-settings-section">
165
+ <div class="fw-settings-label">{t("captions")}</div>
166
+ <div class="fw-settings-list">
167
+ <button
168
+ class="fw-settings-list-item"
169
+ class:fw-settings-list-item--active={captionValue === "none"}
170
+ onclick={() => {
171
+ onSelectCaption?.("none");
172
+ isOpen = false;
173
+ }}>{t("captionsOff")}</button
174
+ >
175
+ {#each textTracks as tt}
176
+ <button
177
+ class="fw-settings-list-item"
178
+ class:fw-settings-list-item--active={captionValue === tt.id}
179
+ onclick={() => {
180
+ onSelectCaption?.(tt.id);
181
+ isOpen = false;
182
+ }}>{tt.label || tt.id}</button
183
+ >
184
+ {/each}
185
+ </div>
186
+ </div>
187
+ {/if}
188
+
189
+ {#if onLocaleChange}
190
+ <div class="fw-settings-section">
191
+ <div class="fw-settings-label">{t("language")}</div>
192
+ <div class="fw-settings-list">
193
+ {#each availableLocales as l}
194
+ <button
195
+ class="fw-settings-list-item"
196
+ class:fw-settings-list-item--active={activeLocale === l}
197
+ onclick={() => {
198
+ onLocaleChange?.(l);
199
+ isOpen = false;
200
+ }}>{getLocaleDisplayName(l)}</button
201
+ >
202
+ {/each}
203
+ </div>
204
+ </div>
205
+ {/if}
206
+ </div>
207
+ {/if}
208
+ </div>