@livepeer-frameworks/player-svelte 0.2.1 → 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.
Files changed (36) hide show
  1. package/README.md +4 -3
  2. package/dist/DevModePanel.svelte +20 -10
  3. package/dist/IdleScreen.svelte +5 -3
  4. package/dist/LoadingScreen.svelte +5 -3
  5. package/dist/Player.svelte +28 -16
  6. package/dist/PlayerControls.svelte +104 -63
  7. package/dist/PlayerControls.svelte.d.ts +4 -0
  8. package/dist/SeekBar.svelte +15 -14
  9. package/dist/SeekBar.svelte.d.ts +4 -4
  10. package/dist/StreamStateOverlay.svelte +6 -4
  11. package/dist/SubtitleRenderer.svelte +3 -3
  12. package/dist/SubtitleRenderer.svelte.d.ts +1 -1
  13. package/dist/controls/FullscreenButton.svelte +5 -3
  14. package/dist/controls/LiveBadge.svelte +5 -3
  15. package/dist/controls/PlayButton.svelte +5 -3
  16. package/dist/controls/SettingsMenu.svelte +12 -4
  17. package/dist/controls/SkipButton.svelte +7 -4
  18. package/dist/controls/VolumeControl.svelte +5 -3
  19. package/dist/stores/playerController.d.ts +12 -4
  20. package/dist/stores/playerController.js +14 -1
  21. package/package.json +5 -5
  22. package/src/DevModePanel.svelte +20 -10
  23. package/src/IdleScreen.svelte +5 -3
  24. package/src/LoadingScreen.svelte +5 -3
  25. package/src/Player.svelte +28 -16
  26. package/src/PlayerControls.svelte +104 -63
  27. package/src/SeekBar.svelte +15 -14
  28. package/src/StreamStateOverlay.svelte +6 -4
  29. package/src/SubtitleRenderer.svelte +3 -3
  30. package/src/controls/FullscreenButton.svelte +5 -3
  31. package/src/controls/LiveBadge.svelte +5 -3
  32. package/src/controls/PlayButton.svelte +5 -3
  33. package/src/controls/SettingsMenu.svelte +12 -4
  34. package/src/controls/SkipButton.svelte +7 -4
  35. package/src/controls/VolumeControl.svelte +5 -3
  36. package/src/stores/playerController.ts +29 -5
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
  ```
@@ -375,7 +375,9 @@
375
375
  {:else}
376
376
  {#each allCombinations as combo, index}
377
377
  {@const isCodecIncompat = (combo as any).codecIncompatible === true}
378
- {@const shouldShow = combo.compatible || isCodecIncompat || showDisabledPlayers}
378
+ {@const isPartial = ((combo as any).missingTracks?.length ?? 0) > 0}
379
+ {@const isWarn = isCodecIncompat || isPartial}
380
+ {@const shouldShow = combo.compatible || isWarn || showDisabledPlayers}
379
381
  {@const isActive = activeComboIndex === index}
380
382
  {@const typeLabel =
381
383
  SOURCE_TYPE_LABELS[combo.sourceType] || combo.sourceType.split("/").pop()}
@@ -396,8 +398,8 @@
396
398
  class={cn(
397
399
  "fw-dev-combo-btn",
398
400
  isActive && "fw-dev-combo-btn--active",
399
- !combo.compatible && !isCodecIncompat && "fw-dev-combo-btn--disabled",
400
- isCodecIncompat && "fw-dev-combo-btn--codec-warn"
401
+ !combo.compatible && !isWarn && "fw-dev-combo-btn--disabled",
402
+ isWarn && "fw-dev-combo-btn--codec-warn"
401
403
  )}
402
404
  >
403
405
  <!-- Rank -->
@@ -406,14 +408,14 @@
406
408
  "fw-dev-combo-rank",
407
409
  isActive
408
410
  ? "fw-dev-combo-rank--active"
409
- : !combo.compatible && !isCodecIncompat
411
+ : !combo.compatible && !isWarn
410
412
  ? "fw-dev-combo-rank--disabled"
411
- : isCodecIncompat
413
+ : isWarn
412
414
  ? "fw-dev-combo-rank--warn"
413
415
  : ""
414
416
  )}
415
417
  >
416
- {combo.compatible ? index + 1 : isCodecIncompat ? "⚠" : "—"}
418
+ {combo.compatible && !isPartial ? index + 1 : isWarn ? "⚠" : "—"}
417
419
  </span>
418
420
  <!-- Player + Protocol -->
419
421
  <span class="fw-dev-combo-name">
@@ -422,8 +424,8 @@
422
424
  <span
423
425
  class={cn(
424
426
  "fw-dev-combo-type",
425
- isCodecIncompat && "fw-dev-combo-type--warn",
426
- !combo.compatible && !isCodecIncompat && "fw-dev-combo-type--disabled"
427
+ isWarn && "fw-dev-combo-type--warn",
428
+ !combo.compatible && !isWarn && "fw-dev-combo-type--disabled"
427
429
  )}>{typeLabel}</span
428
430
  >
429
431
  </span>
@@ -431,9 +433,9 @@
431
433
  <span
432
434
  class={cn(
433
435
  "fw-dev-combo-score",
434
- !combo.compatible && !isCodecIncompat
436
+ !combo.compatible && !isWarn
435
437
  ? "fw-dev-combo-score--disabled"
436
- : isCodecIncompat
438
+ : isWarn
437
439
  ? "fw-dev-combo-score--low"
438
440
  : combo.score >= 2
439
441
  ? "fw-dev-combo-score--high"
@@ -463,6 +465,14 @@
463
465
  </div>
464
466
  {/if}
465
467
  </div>
468
+ {#if combo.note}
469
+ <div class="fw-dev-tooltip-note">{combo.note}</div>
470
+ {/if}
471
+ {#if isPartial}
472
+ <div class="fw-dev-tooltip-note">
473
+ No compatible {(combo as any).missingTracks.join(", ")} codec
474
+ </div>
475
+ {/if}
466
476
  {#if combo.compatible && combo.scoreBreakdown}
467
477
  <div class="fw-dev-tooltip-score">Score: {combo.score.toFixed(2)}</div>
468
478
  <div class="fw-dev-tooltip-row">
@@ -14,6 +14,7 @@
14
14
  -->
15
15
  <script lang="ts">
16
16
  import { onMount, onDestroy, getContext } from "svelte";
17
+ import { readable } from "svelte/store";
17
18
  import type { Readable } from "svelte/store";
18
19
  import { createTranslator, type TranslateFn } from "@livepeer-frameworks/player-core";
19
20
  import type { StreamStatus } from "@livepeer-frameworks/player-core";
@@ -28,9 +29,10 @@
28
29
  onRetry?: () => void;
29
30
  }
30
31
 
31
- const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
32
- const fallbackT = createTranslator({ locale: "en" });
33
- let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
32
+ const translatorStore: Readable<TranslateFn> =
33
+ getContext<Readable<TranslateFn> | undefined>("fw-translator") ??
34
+ readable(createTranslator({ locale: "en" }));
35
+ let t: TranslateFn = $derived($translatorStore);
34
36
 
35
37
  let {
36
38
  status = "OFFLINE",
@@ -13,14 +13,16 @@
13
13
  -->
14
14
  <script lang="ts">
15
15
  import { onMount, onDestroy, getContext } from "svelte";
16
+ import { readable } from "svelte/store";
16
17
  import type { Readable } from "svelte/store";
17
18
  import { createTranslator, type TranslateFn } from "@livepeer-frameworks/player-core";
18
19
  import DvdLogo from "./DvdLogo.svelte";
19
20
  import logomarkAsset from "./assets/logomark.svg";
20
21
 
21
- const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
22
- const fallbackT = createTranslator({ locale: "en" });
23
- let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
22
+ const translatorStore: Readable<TranslateFn> =
23
+ getContext<Readable<TranslateFn> | undefined>("fw-translator") ??
24
+ readable(createTranslator({ locale: "en" }));
25
+ let t: TranslateFn = $derived($translatorStore);
24
26
 
25
27
  interface Props {
26
28
  message?: string;
@@ -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(() => {
@@ -202,7 +206,7 @@
202
206
  isPlaying: false,
203
207
  isPaused: true,
204
208
  isBuffering: false,
205
- isMuted: true,
209
+ isMuted: false,
206
210
  volume: 1,
207
211
  error: null as string | null,
208
212
  isPassiveError: false,
@@ -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
@@ -247,7 +255,7 @@
247
255
  mistUrl: options?.mistUrl,
248
256
  authToken: options?.authToken,
249
257
  autoplay: options?.autoplay !== false,
250
- muted: options?.muted !== false,
258
+ muted: options?.muted === true,
251
259
  controls: options?.stockControls === true,
252
260
  poster: thumbnailUrl || undefined,
253
261
  debug: options?.debug,
@@ -587,7 +595,7 @@
587
595
  >
588
596
  {$translatorStore("retry")}
589
597
  </button>
590
- {#if playerStore?.getController()?.canAttemptFallback()}
598
+ {#if options?.devMode && playerStore?.getController()?.canAttemptFallback()}
591
599
  <button
592
600
  type="button"
593
601
  class="fw-error-btn fw-error-btn--secondary"
@@ -600,17 +608,19 @@
600
608
  {$translatorStore("tryNext")}
601
609
  </button>
602
610
  {/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")}
611
- >
612
- {$translatorStore("reloadPlayer")}
613
- </button>
611
+ {#if options?.devMode}
612
+ <button
613
+ type="button"
614
+ class="fw-error-btn fw-error-btn--secondary"
615
+ onclick={() => {
616
+ playerStore?.clearError();
617
+ playerStore?.reload();
618
+ }}
619
+ aria-label={$translatorStore("reloadPlayer")}
620
+ >
621
+ {$translatorStore("reloadPlayer")}
622
+ </button>
623
+ {/if}
614
624
  </div>
615
625
  </div>
616
626
  </div>
@@ -665,6 +675,8 @@
665
675
  onStatsToggle={() => (isStatsOpen = !isStatsOpen)}
666
676
  isContentLive={storeState.isEffectivelyLive}
667
677
  onJumpToLive={() => playerStore?.getController()?.jumpToLive()}
678
+ controllerSeekableStart={storeState?.controllerSeekableStart ?? 0}
679
+ controllerLiveEdge={storeState?.controllerLiveEdge ?? 0}
668
680
  {activeLocale}
669
681
  onLocaleChange={(l) => {
670
682
  activeLocale = l;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { getContext } from "svelte";
3
+ import { readable } from "svelte/store";
3
4
  import type { Readable } from "svelte/store";
4
5
  import {
5
6
  cn,
@@ -39,9 +40,10 @@
39
40
  } from "./icons";
40
41
 
41
42
  // 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);
43
+ const translatorStore: Readable<TranslateFn> =
44
+ getContext<Readable<TranslateFn> | undefined>("fw-translator") ??
45
+ readable(createTranslator({ locale: "en" }));
46
+ let t: TranslateFn = $derived($translatorStore);
45
47
 
46
48
  // Props - aligned with React PlayerControls
47
49
  interface Props {
@@ -63,6 +65,10 @@
63
65
  onJumpToLive?: () => void;
64
66
  activeLocale?: FwLocale;
65
67
  onLocaleChange?: (locale: FwLocale) => void;
68
+ /** Controller-derived seekable start (ms) — preferred over player direct */
69
+ controllerSeekableStart?: number;
70
+ /** Controller-derived live edge (ms) — preferred over player direct */
71
+ controllerLiveEdge?: number;
66
72
  }
67
73
 
68
74
  let {
@@ -82,6 +88,8 @@
82
88
  onJumpToLive = undefined,
83
89
  activeLocale = undefined,
84
90
  onLocaleChange = undefined,
91
+ controllerSeekableStart = undefined,
92
+ controllerLiveEdge = undefined,
85
93
  }: Props = $props();
86
94
 
87
95
  // Video element discovery
@@ -141,40 +149,77 @@
141
149
 
142
150
  // Audio detection: trust MistServer metadata first, then DOM fallback
143
151
  $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
152
  if (!video) {
151
153
  hasAudio = true;
152
154
  return;
153
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
+ };
154
169
 
155
170
  const checkAudio = () => {
156
- if (video!.srcObject instanceof MediaStream) {
157
- 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;
158
184
  return;
159
185
  }
160
- 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;
161
196
  if (videoAny.audioTracks && videoAny.audioTracks.length !== undefined) {
162
197
  hasAudio = videoAny.audioTracks.length > 0;
163
198
  return;
164
199
  }
200
+
165
201
  hasAudio = true;
166
202
  };
203
+
167
204
  checkAudio();
168
- video.addEventListener("loadedmetadata", checkAudio);
169
- // Safari: audioTracks may be populated after loadedmetadata for HLS streams
170
- 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;
171
210
  if (audioTracks?.addEventListener) {
172
211
  audioTracks.addEventListener("addtrack", checkAudio);
212
+ audioTracks.addEventListener("removetrack", checkAudio);
173
213
  }
214
+
174
215
  return () => {
175
- video!.removeEventListener("loadedmetadata", checkAudio);
216
+ unbindStream();
217
+ currentVideo.removeEventListener("loadedmetadata", checkAudio);
218
+ currentVideo.removeEventListener("loadeddata", checkAudio);
219
+ currentVideo.removeEventListener("durationchange", checkAudio);
176
220
  if (audioTracks?.removeEventListener) {
177
221
  audioTracks.removeEventListener("addtrack", checkAudio);
222
+ audioTracks.removeEventListener("removetrack", checkAudio);
178
223
  }
179
224
  };
180
225
  });
@@ -224,16 +269,22 @@
224
269
  let isWebRTC = $derived(isMediaStreamSource(video));
225
270
  let supportsPlaybackRate = $derived(coreSupportsPlaybackRate(video));
226
271
  function deriveBufferWindowMs(
227
- tracks?: Record<string, { firstms?: number; lastms?: number }>
272
+ tracks?: Record<string, { type?: string; firstms?: number; lastms?: number }>
228
273
  ): number | undefined {
229
274
  if (!tracks) return undefined;
230
- const list = Object.values(tracks);
231
- if (list.length === 0) return undefined;
232
- const firstmsValues = list.map((t) => t.firstms).filter((v): v is number => v !== undefined);
233
- const lastmsValues = list.map((t) => t.lastms).filter((v): v is number => v !== undefined);
275
+ const trackValues = Object.values(tracks).filter(
276
+ (t) => t.type !== "meta" && (t.lastms === undefined || t.lastms > 0)
277
+ );
278
+ if (trackValues.length === 0) return undefined;
279
+ const firstmsValues = trackValues
280
+ .map((t) => t.firstms)
281
+ .filter((v): v is number => v !== undefined);
282
+ const lastmsValues = trackValues
283
+ .map((t) => t.lastms)
284
+ .filter((v): v is number => v !== undefined);
234
285
  if (firstmsValues.length === 0 || lastmsValues.length === 0) return undefined;
235
- const firstms = Math.max(...firstmsValues);
236
- const lastms = Math.min(...lastmsValues);
286
+ const firstms = Math.min(...firstmsValues);
287
+ const lastms = Math.max(...lastmsValues);
237
288
  const window = lastms - firstms;
238
289
  if (!Number.isFinite(window) || window <= 0) return undefined;
239
290
  return window;
@@ -243,48 +294,38 @@
243
294
  mistStreamInfo?.meta?.buffer_window ??
244
295
  deriveBufferWindowMs(
245
296
  mistStreamInfo?.meta?.tracks as
246
- | Record<string, { firstms?: number; lastms?: number }>
297
+ | Record<string, { type?: string; firstms?: number; lastms?: number }>
247
298
  | undefined
248
299
  )
249
300
  );
250
301
 
251
- function getPlayerSeekableRange(): { seekableStart: number; liveEdge: number } | null {
252
- const player = globalPlayerManager.getCurrentPlayer();
253
- if (player && typeof (player as any).getSeekableRange === "function") {
254
- const range = (player as any).getSeekableRange();
255
- if (
256
- range &&
257
- Number.isFinite(range.start) &&
258
- Number.isFinite(range.end) &&
259
- range.end >= range.start
260
- ) {
261
- return { seekableStart: range.start, liveEdge: range.end };
262
- }
263
- }
264
- return null;
265
- }
266
-
267
302
  let allowMediaStreamDvr = $derived(
268
- isMediaStreamSource(video) &&
269
- bufferWindowMs !== undefined &&
270
- bufferWindowMs > 0 &&
271
- sourceType !== "whep" &&
272
- sourceType !== "webrtc"
303
+ isMediaStreamSource(video) && bufferWindowMs !== undefined && bufferWindowMs > 0
273
304
  );
274
305
 
275
- // Seekable range using core calculation (allow player override)
276
- let seekableRange = $derived.by(
277
- () =>
278
- getPlayerSeekableRange() ??
279
- calculateSeekableRange({
280
- isLive,
281
- video,
282
- mistStreamInfo,
283
- currentTime,
284
- duration,
285
- allowMediaStreamDvr,
286
- })
306
+ // Seekable range: prefer controller-derived values (same pattern as React/WC)
307
+ let calcRange = $derived(
308
+ calculateSeekableRange({
309
+ isLive,
310
+ video,
311
+ mistStreamInfo,
312
+ currentTime,
313
+ duration,
314
+ allowMediaStreamDvr,
315
+ })
316
+ );
317
+ let useControllerRange = $derived(
318
+ Number.isFinite(controllerSeekableStart) &&
319
+ Number.isFinite(controllerLiveEdge) &&
320
+ (controllerLiveEdge as number) >= (controllerSeekableStart as number) &&
321
+ ((controllerLiveEdge as number) > 0 || (controllerSeekableStart as number) > 0)
287
322
  );
323
+ let seekableRange = $derived({
324
+ seekableStart: useControllerRange
325
+ ? (controllerSeekableStart as number)
326
+ : calcRange.seekableStart,
327
+ liveEdge: useControllerRange ? (controllerLiveEdge as number) : calcRange.liveEdge,
328
+ });
288
329
  let seekableStart = $derived(seekableRange.seekableStart);
289
330
  let liveEdge = $derived(seekableRange.liveEdge);
290
331
  let hasDvrWindow = $derived(
@@ -422,24 +463,24 @@
422
463
  }
423
464
 
424
465
  function handleSkipBack() {
425
- const newTime = Math.max(0, currentTime - 10);
466
+ const newTime = Math.max(0, currentTime - 10000);
426
467
  if (onseek) {
427
468
  onseek(newTime);
428
469
  return;
429
470
  }
430
471
  const v = findVideoElement();
431
- if (v) v.currentTime = newTime;
472
+ if (v) v.currentTime = newTime / 1000;
432
473
  }
433
474
 
434
475
  function handleSkipForward() {
435
- const maxTime = Number.isFinite(duration) ? duration : currentTime + 10;
436
- const newTime = Math.min(maxTime, currentTime + 10);
476
+ const maxTime = Number.isFinite(duration) ? duration : currentTime + 10000;
477
+ const newTime = Math.min(maxTime, currentTime + 10000);
437
478
  if (onseek) {
438
479
  onseek(newTime);
439
480
  return;
440
481
  }
441
482
  const v = findVideoElement();
442
- if (v) v.currentTime = newTime;
483
+ if (v) v.currentTime = newTime / 1000;
443
484
  }
444
485
 
445
486
  function handleMute() {
@@ -592,7 +633,7 @@
592
633
  if (onseek) {
593
634
  onseek(time);
594
635
  } else if (video) {
595
- video.currentTime = time;
636
+ video.currentTime = time / 1000;
596
637
  }
597
638
  }}
598
639
  />
@@ -19,6 +19,10 @@ interface Props {
19
19
  onJumpToLive?: () => void;
20
20
  activeLocale?: FwLocale;
21
21
  onLocaleChange?: (locale: FwLocale) => void;
22
+ /** Controller-derived seekable start (ms) — preferred over player direct */
23
+ controllerSeekableStart?: number;
24
+ /** Controller-derived live edge (ms) — preferred over player direct */
25
+ controllerLiveEdge?: number;
22
26
  }
23
27
  declare const PlayerControls: import("svelte").Component<Props, {}, "">;
24
28
  type PlayerControls = ReturnType<typeof PlayerControls>;
@@ -6,9 +6,9 @@
6
6
  import { cn } from "@livepeer-frameworks/player-core";
7
7
 
8
8
  interface Props {
9
- /** Current playback time in seconds */
9
+ /** Current playback time in milliseconds */
10
10
  currentTime: number;
11
- /** Total duration in seconds */
11
+ /** Total duration in milliseconds */
12
12
  duration: number;
13
13
  /** Buffered time ranges from video element */
14
14
  buffered?: TimeRanges;
@@ -20,9 +20,9 @@
20
20
  class?: string;
21
21
  /** Whether this is a live stream */
22
22
  isLive?: boolean;
23
- /** For live: start of seekable DVR window (seconds) */
23
+ /** For live: start of seekable DVR window (ms) */
24
24
  seekableStart?: number;
25
- /** For live: current live edge position (seconds) */
25
+ /** For live: current live edge position (ms) */
26
26
  liveEdge?: number;
27
27
  /** Defer seeking until drag release */
28
28
  commitOnRelease?: boolean;
@@ -80,8 +80,9 @@
80
80
 
81
81
  const segments: Array<{ startPercent: number; endPercent: number }> = [];
82
82
  for (let i = 0; i < buffered.length; i++) {
83
- const start = buffered.start(i);
84
- const end = buffered.end(i);
83
+ // buffered TimeRanges are in seconds (browser API), convert to ms
84
+ const start = buffered.start(i) * 1000;
85
+ const end = buffered.end(i) * 1000;
85
86
 
86
87
  const relativeStart = start - rangeStart;
87
88
  const relativeEnd = end - rangeStart;
@@ -95,9 +96,9 @@
95
96
  });
96
97
 
97
98
  // Format time as MM:SS or HH:MM:SS
98
- function formatTime(seconds: number): string {
99
- if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
100
- const total = Math.floor(seconds);
99
+ function formatTime(ms: number): string {
100
+ if (!Number.isFinite(ms) || ms < 0) return "0:00";
101
+ const total = Math.floor(ms / 1000);
101
102
  const hours = Math.floor(total / 3600);
102
103
  const minutes = Math.floor((total % 3600) / 60);
103
104
  const secs = total % 60;
@@ -108,10 +109,10 @@
108
109
  }
109
110
 
110
111
  // Format relative time for live streams
111
- function formatLiveTime(seconds: number, edge: number): string {
112
- const behindSeconds = edge - seconds;
113
- if (behindSeconds < 1) return "LIVE";
114
- const total = Math.floor(behindSeconds);
112
+ function formatLiveTime(ms: number, edgeMs: number): string {
113
+ const behindMs = edgeMs - ms;
114
+ if (behindMs < 1000) return "LIVE";
115
+ const total = Math.floor(behindMs / 1000);
115
116
  const hours = Math.floor(total / 3600);
116
117
  const minutes = Math.floor((total % 3600) / 60);
117
118
  const secs = total % 60;
@@ -218,7 +219,7 @@
218
219
  // Handle keyboard navigation for accessibility
219
220
  function handleKeyDown(e: KeyboardEvent) {
220
221
  if (disabled) return;
221
- const step = e.shiftKey ? 10 : 5; // 5s default, 10s with shift
222
+ const step = e.shiftKey ? 10000 : 5000;
222
223
  const rangeEnd = isLive ? effectiveLiveEdge : duration;
223
224
  const rangeStart = isLive ? seekableStart : 0;
224
225
 
@@ -1,7 +1,7 @@
1
1
  interface Props {
2
- /** Current playback time in seconds */
2
+ /** Current playback time in milliseconds */
3
3
  currentTime: number;
4
- /** Total duration in seconds */
4
+ /** Total duration in milliseconds */
5
5
  duration: number;
6
6
  /** Buffered time ranges from video element */
7
7
  buffered?: TimeRanges;
@@ -13,9 +13,9 @@ interface Props {
13
13
  class?: string;
14
14
  /** Whether this is a live stream */
15
15
  isLive?: boolean;
16
- /** For live: start of seekable DVR window (seconds) */
16
+ /** For live: start of seekable DVR window (ms) */
17
17
  seekableStart?: number;
18
- /** For live: current live edge position (seconds) */
18
+ /** For live: current live edge position (ms) */
19
19
  liveEdge?: number;
20
20
  /** Defer seeking until drag release */
21
21
  commitOnRelease?: boolean;
@@ -11,6 +11,7 @@
11
11
  -->
12
12
  <script lang="ts">
13
13
  import { getContext } from "svelte";
14
+ import { readable } from "svelte/store";
14
15
  import type { Readable } from "svelte/store";
15
16
  import { createTranslator, type TranslateFn } from "@livepeer-frameworks/player-core";
16
17
  import type { StreamStatus } from "@livepeer-frameworks/player-core";
@@ -39,9 +40,10 @@
39
40
  class: className = "",
40
41
  }: Props = $props();
41
42
 
42
- const translatorCtx = getContext<Readable<TranslateFn> | undefined>("fw-translator");
43
- const fallbackT = createTranslator({ locale: "en" });
44
- let t: TranslateFn = $derived(translatorCtx ? $translatorCtx : fallbackT);
43
+ const translatorStore: Readable<TranslateFn> =
44
+ getContext<Readable<TranslateFn> | undefined>("fw-translator") ??
45
+ readable(createTranslator({ locale: "en" }));
46
+ let t: TranslateFn = $derived($translatorStore);
45
47
 
46
48
  // Computed states
47
49
  let showRetry = $derived(status === "ERROR" || status === "INVALID" || status === "OFFLINE");
@@ -136,7 +138,7 @@
136
138
  {#if showProgress && percentage !== undefined}
137
139
  <div style="margin-top: 0.75rem;">
138
140
  <div class="progress-bar">
139
- <div class="progress-fill" style="width: {Math.min(100, percentage)};"></div>
141
+ <div class="progress-fill" style="width: {Math.min(100, percentage)}%;"></div>
140
142
  </div>
141
143
  <p
142
144
  style="margin-top: 0.375rem; font-size: 0.75rem; font-family: monospace; color: hsl(var(--tn-fg-dark, 233 23% 60%));"