@scarlett-player/embed 1.5.1 → 1.7.0

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.
@@ -149,6 +149,7 @@ class StateManager {
149
149
  this.signals = /* @__PURE__ */ new Map();
150
150
  this.changeSubscribers = /* @__PURE__ */ new Set();
151
151
  this.definedDefaults = /* @__PURE__ */ new Map();
152
+ this.destroyed = false;
152
153
  this.initializeSignals(initialState);
153
154
  }
154
155
  /**
@@ -209,6 +210,7 @@ class StateManager {
209
210
  *
210
211
  * @param key - State property key
211
212
  * @returns Signal for the property
213
+ * @throws If the manager has been destroyed, or the key was never registered
212
214
  *
213
215
  * @example
214
216
  * ```ts
@@ -218,6 +220,9 @@ class StateManager {
218
220
  * ```
219
221
  */
220
222
  get(key) {
223
+ if (this.destroyed) {
224
+ throw new Error(`[StateManager] Manager is destroyed (reading '${key}')`);
225
+ }
221
226
  const stateSignal = this.signals.get(key);
222
227
  if (!stateSignal) {
223
228
  throw new Error(`[StateManager] Unknown state key: ${key}`);
@@ -229,6 +234,7 @@ class StateManager {
229
234
  *
230
235
  * @param key - State property key
231
236
  * @returns Current value
237
+ * @throws If the manager has been destroyed, or the key was never registered
232
238
  *
233
239
  * @example
234
240
  * ```ts
@@ -396,6 +402,11 @@ class StateManager {
396
402
  /**
397
403
  * Destroy the state manager and cleanup all signals.
398
404
  *
405
+ * After this, every read or write ({@link get}, {@link getValue},
406
+ * {@link set}) throws a destroyed-specific error. Returning last-known
407
+ * values instead was considered and rejected: it silently masks the
408
+ * lifecycle bugs this throw exposes.
409
+ *
399
410
  * @example
400
411
  * ```ts
401
412
  * state.destroy();
@@ -405,6 +416,7 @@ class StateManager {
405
416
  this.signals.forEach((stateSignal) => stateSignal.destroy());
406
417
  this.signals.clear();
407
418
  this.changeSubscribers.clear();
419
+ this.destroyed = true;
408
420
  }
409
421
  }
410
422
  const DEFAULT_OPTIONS = {
@@ -1629,6 +1641,7 @@ class ScarlettPlayer {
1629
1641
  this.eventBus.on("media:load-request", async ({ src, autoplay }) => {
1630
1642
  if (this.stateManager.getValue("chromecastActive")) return;
1631
1643
  await this.load(src);
1644
+ if (this.destroyed) return;
1632
1645
  if (autoplay !== false) {
1633
1646
  await this.play();
1634
1647
  }
@@ -1637,12 +1650,14 @@ class ScarlettPlayer {
1637
1650
  const was_live = this.stateManager.getValue("live");
1638
1651
  const resume_at = this.stateManager.getValue("currentTime");
1639
1652
  await this.load(src);
1653
+ if (this.destroyed) return;
1640
1654
  if (this.stateManager.getValue("error")) return;
1641
1655
  if (was_live) {
1642
1656
  this.seekToLive();
1643
1657
  } else if (resume_at > 0) {
1644
1658
  this.seek(resume_at);
1645
1659
  }
1660
+ if (this.destroyed) return;
1646
1661
  await this.play();
1647
1662
  });
1648
1663
  if (this.initialSrc) {
@@ -2140,6 +2155,7 @@ class ScarlettPlayer {
2140
2155
  return;
2141
2156
  }
2142
2157
  this.logger.info("Destroying player");
2158
+ this.loadGeneration++;
2143
2159
  if (this.seekResumeTimeout !== null) {
2144
2160
  clearTimeout(this.seekResumeTimeout);
2145
2161
  this.seekResumeTimeout = null;
@@ -2287,6 +2303,15 @@ var __export = (target, all) => {
2287
2303
  for (var name in all)
2288
2304
  __defProp(target, name, { get: all[name], enumerable: true });
2289
2305
  };
2306
+ function sanitizeUrl(url) {
2307
+ if (!url) return void 0;
2308
+ try {
2309
+ const parsed = new URL(url);
2310
+ return `${parsed.origin}${parsed.pathname}`;
2311
+ } catch {
2312
+ return void 0;
2313
+ }
2314
+ }
2290
2315
  function formatLevel(level) {
2291
2316
  if (level.name) {
2292
2317
  return level.name;
@@ -2708,6 +2733,8 @@ function createHLSPluginWith(loader, variant, config) {
2708
2733
  let reconnectWindowStart = 0;
2709
2734
  let reconnectResumePosition = 0;
2710
2735
  let onlineListener = null;
2736
+ let reconnectTriggerError = null;
2737
+ let reconnectExhausted = false;
2711
2738
  const getOrCreateVideo = () => {
2712
2739
  if (video) return video;
2713
2740
  const existing = api?.container.querySelector("video");
@@ -2816,6 +2843,22 @@ function createHLSPluginWith(loader, variant, config) {
2816
2843
  return ErrorCode.PLAYBACK_FAILED;
2817
2844
  }
2818
2845
  };
2846
+ const buildErrorDetail = (error, retriesExhausted) => {
2847
+ const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
2848
+ const detail = {
2849
+ type: error.type,
2850
+ retriesExhausted,
2851
+ attempts
2852
+ };
2853
+ if (typeof error.response?.code === "number" && error.response.code > 0) {
2854
+ detail.httpStatus = error.response.code;
2855
+ }
2856
+ const url = sanitizeUrl(error.url);
2857
+ if (url) {
2858
+ detail.url = url;
2859
+ }
2860
+ return detail;
2861
+ };
2819
2862
  const emitFatalError = (error, retriesExhausted) => {
2820
2863
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
2821
2864
  api?.logger.error(message, { type: error.type, details: error.details });
@@ -2825,7 +2868,8 @@ function createHLSPluginWith(loader, variant, config) {
2825
2868
  code: mapFatalErrorCode(error),
2826
2869
  message,
2827
2870
  fatal: true,
2828
- timestamp: Date.now()
2871
+ timestamp: Date.now(),
2872
+ detail: buildErrorDetail(error, retriesExhausted)
2829
2873
  });
2830
2874
  maybeScheduleReconnect(error);
2831
2875
  };
@@ -2902,6 +2946,62 @@ function createHLSPluginWith(loader, variant, config) {
2902
2946
  }
2903
2947
  return false;
2904
2948
  };
2949
+ const handleNativeFatalError = (error, resumePosition) => {
2950
+ const is_network = error.type === "network";
2951
+ const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
2952
+ const used = is_network ? networkRetryCount : mediaRetryCount;
2953
+ if (!currentSrc || used >= max_retries) {
2954
+ emitFatalError(error, used >= max_retries);
2955
+ return;
2956
+ }
2957
+ const resume_position = resumePosition ?? video?.currentTime ?? 0;
2958
+ if (is_network) {
2959
+ networkRetryCount++;
2960
+ } else {
2961
+ mediaRetryCount++;
2962
+ }
2963
+ const attempt = used + 1;
2964
+ const delay = getRetryDelay(attempt - 1);
2965
+ api?.logger.info(
2966
+ `Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
2967
+ );
2968
+ api?.emit(is_network ? "error:network" : "error:media", {
2969
+ error: new Error(error.details)
2970
+ });
2971
+ if (retryTimeout) {
2972
+ clearTimeout(retryTimeout);
2973
+ }
2974
+ const retry_session = loadSession;
2975
+ retryTimeout = setTimeout(() => {
2976
+ if (retry_session !== loadSession) return;
2977
+ void recoverNative(error, resume_position);
2978
+ }, delay);
2979
+ };
2980
+ const recoverNative = async (error, resumePosition) => {
2981
+ if (!currentSrc) return;
2982
+ const session = ++loadSession;
2983
+ const saved_src = currentSrc;
2984
+ const was_live = api?.getState("live") ?? false;
2985
+ try {
2986
+ teardownPipeline(new Error("HLS load cancelled: native error recovery"));
2987
+ api?.setState("playbackState", "loading");
2988
+ await loadNative(saved_src);
2989
+ if (session !== loadSession) return;
2990
+ if (!was_live && video && resumePosition > 0) {
2991
+ video.currentTime = resumePosition;
2992
+ }
2993
+ api?.setState("playbackState", "ready");
2994
+ api?.setState("buffering", false);
2995
+ try {
2996
+ await video?.play();
2997
+ } catch {
2998
+ }
2999
+ } catch {
3000
+ if (session !== loadSession) return;
3001
+ api?.logger.warn("Native error recovery attempt failed");
3002
+ handleNativeFatalError(error, resumePosition);
3003
+ }
3004
+ };
2905
3005
  const loadNative = async (src) => {
2906
3006
  const session = loadSession;
2907
3007
  const videoEl = getOrCreateVideo();
@@ -2943,12 +3043,24 @@ function createHLSPluginWith(loader, variant, config) {
2943
3043
  const media_error = videoEl.error;
2944
3044
  const hls_error = {
2945
3045
  type: media_error?.code === MediaError.MEDIA_ERR_NETWORK ? "network" : "media",
2946
- details: media_error?.message || "Native HLS playback error"
3046
+ details: media_error?.message || "Native HLS playback error",
3047
+ fatal: true
2947
3048
  };
2948
- emitFatalError(hls_error, false);
3049
+ handleNativeFatalError(hls_error);
2949
3050
  };
2950
3051
  videoEl.addEventListener("error", onFatalVideoError);
2951
- const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
3052
+ const onPlayingResetBudget = () => {
3053
+ if (networkRetryCount > 0 || mediaRetryCount > 0) {
3054
+ api?.logger.debug("Native playback recovered, resetting retry budgets");
3055
+ networkRetryCount = 0;
3056
+ mediaRetryCount = 0;
3057
+ }
3058
+ };
3059
+ videoEl.addEventListener("playing", onPlayingResetBudget);
3060
+ const removeFatalListener = () => {
3061
+ videoEl.removeEventListener("error", onFatalVideoError);
3062
+ videoEl.removeEventListener("playing", onPlayingResetBudget);
3063
+ };
2952
3064
  const previous_cleanup = cleanupVideoEvents;
2953
3065
  cleanupVideoEvents = () => {
2954
3066
  removeFatalListener();
@@ -3073,12 +3185,38 @@ function createHLSPluginWith(loader, variant, config) {
3073
3185
  reconnectAttempts = 0;
3074
3186
  reconnectWindowStart = 0;
3075
3187
  reconnectResumePosition = 0;
3188
+ reconnectTriggerError = null;
3189
+ reconnectExhausted = false;
3190
+ };
3191
+ const emitReconnectExhausted = (elapsedMs, windowMs) => {
3192
+ if (reconnectExhausted) return;
3193
+ reconnectExhausted = true;
3194
+ const attempts = reconnectAttempts;
3195
+ const trigger = reconnectTriggerError;
3196
+ api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
3197
+ api?.setState("playbackState", "error");
3198
+ api?.setState("buffering", false);
3199
+ api?.emit("error", {
3200
+ code: trigger ? mapFatalErrorCode(trigger) : ErrorCode.PLAYBACK_FAILED,
3201
+ message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
3202
+ fatal: true,
3203
+ timestamp: Date.now(),
3204
+ detail: {
3205
+ type: trigger?.type ?? "other",
3206
+ retriesExhausted: true,
3207
+ attempts,
3208
+ reconnectExhausted: true
3209
+ }
3210
+ });
3076
3211
  };
3077
3212
  const scheduleReconnectAttempt = () => {
3213
+ if (reconnectExhausted) return;
3078
3214
  if (reconnectTimer) return;
3079
3215
  const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
3080
- if (Date.now() - reconnectWindowStart > window_ms) {
3216
+ const elapsed_ms = Date.now() - reconnectWindowStart;
3217
+ if (elapsed_ms > window_ms) {
3081
3218
  api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
3219
+ emitReconnectExhausted(elapsed_ms, window_ms);
3082
3220
  return;
3083
3221
  }
3084
3222
  const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
@@ -3086,7 +3224,12 @@ function createHLSPluginWith(loader, variant, config) {
3086
3224
  const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
3087
3225
  const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
3088
3226
  api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
3089
- api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
3227
+ api?.emit("error:reconnecting", {
3228
+ attempt: reconnectAttempts + 1,
3229
+ delayMs: delay,
3230
+ elapsedMs: elapsed_ms,
3231
+ windowMs: window_ms
3232
+ });
3090
3233
  reconnectTimer = setTimeout(() => {
3091
3234
  reconnectTimer = null;
3092
3235
  void attemptReconnect();
@@ -3099,6 +3242,7 @@ function createHLSPluginWith(loader, variant, config) {
3099
3242
  if (reconnectWindowStart === 0) {
3100
3243
  reconnectWindowStart = Date.now();
3101
3244
  reconnectResumePosition = video?.currentTime ?? 0;
3245
+ reconnectTriggerError = error;
3102
3246
  }
3103
3247
  scheduleReconnectAttempt();
3104
3248
  };
@@ -3130,7 +3274,10 @@ function createHLSPluginWith(loader, variant, config) {
3130
3274
  }
3131
3275
  api.setState("playbackState", "ready");
3132
3276
  api.setState("buffering", false);
3133
- api.emit("error:recovered", void 0);
3277
+ api.emit("error:recovered", {
3278
+ attempt: reconnectAttempts,
3279
+ elapsedMs: Date.now() - reconnectWindowStart
3280
+ });
3134
3281
  api.logger.info("Auto-reconnect succeeded");
3135
3282
  cancelReconnect();
3136
3283
  try {
@@ -4220,6 +4367,272 @@ function createAudioUIPlugin(config) {
4220
4367
  };
4221
4368
  return plugin;
4222
4369
  }
4370
+ var PLAYLIST_ICONS = {
4371
+ previous: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6V6zm3.5 6L18 6v12l-8.5-6z"/></svg>',
4372
+ next: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M16 6h2v12h-2V6zM6 6l8.5 6L6 18V6z"/></svg>',
4373
+ list: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h11v2H3V6zm0 5h11v2H3v-2zm0 5h7v2H3v-2zm13-1.5V8l6 3.5-6 3.5z"/></svg>'
4374
+ };
4375
+ var PlaylistSkipButton = class {
4376
+ constructor(plugin, direction) {
4377
+ this.plugin = plugin;
4378
+ this.direction = direction;
4379
+ this.clickHandler = () => {
4380
+ if (this.direction === "next") {
4381
+ this.plugin.next();
4382
+ } else {
4383
+ this.plugin.previous();
4384
+ }
4385
+ };
4386
+ const label = direction === "next" ? "Next item" : "Previous item";
4387
+ this.el = document.createElement("button");
4388
+ this.el.type = "button";
4389
+ this.el.className = `sp-control sp-playlist-skip sp-playlist-skip--${direction}`;
4390
+ this.el.setAttribute("aria-label", label);
4391
+ this.el.setAttribute("title", label);
4392
+ this.el.innerHTML = PLAYLIST_ICONS[direction];
4393
+ this.el.addEventListener("click", this.clickHandler);
4394
+ }
4395
+ render() {
4396
+ return this.el;
4397
+ }
4398
+ update() {
4399
+ const state = this.plugin.getState();
4400
+ if (state.tracks.length <= 1) {
4401
+ this.el.style.display = "none";
4402
+ return;
4403
+ }
4404
+ this.el.style.display = "";
4405
+ const enabled = this.direction === "next" ? state.hasNext : state.hasPrevious;
4406
+ this.el.disabled = !enabled;
4407
+ }
4408
+ destroy() {
4409
+ this.el.removeEventListener("click", this.clickHandler);
4410
+ this.el.remove();
4411
+ }
4412
+ };
4413
+ var PlaylistPanel = class {
4414
+ constructor(plugin, options) {
4415
+ this.plugin = plugin;
4416
+ this.options = options;
4417
+ this.open = false;
4418
+ this.renderedIds = "";
4419
+ this.toggleHandler = (event) => {
4420
+ event.stopPropagation();
4421
+ this.setOpen(!this.open);
4422
+ };
4423
+ this.documentClickHandler = () => {
4424
+ if (this.open) this.setOpen(false);
4425
+ };
4426
+ this.keydownHandler = (event) => {
4427
+ if (event.key === "Escape" && this.open) {
4428
+ this.setOpen(false);
4429
+ this.button.focus();
4430
+ }
4431
+ };
4432
+ this.el = document.createElement("div");
4433
+ this.el.className = "sp-playlist";
4434
+ this.button = document.createElement("button");
4435
+ this.button.type = "button";
4436
+ this.button.className = "sp-control sp-playlist__button";
4437
+ this.button.setAttribute("aria-label", "Playlist");
4438
+ this.button.setAttribute("title", "Playlist");
4439
+ this.button.setAttribute("aria-haspopup", "true");
4440
+ this.button.setAttribute("aria-expanded", "false");
4441
+ this.button.innerHTML = PLAYLIST_ICONS.list;
4442
+ this.panel = document.createElement("div");
4443
+ this.panel.className = "sp-playlist__panel";
4444
+ this.panel.setAttribute("role", "menu");
4445
+ this.panel.hidden = true;
4446
+ this.el.appendChild(this.button);
4447
+ this.el.appendChild(this.panel);
4448
+ this.button.addEventListener("click", this.toggleHandler);
4449
+ document.addEventListener("click", this.documentClickHandler);
4450
+ document.addEventListener("keydown", this.keydownHandler);
4451
+ }
4452
+ render() {
4453
+ return this.el;
4454
+ }
4455
+ update() {
4456
+ const state = this.plugin.getState();
4457
+ if (state.tracks.length <= 1) {
4458
+ this.el.style.display = "none";
4459
+ return;
4460
+ }
4461
+ this.el.style.display = "";
4462
+ const signature = state.tracks.map((track) => track.id).join("|");
4463
+ if (signature !== this.renderedIds) {
4464
+ this.renderedIds = signature;
4465
+ this.renderPanel(state.tracks, state.currentIndex);
4466
+ return;
4467
+ }
4468
+ this.markActive(state.currentIndex);
4469
+ }
4470
+ destroy() {
4471
+ this.button.removeEventListener("click", this.toggleHandler);
4472
+ document.removeEventListener("click", this.documentClickHandler);
4473
+ document.removeEventListener("keydown", this.keydownHandler);
4474
+ this.el.remove();
4475
+ }
4476
+ setOpen(open) {
4477
+ this.open = open;
4478
+ this.panel.hidden = !open;
4479
+ this.button.setAttribute("aria-expanded", String(open));
4480
+ this.el.classList.toggle("sp-playlist--open", open);
4481
+ }
4482
+ markActive(currentIndex) {
4483
+ const items = this.panel.querySelectorAll(".sp-playlist__item");
4484
+ items.forEach((item, index) => {
4485
+ item.classList.toggle("sp-playlist__item--active", index === currentIndex);
4486
+ item.setAttribute("aria-current", index === currentIndex ? "true" : "false");
4487
+ });
4488
+ }
4489
+ renderPanel(tracks, currentIndex) {
4490
+ this.panel.textContent = "";
4491
+ tracks.forEach((track, index) => {
4492
+ const item = document.createElement("button");
4493
+ item.type = "button";
4494
+ item.className = "sp-playlist__item";
4495
+ item.setAttribute("role", "menuitem");
4496
+ item.setAttribute("aria-current", index === currentIndex ? "true" : "false");
4497
+ if (index === currentIndex) {
4498
+ item.classList.add("sp-playlist__item--active");
4499
+ }
4500
+ const position = document.createElement("span");
4501
+ position.className = "sp-playlist__position";
4502
+ position.textContent = String(index + 1);
4503
+ const text = document.createElement("span");
4504
+ text.className = "sp-playlist__text";
4505
+ const title = document.createElement("span");
4506
+ title.className = "sp-playlist__title";
4507
+ title.textContent = track.title ?? `Item ${index + 1}`;
4508
+ text.appendChild(title);
4509
+ if (track.artist) {
4510
+ const artist = document.createElement("span");
4511
+ artist.className = "sp-playlist__artist";
4512
+ artist.textContent = track.artist;
4513
+ text.appendChild(artist);
4514
+ }
4515
+ item.appendChild(position);
4516
+ item.appendChild(text);
4517
+ item.addEventListener("click", (event) => {
4518
+ event.stopPropagation();
4519
+ this.options.onSelect(index);
4520
+ this.setOpen(false);
4521
+ });
4522
+ this.panel.appendChild(item);
4523
+ });
4524
+ }
4525
+ };
4526
+ var STYLE_ID = "sp-playlist-styles";
4527
+ var styles = `
4528
+ .sp-playlist-skip[disabled] {
4529
+ opacity: 0.4;
4530
+ cursor: default;
4531
+ }
4532
+
4533
+ .sp-playlist {
4534
+ position: relative;
4535
+ display: inline-flex;
4536
+ }
4537
+
4538
+ .sp-playlist__button svg,
4539
+ .sp-playlist-skip svg {
4540
+ width: 20px;
4541
+ height: 20px;
4542
+ }
4543
+
4544
+ .sp-playlist__panel {
4545
+ position: absolute;
4546
+ bottom: calc(100% + 8px);
4547
+ right: 0;
4548
+ z-index: 20;
4549
+ display: flex;
4550
+ flex-direction: column;
4551
+ min-width: 240px;
4552
+ max-width: 320px;
4553
+ max-height: 300px;
4554
+ overflow-y: auto;
4555
+ padding: 4px;
4556
+ border-radius: 8px;
4557
+ background: rgba(20, 20, 20, 0.96);
4558
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
4559
+ }
4560
+
4561
+ .sp-playlist__panel[hidden] {
4562
+ display: none;
4563
+ }
4564
+
4565
+ .sp-playlist__item {
4566
+ display: flex;
4567
+ gap: 10px;
4568
+ align-items: flex-start;
4569
+ width: 100%;
4570
+ padding: 8px 10px;
4571
+ border: 0;
4572
+ border-radius: 6px;
4573
+ background: transparent;
4574
+ color: #fff;
4575
+ font: inherit;
4576
+ text-align: left;
4577
+ cursor: pointer;
4578
+ }
4579
+
4580
+ .sp-playlist__item:hover,
4581
+ .sp-playlist__item:focus-visible {
4582
+ background: rgba(255, 255, 255, 0.12);
4583
+ }
4584
+
4585
+ .sp-playlist__item--active {
4586
+ background: rgba(255, 255, 255, 0.08);
4587
+ }
4588
+
4589
+ .sp-playlist__item--active .sp-playlist__title {
4590
+ font-weight: 600;
4591
+ }
4592
+
4593
+ .sp-playlist__position {
4594
+ flex: 0 0 auto;
4595
+ min-width: 18px;
4596
+ color: rgba(255, 255, 255, 0.7);
4597
+ font-variant-numeric: tabular-nums;
4598
+ font-size: 12px;
4599
+ line-height: 18px;
4600
+ }
4601
+
4602
+ .sp-playlist__text {
4603
+ display: flex;
4604
+ flex-direction: column;
4605
+ min-width: 0;
4606
+ }
4607
+
4608
+ .sp-playlist__title {
4609
+ font-size: 13px;
4610
+ line-height: 18px;
4611
+ }
4612
+
4613
+ .sp-playlist__artist {
4614
+ color: rgba(255, 255, 255, 0.6);
4615
+ font-size: 11px;
4616
+ line-height: 16px;
4617
+ }
4618
+
4619
+ @media (max-width: 480px) {
4620
+ .sp-playlist__panel {
4621
+ min-width: 200px;
4622
+ max-width: 76vw;
4623
+ }
4624
+ }
4625
+ `;
4626
+ function injectStyles() {
4627
+ if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
4628
+ return null;
4629
+ }
4630
+ const el = document.createElement("style");
4631
+ el.id = STYLE_ID;
4632
+ el.textContent = styles;
4633
+ document.head.appendChild(el);
4634
+ return el;
4635
+ }
4223
4636
  var DEFAULT_CONFIG$1 = {
4224
4637
  autoAdvance: true,
4225
4638
  preloadNext: true,
@@ -4409,8 +4822,42 @@ function createPlaylistPlugin(config) {
4409
4822
  api?.emit("playlist:ended", void 0);
4410
4823
  }
4411
4824
  });
4825
+ injectStyles();
4826
+ void import("./hls2.js").then(({ registerControl }) => {
4827
+ const self = plugin;
4828
+ registerControl(
4829
+ "playlist-previous",
4830
+ () => new PlaylistSkipButton(self, "previous")
4831
+ );
4832
+ registerControl("playlist-next", () => new PlaylistSkipButton(self, "next"));
4833
+ registerControl(
4834
+ "playlist",
4835
+ () => new PlaylistPanel(self, {
4836
+ onSelect: (index) => self.play(index)
4837
+ })
4838
+ );
4839
+ }).catch(() => {
4840
+ api?.logger.debug("@scarlett-player/ui not present, playlist controls not registered");
4841
+ });
4842
+ const onKeyDown = (event) => {
4843
+ if (!api || !api.container.contains(document.activeElement)) return;
4844
+ const activeEl = document.activeElement;
4845
+ if (activeEl instanceof HTMLInputElement || activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLSelectElement || activeEl?.isContentEditable) {
4846
+ return;
4847
+ }
4848
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
4849
+ if (event.key === "n" || event.key === "N") {
4850
+ event.preventDefault();
4851
+ plugin.next();
4852
+ } else if (event.key === "p" || event.key === "P") {
4853
+ event.preventDefault();
4854
+ plugin.previous();
4855
+ }
4856
+ };
4857
+ document.addEventListener("keydown", onKeyDown);
4412
4858
  api.onDestroy(() => {
4413
4859
  unsubEnded();
4860
+ document.removeEventListener("keydown", onKeyDown);
4414
4861
  if (advanceTimeout) {
4415
4862
  clearTimeout(advanceTimeout);
4416
4863
  advanceTimeout = null;