@scarlett-player/embed 1.6.0 → 1.8.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 = {
@@ -1552,6 +1564,49 @@ class PluginManager {
1552
1564
  }
1553
1565
  }
1554
1566
  }
1567
+ function videoIn(container) {
1568
+ return container.querySelector("video");
1569
+ }
1570
+ function isFullscreen(container) {
1571
+ const doc = document;
1572
+ const active = doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
1573
+ if (active && container.contains(active)) {
1574
+ return true;
1575
+ }
1576
+ return !!videoIn(container)?.webkitDisplayingFullscreen;
1577
+ }
1578
+ async function enterFullscreen(container) {
1579
+ const el = container;
1580
+ if (el.requestFullscreen) {
1581
+ await el.requestFullscreen();
1582
+ return;
1583
+ }
1584
+ if (el.webkitRequestFullscreen) {
1585
+ await el.webkitRequestFullscreen();
1586
+ return;
1587
+ }
1588
+ const video = videoIn(container);
1589
+ if (video?.webkitEnterFullscreen) {
1590
+ video.webkitEnterFullscreen();
1591
+ return;
1592
+ }
1593
+ throw new Error("Fullscreen is not supported");
1594
+ }
1595
+ async function exitFullscreen(container) {
1596
+ const video = videoIn(container);
1597
+ if (video?.webkitDisplayingFullscreen) {
1598
+ video.webkitExitFullscreen?.();
1599
+ return;
1600
+ }
1601
+ const doc = document;
1602
+ if (doc.exitFullscreen) {
1603
+ await doc.exitFullscreen();
1604
+ return;
1605
+ }
1606
+ if (doc.webkitExitFullscreen) {
1607
+ await doc.webkitExitFullscreen();
1608
+ }
1609
+ }
1555
1610
  class ScarlettPlayer {
1556
1611
  /**
1557
1612
  * Create a new ScarlettPlayer.
@@ -1564,6 +1619,11 @@ class ScarlettPlayer {
1564
1619
  this.seekingWhilePlaying = false;
1565
1620
  this.seekResumeTimeout = null;
1566
1621
  this.loadGeneration = 0;
1622
+ this.listenersWired = false;
1623
+ this.readyEmitted = false;
1624
+ this.fullscreenAnnounced = false;
1625
+ this.unwireFullscreen = null;
1626
+ this.initializing = null;
1567
1627
  if (typeof options.container === "string") {
1568
1628
  const el = document.querySelector(options.container);
1569
1629
  if (!el || !(el instanceof HTMLElement)) {
@@ -1604,31 +1664,79 @@ class ScarlettPlayer {
1604
1664
  this.eventBus.on("media:error", ({ error }) => {
1605
1665
  this.errorHandler.record(error, { channel: "media:error" });
1606
1666
  });
1667
+ this.wireFullscreenListeners();
1607
1668
  if (options.plugins) {
1608
1669
  for (const plugin of options.plugins) {
1609
1670
  this.pluginManager.register(plugin);
1610
1671
  }
1611
1672
  }
1612
- this.logger.info("ScarlettPlayer initialized", {
1673
+ this.logger.info("ScarlettPlayer constructed", {
1613
1674
  autoplay: options.autoplay,
1614
1675
  plugins: options.plugins?.length ?? 0
1615
1676
  });
1616
- this.eventBus.emit("player:ready", void 0);
1617
1677
  }
1618
1678
  /**
1619
- * Initialize the player asynchronously.
1620
- * Initializes non-provider plugins and loads initial source if provided.
1679
+ * Initialise every registered non-provider plugin and wire the player's
1680
+ * own lifecycle listeners. Idempotent, and safe to call re-entrantly.
1681
+ *
1682
+ * This exists because `new ScarlettPlayer(...)` followed by `load()` used
1683
+ * to leave the player with a provider and nothing else: the READMEs and 12
1684
+ * plugin `@example` blocks show exactly that shape, and every one of them
1685
+ * produced a dead UI (no controls, no overlay, no playlist). `load()` now
1686
+ * calls this first, so the trap cannot be reached.
1687
+ *
1688
+ * The `media:load-request` and `error:retry` listeners live here rather
1689
+ * than in the constructor because they are part of initialisation, not
1690
+ * construction: without them the playlist plugin cannot load a track and
1691
+ * the error overlay's "Try Again" button does nothing.
1692
+ *
1693
+ * Provider plugins are excluded: they are initialised lazily, per source,
1694
+ * by `load()` once `selectProvider()` has picked one.
1695
+ *
1696
+ * @returns Promise resolving when the pass (or the in-flight one) completes
1621
1697
  */
1622
- async init() {
1623
- this.checkDestroyed();
1624
- for (const [id, record] of this.pluginManager.plugins) {
1625
- if (record.plugin.type !== "provider" && record.state === "registered") {
1626
- await this.pluginManager.initPlugin(id);
1627
- }
1698
+ ensureInitialized() {
1699
+ if (this.initializing) return this.initializing;
1700
+ this.initializing = this.runInitialization().finally(() => {
1701
+ this.initializing = null;
1702
+ });
1703
+ return this.initializing;
1704
+ }
1705
+ /**
1706
+ * One initialisation pass. Never call directly; go through
1707
+ * `ensureInitialized()`, which owns the re-entrancy guard.
1708
+ *
1709
+ * @returns Promise resolving when the pass completes
1710
+ */
1711
+ async runInitialization() {
1712
+ for (const id of this.pluginManager.getPluginIds()) {
1713
+ if (this.destroyed) return;
1714
+ const plugin = this.pluginManager.getPlugin(id);
1715
+ if (!plugin || plugin.type === "provider") continue;
1716
+ if (this.pluginManager.getPluginState(id) !== "registered") continue;
1717
+ await this.pluginManager.initPlugin(id);
1718
+ }
1719
+ if (this.destroyed) return;
1720
+ this.wireLifecycleListeners();
1721
+ if (!this.readyEmitted) {
1722
+ this.readyEmitted = true;
1723
+ this.eventBus.emit("player:ready", void 0);
1628
1724
  }
1725
+ }
1726
+ /**
1727
+ * Wire the two listeners the player owns, exactly once.
1728
+ *
1729
+ * Guarded by a flag rather than by "init() runs once" because
1730
+ * `ensureInitialized()` runs on every `load()`: wiring them twice would
1731
+ * load and play each requested source twice.
1732
+ */
1733
+ wireLifecycleListeners() {
1734
+ if (this.listenersWired) return;
1735
+ this.listenersWired = true;
1629
1736
  this.eventBus.on("media:load-request", async ({ src, autoplay }) => {
1630
1737
  if (this.stateManager.getValue("chromecastActive")) return;
1631
1738
  await this.load(src);
1739
+ if (this.destroyed) return;
1632
1740
  if (autoplay !== false) {
1633
1741
  await this.play();
1634
1742
  }
@@ -1637,23 +1745,50 @@ class ScarlettPlayer {
1637
1745
  const was_live = this.stateManager.getValue("live");
1638
1746
  const resume_at = this.stateManager.getValue("currentTime");
1639
1747
  await this.load(src);
1748
+ if (this.destroyed) return;
1640
1749
  if (this.stateManager.getValue("error")) return;
1641
1750
  if (was_live) {
1642
1751
  this.seekToLive();
1643
1752
  } else if (resume_at > 0) {
1644
1753
  this.seek(resume_at);
1645
1754
  }
1755
+ if (this.destroyed) return;
1646
1756
  await this.play();
1647
1757
  });
1758
+ }
1759
+ /**
1760
+ * Initialize the player asynchronously.
1761
+ *
1762
+ * Initialises non-provider plugins, wires the lifecycle listeners and loads
1763
+ * `initialSrc` when one was given. Idempotent: calling it twice, or calling
1764
+ * it after a `load()` has already initialised the player, wires nothing a
1765
+ * second time and re-emits nothing.
1766
+ *
1767
+ * @returns Promise resolving when initialisation (and any initial load) is done
1768
+ */
1769
+ async init() {
1770
+ this.checkDestroyed();
1771
+ await this.ensureInitialized();
1648
1772
  if (this.initialSrc) {
1649
1773
  await this.load(this.initialSrc);
1650
1774
  }
1651
- return Promise.resolve();
1652
1775
  }
1653
1776
  /**
1654
1777
  * Load a media source.
1655
1778
  *
1656
- * Selects appropriate provider plugin and loads the source.
1779
+ * Initialises the player if that has not happened yet (see
1780
+ * `ensureInitialized()`), then selects the provider plugin for the source
1781
+ * and loads it. The auto-initialisation is what makes the widely copied
1782
+ * `new ScarlettPlayer(...)` plus `load()` shape work: before it, that shape
1783
+ * produced a player with a provider and no UI, no error overlay and no
1784
+ * working playlist.
1785
+ *
1786
+ * Resets playback state, and deliberately does NOT touch `poster`. The
1787
+ * poster is metadata owned by whoever set it (the consumer through
1788
+ * `PlayerOptions.poster` or `setPoster()`, or the playlist plugin on a track
1789
+ * change), not playback state, and it is written BEFORE the load that goes
1790
+ * with it: clearing it here would blank the image over exactly the gap it
1791
+ * exists to cover, while the next source loads.
1657
1792
  *
1658
1793
  * @param source - Media source URL
1659
1794
  * @returns Promise that resolves when source is loaded
@@ -1685,6 +1820,7 @@ class ScarlettPlayer {
1685
1820
  await this.pluginManager.destroyPlugin(previousProviderId);
1686
1821
  this._currentProvider = null;
1687
1822
  }
1823
+ await this.ensureInitialized();
1688
1824
  if (generation !== this.loadGeneration) {
1689
1825
  this.logger.info("Load superseded by newer load call", { source });
1690
1826
  return;
@@ -1882,6 +2018,31 @@ class ScarlettPlayer {
1882
2018
  this.stateManager.set("autoplay", autoplay);
1883
2019
  this.logger.debug("Autoplay set", { autoplay });
1884
2020
  }
2021
+ /**
2022
+ * Set the poster image shown until the first frame renders.
2023
+ *
2024
+ * Writes the `poster` state key; the provider plugins subscribe to it and
2025
+ * mirror it onto the media element, so this takes effect on a player that
2026
+ * is already running. Before this method existed the poster could only be
2027
+ * chosen at construction, which left a playlist showing the previous
2028
+ * track's art (and a Vue `poster` prop change doing nothing at all).
2029
+ *
2030
+ * An empty string clears the poster, which is how a consumer takes the
2031
+ * image away rather than replacing it.
2032
+ *
2033
+ * @param url - Poster image URL, or '' to clear it
2034
+ *
2035
+ * @example
2036
+ * ```ts
2037
+ * player.setPoster('https://example.com/art.jpg');
2038
+ * player.setPoster(''); // back to the bare video surface
2039
+ * ```
2040
+ */
2041
+ setPoster(url) {
2042
+ this.checkDestroyed();
2043
+ this.stateManager.set("poster", url);
2044
+ this.logger.debug("Poster set", { poster: url });
2045
+ }
1885
2046
  /**
1886
2047
  * Subscribe to an event.
1887
2048
  *
@@ -1912,9 +2073,13 @@ class ScarlettPlayer {
1912
2073
  *
1913
2074
  * @example
1914
2075
  * ```ts
2076
+ * // player:ready fires once, at the end of the first initialisation, so a
2077
+ * // one-shot listener has to be attached before init() or load() runs.
2078
+ * const player = new ScarlettPlayer({ container });
1915
2079
  * player.once('player:ready', () => {
1916
2080
  * console.log('Player ready!');
1917
2081
  * });
2082
+ * await player.init();
1918
2083
  * ```
1919
2084
  */
1920
2085
  once(event, handler) {
@@ -2018,36 +2183,83 @@ class ScarlettPlayer {
2018
2183
  return -1;
2019
2184
  }
2020
2185
  // ===== Fullscreen Methods =====
2186
+ /**
2187
+ * Listen for fullscreen changes the player did not initiate.
2188
+ *
2189
+ * Nothing used to: the `fullscreen` state key was written only by this
2190
+ * class's own `requestFullscreen()` and `exitFullscreen()`. Everything else
2191
+ * left it lying. Entering fullscreen through the UI button or the `f`
2192
+ * shortcut never flipped the icon to "Exit fullscreen", `player.fullscreen`
2193
+ * stayed false and `fullscreen:change` never fired; and after a programmatic
2194
+ * `requestFullscreen()` an Escape exit left the state stuck at true.
2195
+ *
2196
+ * `webkitbeginfullscreen` and `webkitendfullscreen` are the iPhone's native
2197
+ * player announcing itself. They are dispatched on the video element, they do
2198
+ * not bubble, and the element does not exist yet when this runs (a provider
2199
+ * plugin creates it, per source), so they are bound to the container in the
2200
+ * CAPTURE phase, which is the one phase that sees a non-bubbling event on a
2201
+ * descendant.
2202
+ */
2203
+ wireFullscreenListeners() {
2204
+ const onChange = () => {
2205
+ this.fullscreenAnnounced = true;
2206
+ this.setFullscreenState(isFullscreen(this.container));
2207
+ };
2208
+ document.addEventListener("fullscreenchange", onChange);
2209
+ document.addEventListener("webkitfullscreenchange", onChange);
2210
+ this.container.addEventListener("webkitbeginfullscreen", onChange, true);
2211
+ this.container.addEventListener("webkitendfullscreen", onChange, true);
2212
+ this.unwireFullscreen = () => {
2213
+ document.removeEventListener("fullscreenchange", onChange);
2214
+ document.removeEventListener("webkitfullscreenchange", onChange);
2215
+ this.container.removeEventListener("webkitbeginfullscreen", onChange, true);
2216
+ this.container.removeEventListener("webkitendfullscreen", onChange, true);
2217
+ };
2218
+ }
2219
+ /**
2220
+ * Record a fullscreen transition, once.
2221
+ *
2222
+ * @param next - The state the browser is now in
2223
+ */
2224
+ setFullscreenState(next) {
2225
+ if (this.stateManager.getValue("fullscreen") === next) {
2226
+ return;
2227
+ }
2228
+ this.stateManager.set("fullscreen", next);
2229
+ this.eventBus.emit("fullscreen:change", { fullscreen: next });
2230
+ }
2021
2231
  /**
2022
2232
  * Request fullscreen mode.
2233
+ *
2234
+ * @returns Promise resolving once the browser has accepted or refused
2023
2235
  */
2024
2236
  async requestFullscreen() {
2025
2237
  this.checkDestroyed();
2238
+ this.fullscreenAnnounced = false;
2026
2239
  try {
2027
- if (this.container.requestFullscreen) {
2028
- await this.container.requestFullscreen();
2029
- } else if (this.container.webkitRequestFullscreen) {
2030
- await this.container.webkitRequestFullscreen();
2240
+ await enterFullscreen(this.container);
2241
+ if (!this.fullscreenAnnounced) {
2242
+ this.stateManager.set("fullscreen", true);
2243
+ this.eventBus.emit("fullscreen:change", { fullscreen: true });
2031
2244
  }
2032
- this.stateManager.set("fullscreen", true);
2033
- this.eventBus.emit("fullscreen:change", { fullscreen: true });
2034
2245
  } catch (error) {
2035
2246
  this.logger.error("Fullscreen request failed", { error });
2036
2247
  }
2037
2248
  }
2038
2249
  /**
2039
2250
  * Exit fullscreen mode.
2251
+ *
2252
+ * @returns Promise resolving once the browser has accepted or refused
2040
2253
  */
2041
2254
  async exitFullscreen() {
2042
2255
  this.checkDestroyed();
2256
+ this.fullscreenAnnounced = false;
2043
2257
  try {
2044
- if (document.exitFullscreen) {
2045
- await document.exitFullscreen();
2046
- } else if (document.webkitExitFullscreen) {
2047
- await document.webkitExitFullscreen();
2258
+ await exitFullscreen(this.container);
2259
+ if (!this.fullscreenAnnounced) {
2260
+ this.stateManager.set("fullscreen", false);
2261
+ this.eventBus.emit("fullscreen:change", { fullscreen: false });
2048
2262
  }
2049
- this.stateManager.set("fullscreen", false);
2050
- this.eventBus.emit("fullscreen:change", { fullscreen: false });
2051
2263
  } catch (error) {
2052
2264
  this.logger.error("Exit fullscreen failed", { error });
2053
2265
  }
@@ -2140,10 +2352,13 @@ class ScarlettPlayer {
2140
2352
  return;
2141
2353
  }
2142
2354
  this.logger.info("Destroying player");
2355
+ this.loadGeneration++;
2143
2356
  if (this.seekResumeTimeout !== null) {
2144
2357
  clearTimeout(this.seekResumeTimeout);
2145
2358
  this.seekResumeTimeout = null;
2146
2359
  }
2360
+ this.unwireFullscreen?.();
2361
+ this.unwireFullscreen = null;
2147
2362
  this.eventBus.emit("player:destroy", void 0);
2148
2363
  this.pluginManager.destroyAll();
2149
2364
  this.eventBus.destroy();
@@ -2224,6 +2439,16 @@ class ScarlettPlayer {
2224
2439
  get autoplay() {
2225
2440
  return this.stateManager.getValue("autoplay");
2226
2441
  }
2442
+ /**
2443
+ * Get the current poster URL ('' when there is none).
2444
+ *
2445
+ * Reads state rather than the media element: the element only exists once a
2446
+ * provider has been initialised, and for an audio source it never carries
2447
+ * the attribute at all.
2448
+ */
2449
+ get poster() {
2450
+ return this.stateManager.getValue("poster");
2451
+ }
2227
2452
  /**
2228
2453
  * Check if player is destroyed.
2229
2454
  * @private
@@ -2287,6 +2512,15 @@ var __export = (target, all) => {
2287
2512
  for (var name in all)
2288
2513
  __defProp(target, name, { get: all[name], enumerable: true });
2289
2514
  };
2515
+ function sanitizeUrl(url) {
2516
+ if (!url) return void 0;
2517
+ try {
2518
+ const parsed = new URL(url);
2519
+ return `${parsed.origin}${parsed.pathname}`;
2520
+ } catch {
2521
+ return void 0;
2522
+ }
2523
+ }
2290
2524
  function formatLevel(level) {
2291
2525
  if (level.name) {
2292
2526
  return level.name;
@@ -2486,8 +2720,14 @@ function setupVideoEventHandlers(video, api) {
2486
2720
  video.addEventListener(event, handler);
2487
2721
  handlers.push({ event, handler });
2488
2722
  };
2723
+ const syncEndedFromElement = () => {
2724
+ if (video.ended || !api.getState("ended")) return;
2725
+ api.setState("ended", false);
2726
+ api.setState("playbackState", video.paused ? "paused" : "playing");
2727
+ };
2489
2728
  addHandler("play", () => {
2490
2729
  api.setState("paused", false);
2730
+ syncEndedFromElement();
2491
2731
  });
2492
2732
  addHandler("playing", () => {
2493
2733
  api.setState("playing", true);
@@ -2495,6 +2735,7 @@ function setupVideoEventHandlers(video, api) {
2495
2735
  api.setState("waiting", false);
2496
2736
  api.setState("buffering", false);
2497
2737
  api.setState("playbackState", "playing");
2738
+ syncEndedFromElement();
2498
2739
  });
2499
2740
  addHandler("pause", () => {
2500
2741
  api.setState("playing", false);
@@ -2549,6 +2790,7 @@ function setupVideoEventHandlers(video, api) {
2549
2790
  });
2550
2791
  addHandler("seeking", () => {
2551
2792
  api.setState("seeking", true);
2793
+ syncEndedFromElement();
2552
2794
  });
2553
2795
  addHandler("seeked", () => {
2554
2796
  api.setState("seeking", false);
@@ -2653,6 +2895,7 @@ function createValidatingPlaylistLoader(Hls) {
2653
2895
  }
2654
2896
  };
2655
2897
  }
2898
+ var PKG_VERSION$5 = "1.7.1";
2656
2899
  var DEFAULT_CONFIG$3 = {
2657
2900
  debug: false,
2658
2901
  autoStartLoad: true,
@@ -2708,6 +2951,12 @@ function createHLSPluginWith(loader, variant, config) {
2708
2951
  let reconnectWindowStart = 0;
2709
2952
  let reconnectResumePosition = 0;
2710
2953
  let onlineListener = null;
2954
+ let reconnectTriggerError = null;
2955
+ let reconnectExhausted = false;
2956
+ const applyPoster = () => {
2957
+ if (!video) return;
2958
+ video.poster = api?.getState("poster") || "";
2959
+ };
2711
2960
  const getOrCreateVideo = () => {
2712
2961
  if (video) return video;
2713
2962
  const existing = api?.container.querySelector("video");
@@ -2720,10 +2969,7 @@ function createHLSPluginWith(loader, variant, config) {
2720
2969
  video.preload = "metadata";
2721
2970
  video.controls = false;
2722
2971
  video.playsInline = true;
2723
- const poster = api?.getState("poster");
2724
- if (poster) {
2725
- video.poster = poster;
2726
- }
2972
+ applyPoster();
2727
2973
  api?.container.appendChild(video);
2728
2974
  return video;
2729
2975
  };
@@ -2816,6 +3062,22 @@ function createHLSPluginWith(loader, variant, config) {
2816
3062
  return ErrorCode.PLAYBACK_FAILED;
2817
3063
  }
2818
3064
  };
3065
+ const buildErrorDetail = (error, retriesExhausted) => {
3066
+ const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
3067
+ const detail = {
3068
+ type: error.type,
3069
+ retriesExhausted,
3070
+ attempts
3071
+ };
3072
+ if (typeof error.response?.code === "number" && error.response.code > 0) {
3073
+ detail.httpStatus = error.response.code;
3074
+ }
3075
+ const url = sanitizeUrl(error.url);
3076
+ if (url) {
3077
+ detail.url = url;
3078
+ }
3079
+ return detail;
3080
+ };
2819
3081
  const emitFatalError = (error, retriesExhausted) => {
2820
3082
  const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
2821
3083
  api?.logger.error(message, { type: error.type, details: error.details });
@@ -2825,7 +3087,8 @@ function createHLSPluginWith(loader, variant, config) {
2825
3087
  code: mapFatalErrorCode(error),
2826
3088
  message,
2827
3089
  fatal: true,
2828
- timestamp: Date.now()
3090
+ timestamp: Date.now(),
3091
+ detail: buildErrorDetail(error, retriesExhausted)
2829
3092
  });
2830
3093
  maybeScheduleReconnect(error);
2831
3094
  };
@@ -2902,6 +3165,62 @@ function createHLSPluginWith(loader, variant, config) {
2902
3165
  }
2903
3166
  return false;
2904
3167
  };
3168
+ const handleNativeFatalError = (error, resumePosition) => {
3169
+ const is_network = error.type === "network";
3170
+ const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
3171
+ const used = is_network ? networkRetryCount : mediaRetryCount;
3172
+ if (!currentSrc || used >= max_retries) {
3173
+ emitFatalError(error, used >= max_retries);
3174
+ return;
3175
+ }
3176
+ const resume_position = resumePosition ?? video?.currentTime ?? 0;
3177
+ if (is_network) {
3178
+ networkRetryCount++;
3179
+ } else {
3180
+ mediaRetryCount++;
3181
+ }
3182
+ const attempt = used + 1;
3183
+ const delay = getRetryDelay(attempt - 1);
3184
+ api?.logger.info(
3185
+ `Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
3186
+ );
3187
+ api?.emit(is_network ? "error:network" : "error:media", {
3188
+ error: new Error(error.details)
3189
+ });
3190
+ if (retryTimeout) {
3191
+ clearTimeout(retryTimeout);
3192
+ }
3193
+ const retry_session = loadSession;
3194
+ retryTimeout = setTimeout(() => {
3195
+ if (retry_session !== loadSession) return;
3196
+ void recoverNative(error, resume_position);
3197
+ }, delay);
3198
+ };
3199
+ const recoverNative = async (error, resumePosition) => {
3200
+ if (!currentSrc) return;
3201
+ const session = ++loadSession;
3202
+ const saved_src = currentSrc;
3203
+ const was_live = api?.getState("live") ?? false;
3204
+ try {
3205
+ teardownPipeline(new Error("HLS load cancelled: native error recovery"));
3206
+ api?.setState("playbackState", "loading");
3207
+ await loadNative(saved_src);
3208
+ if (session !== loadSession) return;
3209
+ if (!was_live && video && resumePosition > 0) {
3210
+ video.currentTime = resumePosition;
3211
+ }
3212
+ api?.setState("playbackState", "ready");
3213
+ api?.setState("buffering", false);
3214
+ try {
3215
+ await video?.play();
3216
+ } catch {
3217
+ }
3218
+ } catch {
3219
+ if (session !== loadSession) return;
3220
+ api?.logger.warn("Native error recovery attempt failed");
3221
+ handleNativeFatalError(error, resumePosition);
3222
+ }
3223
+ };
2905
3224
  const loadNative = async (src) => {
2906
3225
  const session = loadSession;
2907
3226
  const videoEl = getOrCreateVideo();
@@ -2943,12 +3262,24 @@ function createHLSPluginWith(loader, variant, config) {
2943
3262
  const media_error = videoEl.error;
2944
3263
  const hls_error = {
2945
3264
  type: media_error?.code === MediaError.MEDIA_ERR_NETWORK ? "network" : "media",
2946
- details: media_error?.message || "Native HLS playback error"
3265
+ details: media_error?.message || "Native HLS playback error",
3266
+ fatal: true
2947
3267
  };
2948
- emitFatalError(hls_error, false);
3268
+ handleNativeFatalError(hls_error);
2949
3269
  };
2950
3270
  videoEl.addEventListener("error", onFatalVideoError);
2951
- const removeFatalListener = () => videoEl.removeEventListener("error", onFatalVideoError);
3271
+ const onPlayingResetBudget = () => {
3272
+ if (networkRetryCount > 0 || mediaRetryCount > 0) {
3273
+ api?.logger.debug("Native playback recovered, resetting retry budgets");
3274
+ networkRetryCount = 0;
3275
+ mediaRetryCount = 0;
3276
+ }
3277
+ };
3278
+ videoEl.addEventListener("playing", onPlayingResetBudget);
3279
+ const removeFatalListener = () => {
3280
+ videoEl.removeEventListener("error", onFatalVideoError);
3281
+ videoEl.removeEventListener("playing", onPlayingResetBudget);
3282
+ };
2952
3283
  const previous_cleanup = cleanupVideoEvents;
2953
3284
  cleanupVideoEvents = () => {
2954
3285
  removeFatalListener();
@@ -3073,12 +3404,38 @@ function createHLSPluginWith(loader, variant, config) {
3073
3404
  reconnectAttempts = 0;
3074
3405
  reconnectWindowStart = 0;
3075
3406
  reconnectResumePosition = 0;
3407
+ reconnectTriggerError = null;
3408
+ reconnectExhausted = false;
3409
+ };
3410
+ const emitReconnectExhausted = (elapsedMs, windowMs) => {
3411
+ if (reconnectExhausted) return;
3412
+ reconnectExhausted = true;
3413
+ const attempts = reconnectAttempts;
3414
+ const trigger = reconnectTriggerError;
3415
+ api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
3416
+ api?.setState("playbackState", "error");
3417
+ api?.setState("buffering", false);
3418
+ api?.emit("error", {
3419
+ code: trigger ? mapFatalErrorCode(trigger) : ErrorCode.PLAYBACK_FAILED,
3420
+ message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
3421
+ fatal: true,
3422
+ timestamp: Date.now(),
3423
+ detail: {
3424
+ type: trigger?.type ?? "other",
3425
+ retriesExhausted: true,
3426
+ attempts,
3427
+ reconnectExhausted: true
3428
+ }
3429
+ });
3076
3430
  };
3077
3431
  const scheduleReconnectAttempt = () => {
3432
+ if (reconnectExhausted) return;
3078
3433
  if (reconnectTimer) return;
3079
3434
  const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
3080
- if (Date.now() - reconnectWindowStart > window_ms) {
3435
+ const elapsed_ms = Date.now() - reconnectWindowStart;
3436
+ if (elapsed_ms > window_ms) {
3081
3437
  api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
3438
+ emitReconnectExhausted(elapsed_ms, window_ms);
3082
3439
  return;
3083
3440
  }
3084
3441
  const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
@@ -3086,7 +3443,12 @@ function createHLSPluginWith(loader, variant, config) {
3086
3443
  const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
3087
3444
  const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
3088
3445
  api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
3089
- api?.emit("error:reconnecting", { attempt: reconnectAttempts + 1, delayMs: delay });
3446
+ api?.emit("error:reconnecting", {
3447
+ attempt: reconnectAttempts + 1,
3448
+ delayMs: delay,
3449
+ elapsedMs: elapsed_ms,
3450
+ windowMs: window_ms
3451
+ });
3090
3452
  reconnectTimer = setTimeout(() => {
3091
3453
  reconnectTimer = null;
3092
3454
  void attemptReconnect();
@@ -3099,6 +3461,7 @@ function createHLSPluginWith(loader, variant, config) {
3099
3461
  if (reconnectWindowStart === 0) {
3100
3462
  reconnectWindowStart = Date.now();
3101
3463
  reconnectResumePosition = video?.currentTime ?? 0;
3464
+ reconnectTriggerError = error;
3102
3465
  }
3103
3466
  scheduleReconnectAttempt();
3104
3467
  };
@@ -3130,7 +3493,10 @@ function createHLSPluginWith(loader, variant, config) {
3130
3493
  }
3131
3494
  api.setState("playbackState", "ready");
3132
3495
  api.setState("buffering", false);
3133
- api.emit("error:recovered", void 0);
3496
+ api.emit("error:recovered", {
3497
+ attempt: reconnectAttempts,
3498
+ elapsedMs: Date.now() - reconnectWindowStart
3499
+ });
3134
3500
  api.logger.info("Auto-reconnect succeeded");
3135
3501
  cancelReconnect();
3136
3502
  try {
@@ -3146,7 +3512,7 @@ function createHLSPluginWith(loader, variant, config) {
3146
3512
  const plugin = {
3147
3513
  id: "hls-provider",
3148
3514
  name: variant.name,
3149
- version: "1.0.0",
3515
+ version: PKG_VERSION$5,
3150
3516
  type: "provider",
3151
3517
  description: variant.description,
3152
3518
  canPlay(src) {
@@ -3237,6 +3603,9 @@ function createHLSPluginWith(loader, variant, config) {
3237
3603
  };
3238
3604
  window.addEventListener("online", onlineListener);
3239
3605
  }
3606
+ const unsubPoster = api.subscribeToState((event) => {
3607
+ if (event.key === "poster") applyPoster();
3608
+ });
3240
3609
  api.onDestroy(() => {
3241
3610
  unsubPlay();
3242
3611
  unsubPause();
@@ -3245,6 +3614,7 @@ function createHLSPluginWith(loader, variant, config) {
3245
3614
  unsubMute();
3246
3615
  unsubRate();
3247
3616
  unsubQuality();
3617
+ unsubPoster();
3248
3618
  });
3249
3619
  },
3250
3620
  async destroy() {
@@ -3270,6 +3640,7 @@ function createHLSPluginWith(loader, variant, config) {
3270
3640
  hasPlayedContent = false;
3271
3641
  cleanup(new Error("HLS load cancelled: superseded by a new load"));
3272
3642
  currentSrc = src;
3643
+ applyPoster();
3273
3644
  api.setState("playbackState", "loading");
3274
3645
  api.setState("buffering", true);
3275
3646
  if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
@@ -3404,8 +3775,8 @@ function createHLSPluginWith(loader, variant, config) {
3404
3775
  };
3405
3776
  return plugin;
3406
3777
  }
3407
- var hls_loader_exports = {};
3408
- __export(hls_loader_exports, {
3778
+ var hls_loader_light_exports = {};
3779
+ __export(hls_loader_light_exports, {
3409
3780
  createHlsInstance: () => createHlsInstance,
3410
3781
  getHlsConstructor: () => getHlsConstructor,
3411
3782
  isHLSSupported: () => isHLSSupported,
@@ -3448,7 +3819,7 @@ async function loadHlsJs() {
3448
3819
  }
3449
3820
  loadingPromise = (async () => {
3450
3821
  try {
3451
- const hlsModule = await import("./hls.js");
3822
+ const hlsModule = await import("./hls.light.js");
3452
3823
  hlsConstructor = hlsModule.default;
3453
3824
  if (!hlsConstructor.isSupported()) {
3454
3825
  throw new Error("hls.js is not supported in this browser");
@@ -3478,16 +3849,396 @@ function resetLoader() {
3478
3849
  }
3479
3850
  function createHLSPlugin(config) {
3480
3851
  return createHLSPluginWith(
3481
- hls_loader_exports,
3852
+ hls_loader_light_exports,
3482
3853
  {
3483
- name: "HLS Provider",
3484
- description: "HLS playback provider using hls.js",
3485
- logSuffix: "",
3486
- engineLabel: "hls.js"
3854
+ name: "HLS Provider (Light)",
3855
+ description: "HLS playback provider using hls.js/light (smaller bundle)",
3856
+ logSuffix: " (light)",
3857
+ engineLabel: "hls.js/light"
3487
3858
  },
3488
3859
  config
3489
3860
  );
3490
3861
  }
3862
+ var PKG_VERSION$4 = "1.7.1";
3863
+ var VIDEO_EXTENSIONS = ["mp4", "webm", "mov", "mkv", "ogv", "m4v"];
3864
+ var AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "weba"];
3865
+ var SUPPORTED_EXTENSIONS = [...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS];
3866
+ var MIME_TYPES = {
3867
+ // Video
3868
+ mp4: "video/mp4",
3869
+ m4v: "video/mp4",
3870
+ webm: "video/webm",
3871
+ mov: "video/quicktime",
3872
+ mkv: "video/x-matroska",
3873
+ ogv: "video/ogg",
3874
+ // Audio
3875
+ mp3: "audio/mpeg",
3876
+ wav: "audio/wav",
3877
+ ogg: "audio/ogg",
3878
+ flac: "audio/flac",
3879
+ aac: "audio/aac",
3880
+ m4a: "audio/mp4",
3881
+ opus: "audio/opus",
3882
+ weba: "audio/webm"
3883
+ };
3884
+ function createNativePlugin(config) {
3885
+ const preload = config?.preload ?? "metadata";
3886
+ const load_timeout_ms = config?.loadTimeoutMs ?? 3e4;
3887
+ let api = null;
3888
+ let video = null;
3889
+ let cleanupEvents = null;
3890
+ let derived_title = null;
3891
+ let is_audio_source = false;
3892
+ const getExtension = (src) => {
3893
+ try {
3894
+ const url = new URL(src, window.location.href);
3895
+ const pathname = url.pathname;
3896
+ const ext = pathname.split(".").pop()?.toLowerCase() ?? "";
3897
+ return ext;
3898
+ } catch {
3899
+ const rawExt = src.split(".").pop()?.toLowerCase() ?? "";
3900
+ return rawExt.split("?")[0] ?? "";
3901
+ }
3902
+ };
3903
+ const getMimeType = (ext) => {
3904
+ return MIME_TYPES[ext] || "video/mp4";
3905
+ };
3906
+ const isAudioExtension = (ext) => {
3907
+ return AUDIO_EXTENSIONS.includes(ext);
3908
+ };
3909
+ const canBrowserPlay = (mimeType) => {
3910
+ const isAudio = mimeType.startsWith("audio/");
3911
+ const testElement = isAudio ? document.createElement("audio") : document.createElement("video");
3912
+ const canPlay = testElement.canPlayType(mimeType);
3913
+ return canPlay === "probably" || canPlay === "maybe";
3914
+ };
3915
+ const applyPoster = () => {
3916
+ if (!video) return;
3917
+ if (is_audio_source) {
3918
+ video.poster = "";
3919
+ return;
3920
+ }
3921
+ video.poster = api?.getState("poster") || "";
3922
+ };
3923
+ const getOrCreateVideo = () => {
3924
+ if (video) return video;
3925
+ const existing = api?.container.querySelector("video");
3926
+ if (existing) {
3927
+ video = existing;
3928
+ return video;
3929
+ }
3930
+ video = document.createElement("video");
3931
+ video.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;display:block;object-fit:contain;background:#000";
3932
+ video.preload = preload;
3933
+ video.controls = false;
3934
+ video.playsInline = true;
3935
+ applyPoster();
3936
+ api?.container.appendChild(video);
3937
+ return video;
3938
+ };
3939
+ const setupEventListeners = (videoEl) => {
3940
+ const handlers = [];
3941
+ const on = (event, handler) => {
3942
+ videoEl.addEventListener(event, handler);
3943
+ handlers.push([event, handler]);
3944
+ };
3945
+ const syncEndedFromElement = () => {
3946
+ if (videoEl.ended || !api?.getState("ended")) return;
3947
+ api?.setState("ended", false);
3948
+ api?.setState("playbackState", videoEl.paused ? "paused" : "playing");
3949
+ };
3950
+ on("play", () => {
3951
+ api?.setState("paused", false);
3952
+ syncEndedFromElement();
3953
+ });
3954
+ on("playing", () => {
3955
+ api?.setState("playing", true);
3956
+ api?.setState("paused", false);
3957
+ api?.setState("playbackState", "playing");
3958
+ api?.emit("playback:play", void 0);
3959
+ syncEndedFromElement();
3960
+ });
3961
+ on("pause", () => {
3962
+ api?.setState("playing", false);
3963
+ api?.setState("paused", true);
3964
+ api?.setState("playbackState", "paused");
3965
+ api?.emit("playback:pause", void 0);
3966
+ });
3967
+ on("ended", () => {
3968
+ api?.setState("playing", false);
3969
+ api?.setState("ended", true);
3970
+ api?.setState("playbackState", "ended");
3971
+ api?.emit("playback:ended", void 0);
3972
+ });
3973
+ on("timeupdate", () => {
3974
+ api?.setState("currentTime", videoEl.currentTime);
3975
+ api?.emit("playback:timeupdate", { currentTime: videoEl.currentTime });
3976
+ });
3977
+ on("durationchange", () => {
3978
+ api?.setState("duration", videoEl.duration || 0);
3979
+ });
3980
+ on("loadedmetadata", () => {
3981
+ api?.setState("duration", videoEl.duration || 0);
3982
+ api?.emit("media:loadedmetadata", { duration: videoEl.duration || 0 });
3983
+ });
3984
+ on("canplay", () => {
3985
+ api?.setState("buffering", false);
3986
+ api?.emit("media:canplay", void 0);
3987
+ });
3988
+ on("canplaythrough", () => {
3989
+ api?.emit("media:canplaythrough", void 0);
3990
+ });
3991
+ on("waiting", () => {
3992
+ api?.setState("buffering", true);
3993
+ api?.emit("media:waiting", void 0);
3994
+ });
3995
+ on("progress", () => {
3996
+ if (videoEl.buffered.length > 0) {
3997
+ const bufferedEnd = videoEl.buffered.end(videoEl.buffered.length - 1);
3998
+ const duration = videoEl.duration || 0;
3999
+ const buffered = duration > 0 ? bufferedEnd / duration : 0;
4000
+ api?.setState("bufferedAmount", buffered);
4001
+ api?.emit("media:progress", { buffered });
4002
+ }
4003
+ });
4004
+ on("seeking", () => {
4005
+ api?.setState("seeking", true);
4006
+ syncEndedFromElement();
4007
+ });
4008
+ on("seeked", () => {
4009
+ api?.setState("seeking", false);
4010
+ api?.emit("playback:seeked", { time: videoEl.currentTime });
4011
+ });
4012
+ on("volumechange", () => {
4013
+ api?.setState("volume", videoEl.volume);
4014
+ api?.setState("muted", videoEl.muted);
4015
+ api?.emit("volume:change", { volume: videoEl.volume, muted: videoEl.muted });
4016
+ });
4017
+ on("ratechange", () => {
4018
+ api?.setState("playbackRate", videoEl.playbackRate);
4019
+ api?.emit("playback:ratechange", { rate: videoEl.playbackRate });
4020
+ });
4021
+ on("stalled", () => {
4022
+ api?.setState("buffering", true);
4023
+ api?.emit("media:stalled", void 0);
4024
+ api?.logger.warn("Media stalled - network may be slow");
4025
+ });
4026
+ on("suspend", () => {
4027
+ api?.emit("media:suspend", void 0);
4028
+ });
4029
+ on("abort", () => {
4030
+ api?.emit("media:abort", void 0);
4031
+ });
4032
+ on("error", () => {
4033
+ const error = videoEl.error;
4034
+ let message = "Unknown video error";
4035
+ if (error) {
4036
+ switch (error.code) {
4037
+ case MediaError.MEDIA_ERR_ABORTED:
4038
+ message = "Playback aborted";
4039
+ break;
4040
+ case MediaError.MEDIA_ERR_NETWORK:
4041
+ message = "Network error";
4042
+ break;
4043
+ case MediaError.MEDIA_ERR_DECODE:
4044
+ message = "Decode error - format may not be supported";
4045
+ break;
4046
+ case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
4047
+ message = "Format not supported";
4048
+ break;
4049
+ }
4050
+ }
4051
+ api?.logger.error("Video error", { code: error?.code, message });
4052
+ api?.emit("error", {
4053
+ code: ErrorCode.PLAYBACK_FAILED,
4054
+ message,
4055
+ fatal: true,
4056
+ timestamp: Date.now()
4057
+ });
4058
+ });
4059
+ on("enterpictureinpicture", () => {
4060
+ api?.setState("pip", true);
4061
+ api?.logger.debug("PiP: entered (standard)");
4062
+ });
4063
+ on("leavepictureinpicture", () => {
4064
+ api?.setState("pip", false);
4065
+ api?.logger.debug("PiP: exited (standard)");
4066
+ if (!videoEl.paused || api?.getState("playing")) {
4067
+ videoEl.play().catch(() => {
4068
+ });
4069
+ }
4070
+ });
4071
+ const webkitVideo = videoEl;
4072
+ if ("webkitPresentationMode" in videoEl) {
4073
+ on("webkitpresentationmodechanged", () => {
4074
+ const mode = webkitVideo.webkitPresentationMode;
4075
+ api?.setState("pip", mode === "picture-in-picture");
4076
+ api?.logger.debug(`PiP: mode changed to ${mode} (webkit)`);
4077
+ if (mode === "inline" && videoEl.paused) {
4078
+ videoEl.play().catch(() => {
4079
+ });
4080
+ }
4081
+ });
4082
+ }
4083
+ return () => {
4084
+ handlers.forEach(([event, handler]) => {
4085
+ videoEl.removeEventListener(event, handler);
4086
+ });
4087
+ };
4088
+ };
4089
+ const cleanup = () => {
4090
+ cleanupEvents?.();
4091
+ cleanupEvents = null;
4092
+ if (video) {
4093
+ video.pause();
4094
+ video.removeAttribute("src");
4095
+ video.load();
4096
+ }
4097
+ };
4098
+ const plugin = {
4099
+ id: "native-provider",
4100
+ name: "Native Media Provider",
4101
+ version: PKG_VERSION$4,
4102
+ type: "provider",
4103
+ description: "Native HTML5 playback for video (MP4, WebM, MOV) and audio (MP3, WAV, FLAC, AAC)",
4104
+ canPlay(src) {
4105
+ const ext = getExtension(src);
4106
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
4107
+ return false;
4108
+ }
4109
+ const mimeType = getMimeType(ext);
4110
+ return canBrowserPlay(mimeType);
4111
+ },
4112
+ async init(pluginApi) {
4113
+ api = pluginApi;
4114
+ api.logger.info("Native video plugin initialized");
4115
+ const unsubPlay = api.on("playback:play", async () => {
4116
+ if (!video) return;
4117
+ try {
4118
+ await video.play();
4119
+ } catch (e) {
4120
+ api?.logger.error("Play failed", e);
4121
+ }
4122
+ });
4123
+ const unsubPause = api.on("playback:pause", () => {
4124
+ video?.pause();
4125
+ });
4126
+ const unsubSeek = api.on("playback:seeking", ({ time }) => {
4127
+ if (!video) return;
4128
+ const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
4129
+ video.currentTime = clampedTime;
4130
+ });
4131
+ const unsubVolume = api.on("volume:change", ({ volume, muted }) => {
4132
+ if (video) {
4133
+ video.volume = volume;
4134
+ video.muted = muted;
4135
+ }
4136
+ });
4137
+ const unsubMute = api.on("volume:mute", ({ muted }) => {
4138
+ if (video) video.muted = muted;
4139
+ });
4140
+ const unsubRate = api.on("playback:ratechange", ({ rate }) => {
4141
+ if (video) video.playbackRate = rate;
4142
+ });
4143
+ const unsubPoster = api.subscribeToState((event) => {
4144
+ if (event.key === "poster") applyPoster();
4145
+ });
4146
+ api.onDestroy(() => {
4147
+ unsubPlay();
4148
+ unsubPause();
4149
+ unsubSeek();
4150
+ unsubVolume();
4151
+ unsubMute();
4152
+ unsubRate();
4153
+ unsubPoster();
4154
+ });
4155
+ },
4156
+ async destroy() {
4157
+ api?.logger.info("Native video plugin destroying");
4158
+ cleanup();
4159
+ if (video?.parentNode) {
4160
+ video.parentNode.removeChild(video);
4161
+ }
4162
+ video = null;
4163
+ api = null;
4164
+ derived_title = null;
4165
+ is_audio_source = false;
4166
+ },
4167
+ async loadSource(src) {
4168
+ if (!api) throw new Error("Plugin not initialized");
4169
+ const ext = getExtension(src);
4170
+ const mimeType = getMimeType(ext);
4171
+ const isAudio = isAudioExtension(ext);
4172
+ is_audio_source = isAudio;
4173
+ api.logger.info("Loading native media source", { src, mimeType, isAudio });
4174
+ cleanup();
4175
+ api.setState("playbackState", "loading");
4176
+ api.setState("buffering", true);
4177
+ api.setState("mediaType", isAudio ? "audio" : "video");
4178
+ if (isAudio) {
4179
+ const current_title = api.getState("title");
4180
+ if (!current_title || current_title === derived_title) {
4181
+ try {
4182
+ const url = new URL(src, window.location.href);
4183
+ const filename = url.pathname.split("/").pop() || "Audio";
4184
+ const title = decodeURIComponent(filename.replace(/\.[^.]+$/, "").replace(/[-_]/g, " "));
4185
+ derived_title = title;
4186
+ api.setState("title", title);
4187
+ } catch {
4188
+ derived_title = "Audio";
4189
+ api.setState("title", "Audio");
4190
+ }
4191
+ }
4192
+ }
4193
+ api.setState("qualities", []);
4194
+ api.setState("currentQuality", null);
4195
+ const videoEl = getOrCreateVideo();
4196
+ videoEl.style.display = isAudio ? "none" : "block";
4197
+ applyPoster();
4198
+ cleanupEvents = setupEventListeners(videoEl);
4199
+ return new Promise((resolve, reject) => {
4200
+ let watchdog = null;
4201
+ const settle = () => {
4202
+ videoEl.removeEventListener("loadedmetadata", onLoaded);
4203
+ videoEl.removeEventListener("error", onError);
4204
+ if (watchdog !== null) {
4205
+ clearTimeout(watchdog);
4206
+ watchdog = null;
4207
+ }
4208
+ };
4209
+ const onLoaded = () => {
4210
+ settle();
4211
+ const muted = api?.getState("muted");
4212
+ const volume = api?.getState("volume");
4213
+ if (muted !== void 0) videoEl.muted = muted;
4214
+ if (volume !== void 0) videoEl.volume = volume;
4215
+ api?.setState("source", { src, type: mimeType });
4216
+ api?.setState("playbackState", "ready");
4217
+ api?.setState("buffering", false);
4218
+ api?.emit("media:loaded", { src, type: mimeType });
4219
+ resolve();
4220
+ };
4221
+ const onError = () => {
4222
+ settle();
4223
+ const error = videoEl.error;
4224
+ reject(new Error(error?.message || "Failed to load video source"));
4225
+ };
4226
+ if (load_timeout_ms > 0) {
4227
+ watchdog = setTimeout(() => {
4228
+ settle();
4229
+ reject(new Error("Video took too long to load (network timeout)"));
4230
+ }, load_timeout_ms);
4231
+ }
4232
+ videoEl.addEventListener("loadedmetadata", onLoaded);
4233
+ videoEl.addEventListener("error", onError);
4234
+ videoEl.src = src;
4235
+ videoEl.load();
4236
+ });
4237
+ }
4238
+ };
4239
+ return plugin;
4240
+ }
4241
+ var PKG_VERSION$3 = "1.7.1";
3491
4242
  var DEFAULT_THEME = {
3492
4243
  primary: "#6366f1",
3493
4244
  background: "#18181b",
@@ -4114,7 +4865,7 @@ function createAudioUIPlugin(config) {
4114
4865
  const plugin = {
4115
4866
  id: "audio-ui",
4116
4867
  name: "Audio UI",
4117
- version: "1.0.0",
4868
+ version: PKG_VERSION$3,
4118
4869
  type: "ui",
4119
4870
  description: "Compact audio player interface",
4120
4871
  async init(pluginApi) {
@@ -4486,6 +5237,7 @@ function injectStyles() {
4486
5237
  document.head.appendChild(el);
4487
5238
  return el;
4488
5239
  }
5240
+ var PKG_VERSION$2 = "1.7.1";
4489
5241
  var DEFAULT_CONFIG$1 = {
4490
5242
  autoAdvance: true,
4491
5243
  preloadNext: true,
@@ -4634,9 +5386,7 @@ function createPlaylistPlugin(config) {
4634
5386
  currentIndex = index;
4635
5387
  api?.logger.info("Track changed", { index, title: track.title, src: track.src });
4636
5388
  api?.setState("title", track.title || "");
4637
- if (track.artwork) {
4638
- api?.setState("poster", track.artwork);
4639
- }
5389
+ api?.setState("poster", track.artwork || "");
4640
5390
  api?.setState("mediaType", track.type || "audio");
4641
5391
  emitChange();
4642
5392
  if (mergedConfig.autoLoad !== false && track.src) {
@@ -4646,7 +5396,7 @@ function createPlaylistPlugin(config) {
4646
5396
  const plugin = {
4647
5397
  id: "playlist",
4648
5398
  name: "Playlist",
4649
- version: "1.0.0",
5399
+ version: PKG_VERSION$2,
4650
5400
  type: "feature",
4651
5401
  description: "Playlist management with shuffle, repeat, and gapless playback",
4652
5402
  async init(pluginApi) {
@@ -4676,7 +5426,7 @@ function createPlaylistPlugin(config) {
4676
5426
  }
4677
5427
  });
4678
5428
  injectStyles();
4679
- void import("./hls2.js").then(({ registerControl }) => {
5429
+ void import("./embed.audio.index.js").then(({ registerControl }) => {
4680
5430
  const self = plugin;
4681
5431
  registerControl(
4682
5432
  "playlist-previous",
@@ -4902,6 +5652,7 @@ function createPlaylistPlugin(config) {
4902
5652
  };
4903
5653
  return plugin;
4904
5654
  }
5655
+ var PKG_VERSION$1 = "1.7.1";
4905
5656
  var DEFAULT_CONFIG = {
4906
5657
  enablePlayPause: true,
4907
5658
  enableSeek: true,
@@ -5049,7 +5800,7 @@ function createMediaSessionPlugin(config) {
5049
5800
  const plugin = {
5050
5801
  id: "media-session",
5051
5802
  name: "Media Session",
5052
- version: "1.0.0",
5803
+ version: PKG_VERSION$1,
5053
5804
  type: "feature",
5054
5805
  description: "Media Session API integration for system-level media controls",
5055
5806
  async init(pluginApi) {
@@ -5189,6 +5940,14 @@ function parseDataAttributes(element) {
5189
5940
  if (controls !== null) {
5190
5941
  config.controls = controls !== "false";
5191
5942
  }
5943
+ const bigPlayButton = getAttr(element, "data-big-play-button", "big-play-button");
5944
+ if (bigPlayButton !== null) {
5945
+ config.bigPlayButton = bigPlayButton !== "false";
5946
+ }
5947
+ const gestures = getAttr(element, "data-gestures", "gestures");
5948
+ if (gestures !== null) {
5949
+ config.gestures = gestures !== "false";
5950
+ }
5192
5951
  const keyboard = getAttr(element, "data-keyboard", "keyboard");
5193
5952
  if (keyboard !== null) {
5194
5953
  config.keyboard = keyboard !== "false";
@@ -5338,6 +6097,9 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
5338
6097
  if (config.primaryColor) theme.primaryColor = config.primaryColor;
5339
6098
  if (config.backgroundColor) theme.backgroundColor = config.backgroundColor;
5340
6099
  const plugins = [pluginCreators2.hls()];
6100
+ if (pluginCreators2.native) {
6101
+ plugins.push(pluginCreators2.native());
6102
+ }
5341
6103
  if (pluginCreators2.playlist && config.playlist?.length) {
5342
6104
  plugins.push(pluginCreators2.playlist({
5343
6105
  items: config.playlist.map((item, index) => ({
@@ -5364,6 +6126,9 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
5364
6126
  if (pluginCreators2.captions) {
5365
6127
  plugins.push(pluginCreators2.captions(config.captions || {}));
5366
6128
  }
6129
+ if (type === "video" && pluginCreators2.gestures && config.gestures !== false) {
6130
+ plugins.push(pluginCreators2.gestures({}));
6131
+ }
5367
6132
  if (pluginCreators2.analytics && config.analytics?.beaconUrl) {
5368
6133
  plugins.push(pluginCreators2.analytics({
5369
6134
  beaconUrl: config.analytics.beaconUrl,
@@ -5376,6 +6141,7 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
5376
6141
  const uiConfig = {};
5377
6142
  if (Object.keys(theme).length > 0) uiConfig.theme = theme;
5378
6143
  if (config.hideDelay !== void 0) uiConfig.hideDelay = config.hideDelay;
6144
+ if (config.bigPlayButton !== void 0) uiConfig.bigPlayButton = config.bigPlayButton;
5379
6145
  plugins.push(pluginCreators2.videoUI(uiConfig));
5380
6146
  } else if ((type === "audio" || type === "audio-mini") && pluginCreators2.audioUI) {
5381
6147
  plugins.push(pluginCreators2.audioUI({
@@ -5481,10 +6247,12 @@ function setupAutoInit(pluginCreators2, availableTypes) {
5481
6247
  }
5482
6248
  }
5483
6249
  }
5484
- const VERSION = "0.5.3-audio";
6250
+ const PKG_VERSION = "1.7.1";
6251
+ const VERSION = `${PKG_VERSION}-audio`;
5485
6252
  const AVAILABLE_TYPES = ["audio", "audio-mini"];
5486
6253
  const pluginCreators = {
5487
6254
  hls: createHLSPlugin,
6255
+ native: createNativePlugin,
5488
6256
  audioUI: createAudioUIPlugin,
5489
6257
  playlist: createPlaylistPlugin,
5490
6258
  mediaSession: createMediaSessionPlugin