@livepeer-frameworks/player-svelte 0.2.3 → 0.2.5

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.
package/README.md CHANGED
@@ -16,14 +16,15 @@ npm i @livepeer-frameworks/player-svelte
16
16
 
17
17
  ```svelte
18
18
  <script lang="ts">
19
- import { Player } from '@livepeer-frameworks/player-svelte';
19
+ import { Player } from "@livepeer-frameworks/player-svelte";
20
20
  </script>
21
21
 
22
22
  <div style="width: 100%; height: 500px;">
23
23
  <Player
24
24
  contentType="live"
25
- contentId="pk_..." // playbackId
26
- options={{ gatewayUrl: 'https://your-bridge/graphql' }}
25
+ contentId="pk_..."
26
+ // playbackId
27
+ options={{ gatewayUrl: "https://your-bridge/graphql" }}
27
28
  />
28
29
  </div>
29
30
  ```
@@ -90,8 +90,12 @@
90
90
  let isDevPanelOpen = $state(false);
91
91
  let skipDirection: SkipDirection = $state(null);
92
92
 
93
- let activeTheme = $state<FwThemePreset>(options?.theme ?? "default");
94
- let activeLocale = $state<FwLocale>(options?.locale ?? "en");
93
+ let activeTheme = $state<FwThemePreset>("default");
94
+ let activeLocale = $state<FwLocale>("en");
95
+ $effect(() => {
96
+ if (options.theme) activeTheme = options.theme;
97
+ if (options.locale) activeLocale = options.locale;
98
+ });
95
99
 
96
100
  // Sync locale state to i18n store and provide translator context
97
101
  $effect(() => {
@@ -221,6 +225,10 @@
221
225
  playbackQuality: null as any,
222
226
  subtitlesEnabled: false,
223
227
  toast: null as { message: string; timestamp: number } | null,
228
+ controllerSeekableStart: 0,
229
+ controllerLiveEdge: 0,
230
+ controllerCanSeek: false,
231
+ controllerHasAudio: true,
224
232
  });
225
233
 
226
234
  // Track if we've already attached to prevent double-attach race
@@ -667,8 +675,8 @@
667
675
  onStatsToggle={() => (isStatsOpen = !isStatsOpen)}
668
676
  isContentLive={storeState.isEffectivelyLive}
669
677
  onJumpToLive={() => playerStore?.getController()?.jumpToLive()}
670
- controllerSeekableStart={playerStore?.getController()?.getSeekableStart()}
671
- controllerLiveEdge={playerStore?.getController()?.getLiveEdge()}
678
+ controllerSeekableStart={storeState?.controllerSeekableStart ?? 0}
679
+ controllerLiveEdge={storeState?.controllerLiveEdge ?? 0}
672
680
  {activeLocale}
