@scarlett-player/embed 1.7.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.
@@ -1564,6 +1564,49 @@ class PluginManager {
1564
1564
  }
1565
1565
  }
1566
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
+ }
1567
1610
  class ScarlettPlayer {
1568
1611
  /**
1569
1612
  * Create a new ScarlettPlayer.
@@ -1576,6 +1619,11 @@ class ScarlettPlayer {
1576
1619
  this.seekingWhilePlaying = false;
1577
1620
  this.seekResumeTimeout = null;
1578
1621
  this.loadGeneration = 0;
1622
+ this.listenersWired = false;
1623
+ this.readyEmitted = false;
1624
+ this.fullscreenAnnounced = false;
1625
+ this.unwireFullscreen = null;
1626
+ this.initializing = null;
1579
1627
  if (typeof options.container === "string") {
1580
1628
  const el = document.querySelector(options.container);
1581
1629
  if (!el || !(el instanceof HTMLElement)) {
@@ -1616,28 +1664,75 @@ class ScarlettPlayer {
1616
1664
  this.eventBus.on("media:error", ({ error }) => {
1617
1665
  this.errorHandler.record(error, { channel: "media:error" });
1618
1666
  });
1667
+ this.wireFullscreenListeners();
1619
1668
  if (options.plugins) {
1620
1669
  for (const plugin of options.plugins) {
1621
1670
  this.pluginManager.register(plugin);
1622
1671
  }
1623
1672
  }
1624
- this.logger.info("ScarlettPlayer initialized", {
1673
+ this.logger.info("ScarlettPlayer constructed", {
1625
1674
  autoplay: options.autoplay,
1626
1675
  plugins: options.plugins?.length ?? 0
1627
1676
  });
1628
- this.eventBus.emit("player:ready", void 0);
1629
1677
  }
1630
1678
  /**
1631
- * Initialize the player asynchronously.
1632
- * 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
1633
1697
  */
1634
- async init() {
1635
- this.checkDestroyed();
1636
- for (const [id, record] of this.pluginManager.plugins) {
1637
- if (record.plugin.type !== "provider" && record.state === "registered") {
1638
- await this.pluginManager.initPlugin(id);
1639
- }
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);
1640
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;
1641
1736
  this.eventBus.on("media:load-request", async ({ src, autoplay }) => {
1642
1737
  if (this.stateManager.getValue("chromecastActive")) return;
1643
1738
  await this.load(src);
@@ -1660,15 +1755,40 @@ class ScarlettPlayer {
1660
1755
  if (this.destroyed) return;
1661
1756
  await this.play();
1662
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();
1663
1772
  if (this.initialSrc) {
1664
1773
  await this.load(this.initialSrc);
1665
1774
  }
1666
- return Promise.resolve();
1667
1775
  }
1668
1776
  /**
1669
1777
  * Load a media source.
1670
1778
  *
1671
- * 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.
1672
1792
  *
1673
1793
  * @param source - Media source URL
1674
1794
  * @returns Promise that resolves when source is loaded
@@ -1700,6 +1820,7 @@ class ScarlettPlayer {
1700
1820
  await this.pluginManager.destroyPlugin(previousProviderId);
1701
1821
  this._currentProvider = null;
1702
1822
  }
1823
+ await this.ensureInitialized();
1703
1824
  if (generation !== this.loadGeneration) {
1704
1825
  this.logger.info("Load superseded by newer load call", { source });
1705
1826
  return;
@@ -1897,6 +2018,31 @@ class ScarlettPlayer {
1897
2018
  this.stateManager.set("autoplay", autoplay);
1898
2019
  this.logger.debug("Autoplay set", { autoplay });
1899
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
+ }
1900
2046
  /**
1901
2047
  * Subscribe to an event.
1902
2048
  *
@@ -1927,9 +2073,13 @@ class ScarlettPlayer {
1927
2073
  *
1928
2074
  * @example
1929
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 });
1930
2079
  * player.once('player:ready', () => {
1931
2080
  * console.log('Player ready!');
1932
2081
  * });
2082
+ * await player.init();
1933
2083
  * ```
1934
2084
  */
1935
2085
  once(event, handler) {
@@ -2033,36 +2183,83 @@ class ScarlettPlayer {
2033
2183
  return -1;
2034
2184
  }
2035
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
+ }
2036
2231
  /**
2037
2232
  * Request fullscreen mode.
2233
+ *
2234
+ * @returns Promise resolving once the browser has accepted or refused
2038
2235
  */
2039
2236
  async requestFullscreen() {
2040
2237
  this.checkDestroyed();
2238
+ this.fullscreenAnnounced = false;
2041
2239
  try {
2042
- if (this.container.requestFullscreen) {
2043
- await this.container.requestFullscreen();
2044
- } else if (this.container.webkitRequestFullscreen) {
2045
- 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 });
2046
2244
  }
2047
- this.stateManager.set("fullscreen", true);
2048
- this.eventBus.emit("fullscreen:change", { fullscreen: true });
2049
2245
  } catch (error) {
2050
2246
  this.logger.error("Fullscreen request failed", { error });
2051
2247
  }
2052
2248
  }
2053
2249
  /**
2054
2250
  * Exit fullscreen mode.
2251
+ *
2252
+ * @returns Promise resolving once the browser has accepted or refused
2055
2253
  */
2056
2254
  async exitFullscreen() {
2057
2255
  this.checkDestroyed();
2256
+ this.fullscreenAnnounced = false;
2058
2257
  try {
2059
- if (document.exitFullscreen) {
2060
- await document.exitFullscreen();
2061
- } else if (document.webkitExitFullscreen) {
2062
- 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 });
2063
2262
  }
2064
- this.stateManager.set("fullscreen", false);
2065
- this.eventBus.emit("fullscreen:change", { fullscreen: false });
2066
2263
  } catch (error) {
2067
2264
  this.logger.error("Exit fullscreen failed", { error });
2068
2265
  }
@@ -2160,6 +2357,8 @@ class ScarlettPlayer {
2160
2357
  clearTimeout(this.seekResumeTimeout);
2161
2358
  this.seekResumeTimeout = null;
2162
2359
  }
2360
+ this.unwireFullscreen?.();
2361
+ this.unwireFullscreen = null;
2163
2362
  this.eventBus.emit("player:destroy", void 0);
2164
2363
  this.pluginManager.destroyAll();
2165
2364
  this.eventBus.destroy();
@@ -2240,6 +2439,16 @@ class ScarlettPlayer {
2240
2439
  get autoplay() {
2241
2440
  return this.stateManager.getValue("autoplay");
2242
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
+ }
2243
2452
  /**
2244
2453
  * Check if player is destroyed.
2245
2454
  * @private
@@ -2511,8 +2720,14 @@ function setupVideoEventHandlers(video, api) {
2511
2720
  video.addEventListener(event, handler);
2512
2721
  handlers.push({ event, handler });
2513
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
+ };
2514
2728
  addHandler("play", () => {
2515
2729
  api.setState("paused", false);
2730
+ syncEndedFromElement();
2516
2731
  });
2517
2732
  addHandler("playing", () => {
2518
2733
  api.setState("playing", true);
@@ -2520,6 +2735,7 @@ function setupVideoEventHandlers(video, api) {
2520
2735
  api.setState("waiting", false);
2521
2736
  api.setState("buffering", false);
2522
2737
  api.setState("playbackState", "playing");
2738
+ syncEndedFromElement();
2523
2739
  });
2524
2740
  addHandler("pause", () => {
2525
2741
  api.setState("playing", false);
@@ -2574,6 +2790,7 @@ function setupVideoEventHandlers(video, api) {
2574
2790
  });
2575
2791
  addHandler("seeking", () => {
2576
2792
  api.setState("seeking", true);
2793
+ syncEndedFromElement();
2577
2794
  });
2578
2795
  addHandler("seeked", () => {
2579
2796
  api.setState("seeking", false);
@@ -2678,6 +2895,7 @@ function createValidatingPlaylistLoader(Hls) {
2678
2895
  }
2679
2896
  };
2680
2897
  }
2898
+ var PKG_VERSION$5 = "1.7.1";
2681
2899
  var DEFAULT_CONFIG$3 = {
2682
2900
  debug: false,
2683
2901
  autoStartLoad: true,
@@ -2735,6 +2953,10 @@ function createHLSPluginWith(loader, variant, config) {
2735
2953
  let onlineListener = null;
2736
2954
  let reconnectTriggerError = null;
2737
2955
  let reconnectExhausted = false;
2956
+ const applyPoster = () => {
2957
+ if (!video) return;
2958
+ video.poster = api?.getState("poster") || "";
2959
+ };
2738
2960
  const getOrCreateVideo = () => {
2739
2961
  if (video) return video;
2740
2962
  const existing = api?.container.querySelector("video");
@@ -2747,10 +2969,7 @@ function createHLSPluginWith(loader, variant, config) {
2747
2969
  video.preload = "metadata";
2748
2970
  video.controls = false;
2749
2971
  video.playsInline = true;
2750
- const poster = api?.getState("poster");
2751
- if (poster) {
2752
- video.poster = poster;
2753
- }
2972
+ applyPoster();
2754
2973
  api?.container.appendChild(video);
2755
2974
  return video;
2756
2975
  };
@@ -3293,7 +3512,7 @@ function createHLSPluginWith(loader, variant, config) {
3293
3512
  const plugin = {
3294
3513
  id: "hls-provider",
3295
3514
  name: variant.name,
3296
- version: "1.0.0",
3515
+ version: PKG_VERSION$5,
3297
3516
  type: "provider",
3298
3517
  description: variant.description,
3299
3518
  canPlay(src) {
@@ -3384,6 +3603,9 @@ function createHLSPluginWith(loader, variant, config) {
3384
3603
  };
3385
3604
  window.addEventListener("online", onlineListener);
3386
3605
  }
3606
+ const unsubPoster = api.subscribeToState((event) => {
3607
+ if (event.key === "poster") applyPoster();
3608
+ });
3387
3609
  api.onDestroy(() => {
3388
3610
  unsubPlay();
3389
3611
  unsubPause();
@@ -3392,6 +3614,7 @@ function createHLSPluginWith(loader, variant, config) {
3392
3614
  unsubMute();
3393
3615
  unsubRate();
3394
3616
  unsubQuality();
3617
+ unsubPoster();
3395
3618
  });
3396
3619
  },
3397
3620
  async destroy() {
@@ -3417,6 +3640,7 @@ function createHLSPluginWith(loader, variant, config) {
3417
3640
  hasPlayedContent = false;
3418
3641
  cleanup(new Error("HLS load cancelled: superseded by a new load"));
3419
3642
  currentSrc = src;
3643
+ applyPoster();
3420
3644
  api.setState("playbackState", "loading");
3421
3645
  api.setState("buffering", true);
3422
3646
  if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
@@ -3551,8 +3775,8 @@ function createHLSPluginWith(loader, variant, config) {
3551
3775
  };
3552
3776
  return plugin;
3553
3777
  }
3554
- var hls_loader_exports = {};
3555
- __export(hls_loader_exports, {
3778
+ var hls_loader_light_exports = {};
3779
+ __export(hls_loader_light_exports, {
3556
3780
  createHlsInstance: () => createHlsInstance,
3557
3781
  getHlsConstructor: () => getHlsConstructor,
3558
3782
  isHLSSupported: () => isHLSSupported,
@@ -3595,7 +3819,7 @@ async function loadHlsJs() {
3595
3819
  }
3596
3820
  loadingPromise = (async () => {
3597
3821
  try {
3598
- const hlsModule = await import("./hls.js");
3822
+ const hlsModule = await import("./hls.light.js");
3599
3823
  hlsConstructor = hlsModule.default;
3600
3824
  if (!hlsConstructor.isSupported()) {
3601
3825
  throw new Error("hls.js is not supported in this browser");
@@ -3625,16 +3849,396 @@ function resetLoader() {
3625
3849
  }
3626
3850
  function createHLSPlugin(config) {
3627
3851
  return createHLSPluginWith(
3628
- hls_loader_exports,
3852
+ hls_loader_light_exports,
3629
3853
  {
3630
- name: "HLS Provider",
3631
- description: "HLS playback provider using hls.js",
3632
- logSuffix: "",
3633
- 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"
3634
3858
  },
3635
3859
  config
3636
3860
  );
3637
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";
3638
4242
  var DEFAULT_THEME = {
3639
4243
  primary: "#6366f1",
3640
4244
  background: "#18181b",
@@ -4261,7 +4865,7 @@ function createAudioUIPlugin(config) {
4261
4865
  const plugin = {
4262
4866
  id: "audio-ui",
4263
4867
  name: "Audio UI",
4264
- version: "1.0.0",
4868
+ version: PKG_VERSION$3,
4265
4869
  type: "ui",
4266
4870
  description: "Compact audio player interface",
4267
4871
  async init(pluginApi) {
@@ -4633,6 +5237,7 @@ function injectStyles() {
4633
5237
  document.head.appendChild(el);
4634
5238
  return el;
4635
5239
  }
5240
+ var PKG_VERSION$2 = "1.7.1";
4636
5241
  var DEFAULT_CONFIG$1 = {
4637
5242
  autoAdvance: true,
4638
5243
  preloadNext: true,
@@ -4781,9 +5386,7 @@ function createPlaylistPlugin(config) {
4781
5386
  currentIndex = index;
4782
5387
  api?.logger.info("Track changed", { index, title: track.title, src: track.src });
4783
5388
  api?.setState("title", track.title || "");
4784
- if (track.artwork) {
4785
- api?.setState("poster", track.artwork);
4786
- }
5389
+ api?.setState("poster", track.artwork || "");
4787
5390
  api?.setState("mediaType", track.type || "audio");
4788
5391
  emitChange();
4789
5392
  if (mergedConfig.autoLoad !== false && track.src) {
@@ -4793,7 +5396,7 @@ function createPlaylistPlugin(config) {
4793
5396
  const plugin = {
4794
5397
  id: "playlist",
4795
5398
  name: "Playlist",
4796
- version: "1.0.0",
5399
+ version: PKG_VERSION$2,
4797
5400
  type: "feature",
4798
5401
  description: "Playlist management with shuffle, repeat, and gapless playback",
4799
5402
  async init(pluginApi) {
@@ -4823,7 +5426,7 @@ function createPlaylistPlugin(config) {
4823
5426
  }
4824
5427
  });
4825
5428
  injectStyles();
4826
- void import("./hls2.js").then(({ registerControl }) => {
5429
+ void import("./embed.audio.index.js").then(({ registerControl }) => {
4827
5430
  const self = plugin;
4828
5431
  registerControl(
4829
5432
  "playlist-previous",
@@ -5049,6 +5652,7 @@ function createPlaylistPlugin(config) {
5049
5652
  };
5050
5653
  return plugin;
5051
5654
  }
5655
+ var PKG_VERSION$1 = "1.7.1";
5052
5656
  var DEFAULT_CONFIG = {
5053
5657
  enablePlayPause: true,
5054
5658
  enableSeek: true,
@@ -5196,7 +5800,7 @@ function createMediaSessionPlugin(config) {
5196
5800
  const plugin = {
5197
5801
  id: "media-session",
5198
5802
  name: "Media Session",
5199
- version: "1.0.0",
5803
+ version: PKG_VERSION$1,
5200
5804
  type: "feature",
5201
5805
  description: "Media Session API integration for system-level media controls",
5202
5806
  async init(pluginApi) {
@@ -5336,6 +5940,14 @@ function parseDataAttributes(element) {
5336
5940
  if (controls !== null) {
5337
5941
  config.controls = controls !== "false";
5338
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
+ }
5339
5951
  const keyboard = getAttr(element, "data-keyboard", "keyboard");
5340
5952
  if (keyboard !== null) {
5341
5953
  config.keyboard = keyboard !== "false";
@@ -5485,6 +6097,9 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
5485
6097
  if (config.primaryColor) theme.primaryColor = config.primaryColor;
5486
6098
  if (config.backgroundColor) theme.backgroundColor = config.backgroundColor;
5487
6099
  const plugins = [pluginCreators2.hls()];
6100
+ if (pluginCreators2.native) {
6101
+ plugins.push(pluginCreators2.native());
6102
+ }
5488
6103
  if (pluginCreators2.playlist && config.playlist?.length) {
5489
6104
  plugins.push(pluginCreators2.playlist({
5490
6105
  items: config.playlist.map((item, index) => ({
@@ -5511,6 +6126,9 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
5511
6126
  if (pluginCreators2.captions) {
5512
6127
  plugins.push(pluginCreators2.captions(config.captions || {}));
5513
6128
  }
6129
+ if (type === "video" && pluginCreators2.gestures && config.gestures !== false) {
6130
+ plugins.push(pluginCreators2.gestures({}));
6131
+ }
5514
6132
  if (pluginCreators2.analytics && config.analytics?.beaconUrl) {
5515
6133
  plugins.push(pluginCreators2.analytics({
5516
6134
  beaconUrl: config.analytics.beaconUrl,
@@ -5523,6 +6141,7 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
5523
6141
  const uiConfig = {};
5524
6142
  if (Object.keys(theme).length > 0) uiConfig.theme = theme;
5525
6143
  if (config.hideDelay !== void 0) uiConfig.hideDelay = config.hideDelay;
6144
+ if (config.bigPlayButton !== void 0) uiConfig.bigPlayButton = config.bigPlayButton;
5526
6145
  plugins.push(pluginCreators2.videoUI(uiConfig));
5527
6146
  } else if ((type === "audio" || type === "audio-mini") && pluginCreators2.audioUI) {
5528
6147
  plugins.push(pluginCreators2.audioUI({
@@ -5628,10 +6247,12 @@ function setupAutoInit(pluginCreators2, availableTypes) {
5628
6247
  }
5629
6248
  }
5630
6249
  }
5631
- const VERSION = "0.5.3-audio";
6250
+ const PKG_VERSION = "1.7.1";
6251
+ const VERSION = `${PKG_VERSION}-audio`;
5632
6252
  const AVAILABLE_TYPES = ["audio", "audio-mini"];
5633
6253
  const pluginCreators = {
5634
6254
  hls: createHLSPlugin,
6255
+ native: createNativePlugin,
5635
6256
  audioUI: createAudioUIPlugin,
5636
6257
  playlist: createPlaylistPlugin,
5637
6258
  mediaSession: createMediaSessionPlugin