673
681
  onLocaleChange={(l) => {
674
682
  activeLocale = l;
@@ -149,40 +149,77 @@
149
149
 
150
150
  // Audio detection: trust MistServer metadata first, then DOM fallback
151
151
  $effect(() => {
152
- // Primary: trust MistServer stream metadata (matches ddvtech embed approach)
153
- if (mistStreamInfo?.hasAudio !== undefined) {
154
- hasAudio = mistStreamInfo.hasAudio;
155
- return;
156
- }
157
-
158
152
  if (!video) {
159
153
  hasAudio = true;
160
154
  return;
161
155
  }
156
+ const currentVideo = video;
157
+
158
+ let boundStream: MediaStream | null = null;
159
+ let streamListener: (() => void) | null = null;
160
+
161
+ const unbindStream = () => {
162
+ if (boundStream && streamListener) {
163
+ boundStream.removeEventListener("addtrack", streamListener);
164
+ boundStream.removeEventListener("removetrack", streamListener);
165
+ }
166
+ boundStream = null;
167
+ streamListener = null;
168
+ };
162
169
 
163
170
  const checkAudio = () => {
164
- if (video!.srcObject instanceof MediaStream) {
165
- hasAudio = video!.srcObject.getAudioTracks().length > 0;
171
+ // Prefer actual MediaStream tracks over metadata to avoid phantom controls.
172
+ if (currentVideo.srcObject instanceof MediaStream) {
173
+ const stream = currentVideo.srcObject;
174
+ if (stream !== boundStream) {
175
+ unbindStream();
176
+ boundStream = stream;
177
+ streamListener = () => {
178
+ hasAudio = stream.getAudioTracks().length > 0;
179
+ };
180
+ stream.addEventListener("addtrack", streamListener);
181
+ stream.addEventListener("removetrack", streamListener);
182
+ }
183
+ hasAudio = stream.getAudioTracks().length > 0;
166
184
  return;
167
185
  }
168
- const videoAny = video as any;
186
+
187
+ unbindStream();
188
+
189
+ // Fallback: metadata for non-MediaStream sources.
190
+ if (mistStreamInfo?.hasAudio !== undefined) {
191
+ hasAudio = mistStreamInfo.hasAudio;
192
+ return;
193
+ }
194
+
195
+ const videoAny = currentVideo as any;
169
196
  if (videoAny.audioTracks && videoAny.audioTracks.length !== undefined) {
170
197
  hasAudio = videoAny.audioTracks.length > 0;
171
198
  return;
172
199
  }
200
+
173
201
  hasAudio = true;
174
202
  };
203
+
175
204
  checkAudio();
176
- video.addEventListener("loadedmetadata", checkAudio);
177
- // Safari: audioTracks may be populated after loadedmetadata for HLS streams
178
- const audioTracks = (video as any).audioTracks;
205
+ currentVideo.addEventListener("loadedmetadata", checkAudio);
206
+ currentVideo.addEventListener("loadeddata", checkAudio);
207
+ currentVideo.addEventListener("durationchange", checkAudio);
208
+
209
+ const audioTracks = (currentVideo as any).audioTracks;
179
210
  if (audioTracks?.addEventListener) {
180
211
  audioTracks.addEventListener("addtrack", checkAudio);
212
+ audioTracks.addEventListener("removetrack", checkAudio);
181
213
  }
214
+
182
215
  return () => {
183
- video!.removeEventListener("loadedmetadata", checkAudio);
216
+ unbindStream();
217
+ currentVideo.removeEventListener("loadedmetadata", checkAudio);
218
+ currentVideo.removeEventListener("loadeddata", checkAudio);
219
+ currentVideo.removeEventListener("durationchange", checkAudio);
184
220
  if (audioTracks?.removeEventListener) {
185
221
  audioTracks.removeEventListener("addtrack", checkAudio);
222
+ audioTracks.removeEventListener("removetrack", checkAudio);
186
223
  }
187
224
  };
188
225
  });
@@ -263,11 +300,7 @@
263
300
  );
264
301
 
265
302
  let allowMediaStreamDvr = $derived(
266
- isMediaStreamSource(video) &&
267
- bufferWindowMs !== undefined &&
268
- bufferWindowMs > 0 &&
269
- sourceType !== "whep" &&
270
- sourceType !== "webrtc"
303
+ isMediaStreamSource(video) && bufferWindowMs !== undefined && bufferWindowMs > 0
271
304
  );
272
305
 
273
306
  // Seekable range: prefer controller-derived values (same pattern as React/WC)
@@ -101,7 +101,13 @@
101
101
 
102
102
  {#if isOpen}
103
103
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
104
- <div class="fw-settings-menu" role="menu" aria-label={t("settings")} onkeydown={handleKeyDown}>
104
+ <div
105
+ class="fw-settings-menu"
106
+ role="menu"
107
+ aria-label={t("settings")}
108
+ onkeydown={handleKeyDown}
109
+ tabindex="-1"
110
+ >
105
111
  {#if showModeSelector && onModeChange}
106
112
  <div class="fw-settings-section">
107
113
  <div class="fw-settings-label">{t("mode")}</div>
@@ -78,6 +78,14 @@ export interface PlayerControllerState {
78
78
  message: string;
79
79
  timestamp: number;
80
80
  } | null;
81
+ /** Controller-derived seekable start (ms) — reactively updated via seekingStateChange */
82
+ controllerSeekableStart: number;
83
+ /** Controller-derived live edge (ms) — reactively updated via seekingStateChange */
84
+ controllerLiveEdge: number;
85
+ /** Controller-derived canSeek — reactively updated via seekingStateChange */
86
+ controllerCanSeek: boolean;
87
+ /** Controller-derived hasAudio — reactively updated via seekingStateChange */
88
+ controllerHasAudio: boolean;
81
89
  }
82
90
  export interface PlayerControllerStore extends Readable<PlayerControllerState> {
83
91
  /** Get controller instance */
@@ -38,6 +38,10 @@ const initialState = {
38
38
  playbackQuality: null,
39
39
  subtitlesEnabled: false,
40
40
  toast: null,
41
+ controllerSeekableStart: 0,
42
+ controllerLiveEdge: 0,
43
+ controllerCanSeek: false,
44
+ controllerHasAudio: true,
41
45
  };
42
46
  // ============================================================================
43
47
  // Store Factory
@@ -209,6 +213,15 @@ export function createPlayerControllerStore(config) {
209
213
  unsubscribers.push(controller.on("captionsChange", ({ enabled }) => {
210
214
  store.update((prev) => ({ ...prev, subtitlesEnabled: enabled }));
211
215
  }));
216
+ unsubscribers.push(controller.on("seekingStateChange", (data) => {
217
+ store.update((prev) => ({
218
+ ...prev,
219
+ controllerSeekableStart: data.seekableStart,
220
+ controllerLiveEdge: data.liveEdge,
221
+ controllerCanSeek: data.canSeek,
222
+ controllerHasAudio: data.hasAudio,
223
+ }));
224
+ }));
212
225
  // Error handling events - show toasts/modals
213
226
  unsubscribers.push(controller.on("protocolSwapped", (data) => {
214
227
  const message = `Switched to ${data.toProtocol}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livepeer-frameworks/player-svelte",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Svelte 5 components for FrameWorks streaming player",
6
6
  "svelte": "./dist/index.js",
@@ -21,8 +21,8 @@
21
21
  "./player.css": "./src/player.css"
22
22
  },
23
23
  "dependencies": {
24
- "bits-ui": "^2.15.5",
25
- "@livepeer-frameworks/player-core": "0.2.3"
24
+ "bits-ui": "^2.16.2",
25
+ "@livepeer-frameworks/player-core": "0.2.5"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "svelte": "^5.0.0"
@@ -33,8 +33,8 @@
33
33
  "@testing-library/svelte": "^5.3.1",
34
34
  "@vitest/coverage-v8": "^4.0.18",
35
35
  "jsdom": "^28.1.0",
36
- "svelte": "^5.51.2",
37
- "svelte-check": "^4.4.0",
36
+ "svelte": "^5.53.6",
37
+ "svelte-check": "^4.4.4",
38
38
  "typescript": "^5.9.3",
39
39
  "vite": "^7.3.1",
40
40
  "vitest": "^4.0.18"
package/src/Player.svelte CHANGED
@@ -90,8 +90,12 @@
90
90
  let isDevPanelOpen = $state(false);
91
91
  let skipDirection: SkipDirection = $state(null);
92
92
 
93
- let activeTheme = $state<FwThemePreset>(options?.theme ?? "default");
94
- let activeLocale = $state<FwLocale>(options?.locale ?? "en");
93
+ let activeTheme = $state<FwThemePreset>("default");
94
+ let activeLocale = $state<FwLocale>("en");
95
+ $effect(() => {
96
+ if (options.theme) activeTheme = options.theme;
97
+ if (options.locale) activeLocale = options.locale;
98
+ });
95
99
 
96
100
  // Sync locale state to i18n store and provide translator context
97
101
  $effect(() => {
@@ -221,6 +225,10 @@
221
225
  playbackQuality: null as any,
222
226
  subtitlesEnabled: false,
223
227
  toast: null as { message: string; timestamp: number } | null,
228
+ controllerSeekableStart: 0,
229
+ controllerLiveEdge: 0,
230
+ controllerCanSeek: false,
231
+ controllerHasAudio: true,
224
232
  });
225
233
 
226
234
  // Track if we've already attached to prevent double-attach race
@@ -667,8 +675,8 @@
667
675
  onStatsToggle={() => (isStatsOpen = !isStatsOpen)}
668
676
  isContentLive={storeState.isEffectivelyLive}
669
677
  onJumpToLive={() => playerStore?.getController()?.jumpToLive()}
670
- controllerSeekableStart={playerStore?.getController()?.getSeekableStart()}
671
- controllerLiveEdge={playerStore?.getController()?.getLiveEdge()}
678
+ controllerSeekableStart={storeState?.controllerSeekableStart ?? 0}
679
+ controllerLiveEdge={storeState?.controllerLiveEdge ?? 0}
672
680
  {activeLocale}
673
681
  onLocaleChange={(l) => {
674
682
  activeLocale = l;
@@ -149,40 +149,77 @@
149
149
 
150
150
  // Audio detection: trust MistServer metadata first, then DOM fallback
151
151
  $effect(() => {
152
- // Primary: trust MistServer stream metadata (matches ddvtech embed approach)
153
- if (mistStreamInfo?.hasAudio !== undefined) {
154
- hasAudio = mistStreamInfo.hasAudio;
155
- return;
156
- }
157
-
158
152
  if (!video) {
159
153
  hasAudio = true;
160
154
  return;
161
155
  }
156
+ const currentVideo = video;
157
+
158
+ let boundStream: MediaStream | null = null;
159
+ let streamListener: (() => void) | null = null;
160
+
161
+ const unbindStream = () => {
162
+ if (boundStream && streamListener) {
163
+ boundStream.removeEventListener("addtrack", streamListener);
164
+ boundStream.removeEventListener("removetrack", streamListener);
165
+ }
166
+ boundStream = null;
167
+ streamListener = null;
168
+ };
162
169
 
163
170
  const checkAudio = () => {
164
- if (video!.srcObject instanceof MediaStream) {
165
- hasAudio = video!.srcObject.getAudioTracks().length > 0;
171
+ // Prefer actual MediaStream tracks over metadata to avoid phantom controls.
172
+ if (currentVideo.srcObject instanceof MediaStream) {
173
+ const stream = currentVideo.srcObject;
174
+ if (stream !== boundStream) {
175
+ unbindStream();
176
+ boundStream = stream;
177
+ streamListener = () => {
178
+ hasAudio = stream.getAudioTracks().length > 0;
179
+ };
180
+ stream.addEventListener("addtrack", streamListener);
181
+ stream.addEventListener("removetrack", streamListener);
182
+ }
183
+ hasAudio = stream.getAudioTracks().length > 0;
166
184
  return;
167
185
  }
168
- const videoAny = video as any;
186
+
187
+ unbindStream();
188
+
189
+ // Fallback: metadata for non-MediaStream sources.
190
+ if (mistStreamInfo?.hasAudio !== undefined) {
191
+ hasAudio = mistStreamInfo.hasAudio;
192
+ return;
193
+ }
194
+
195
+ const videoAny = currentVideo as any;
169
196
  if (videoAny.audioTracks && videoAny.audioTracks.length !== undefined) {
170
197
  hasAudio = videoAny.audioTracks.length > 0;
171
198
  return;
172
199
  }
200
+
173
201
  hasAudio = true;
174
202
  };
203
+
175
204
  checkAudio();
176
- video.addEventListener("loadedmetadata", checkAudio);
177
- // Safari: audioTracks may be populated after loadedmetadata for HLS streams
178
- const audioTracks = (video as any).audioTracks;
205
+ currentVideo.addEventListener("loadedmetadata", checkAudio);
206
+ currentVideo.addEventListener("loadeddata", checkAudio);
207
+ currentVideo.addEventListener("durationchange", checkAudio);
208
+
209
+ const audioTracks = (currentVideo as any).audioTracks;
179
210
  if (audioTracks?.addEventListener) {
180
211
  audioTracks.addEventListener("addtrack", checkAudio);
212
+ audioTracks.addEventListener("removetrack", checkAudio);
181
213
  }
214
+
182
215
  return () => {
183
- video!.removeEventListener("loadedmetadata", checkAudio);
216
+ unbindStream();
217
+ currentVideo.removeEventListener("loadedmetadata", checkAudio);
218
+ currentVideo.removeEventListener("loadeddata", checkAudio);
219
+ currentVideo.removeEventListener("durationchange", checkAudio);
184
220
  if (audioTracks?.removeEventListener) {
185
221
  audioTracks.removeEventListener("addtrack", checkAudio);
222
+ audioTracks.removeEventListener("removetrack", checkAudio);
186
223
  }
187
224
  };
188
225
  });
@@ -263,11 +300,7 @@
263
300
  );
264
301
 
265
302
  let allowMediaStreamDvr = $derived(
266
- isMediaStreamSource(video) &&
267
- bufferWindowMs !== undefined &&
268
- bufferWindowMs > 0 &&
269
- sourceType !== "whep" &&
270
- sourceType !== "webrtc"
303
+ isMediaStreamSource(video) && bufferWindowMs !== undefined && bufferWindowMs > 0
271
304
  );
272
305
 
273
306
  // Seekable range: prefer controller-derived values (same pattern as React/WC)
@@ -101,7 +101,13 @@
101
101
 
102
102
  {#if isOpen}
103
103
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
104
- <div class="fw-settings-menu" role="menu" aria-label={t("settings")} onkeydown={handleKeyDown}>
104
+ <div
105
+ class="fw-settings-menu"
106
+ role="menu"
107
+ aria-label={t("settings")}
108
+ onkeydown={handleKeyDown}
109
+ tabindex="-1"
110
+ >
105
111
  {#if showModeSelector && onModeChange}
106
112
  <div class="fw-settings-section">
107
113
  <div class="fw-settings-label">{t("mode")}</div>
@@ -85,6 +85,14 @@ export interface PlayerControllerState {
85
85
  subtitlesEnabled: boolean;
86
86
  /** Toast message to display (auto-dismisses) */
87
87
  toast: { message: string; timestamp: number } | null;
88
+ /** Controller-derived seekable start (ms) — reactively updated via seekingStateChange */
89
+ controllerSeekableStart: number;
90
+ /** Controller-derived live edge (ms) — reactively updated via seekingStateChange */
91
+ controllerLiveEdge: number;
92
+ /** Controller-derived canSeek — reactively updated via seekingStateChange */
93
+ controllerCanSeek: boolean;
94
+ /** Controller-derived hasAudio — reactively updated via seekingStateChange */
95
+ controllerHasAudio: boolean;
88
96
  }
89
97
 
90
98
  export interface PlayerControllerStore extends Readable<PlayerControllerState> {
@@ -184,6 +192,10 @@ const initialState: PlayerControllerState = {
184
192
  playbackQuality: null,
185
193
  subtitlesEnabled: false,
186
194
  toast: null,
195
+ controllerSeekableStart: 0,
196
+ controllerLiveEdge: 0,
197
+ controllerCanSeek: false,
198
+ controllerHasAudio: true,
187
199
  };
188
200
 
189
201
  // ============================================================================
@@ -412,6 +424,18 @@ export function createPlayerControllerStore(
412
424
  })
413
425
  );
414
426
 
427
+ unsubscribers.push(
428
+ controller.on("seekingStateChange", (data) => {
429
+ store.update((prev) => ({
430
+ ...prev,
431
+ controllerSeekableStart: data.seekableStart,
432
+ controllerLiveEdge: data.liveEdge,
433
+ controllerCanSeek: data.canSeek,
434
+ controllerHasAudio: data.hasAudio,
435
+ }));
436
+ })
437
+ );
438
+
415
439
  // Error handling events - show toasts/modals
416
440
  unsubscribers.push(
417
441
  controller.on("protocolSwapped", (data) => {