@scarlett-player/embed 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/embed.js CHANGED
@@ -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);
1640
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);
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$b = "1.8.1";
2681
2899
  var DEFAULT_CONFIG$4 = {
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
  };
@@ -3009,7 +3228,7 @@ function createHLSPluginWith(loader, variant, config) {
3009
3228
  if (api) {
3010
3229
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
3011
3230
  }
3012
- return new Promise((resolve, reject) => {
3231
+ return new Promise((resolve2, reject) => {
3013
3232
  let watchdog = null;
3014
3233
  let settled = false;
3015
3234
  const settle = () => {
@@ -3068,7 +3287,7 @@ function createHLSPluginWith(loader, variant, config) {
3068
3287
  };
3069
3288
  api?.setState("source", { src, type: "application/x-mpegURL" });
3070
3289
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
3071
- resolve();
3290
+ resolve2();
3072
3291
  };
3073
3292
  const onError = () => {
3074
3293
  if (settled) return;
@@ -3102,7 +3321,7 @@ function createHLSPluginWith(loader, variant, config) {
3102
3321
  if (api) {
3103
3322
  cleanupVideoEvents = setupVideoEventHandlers(videoEl, api);
3104
3323
  }
3105
- return new Promise((resolve, reject) => {
3324
+ return new Promise((resolve2, reject) => {
3106
3325
  if (!hls || !api) {
3107
3326
  reject(new Error("HLS not initialized"));
3108
3327
  return;
@@ -3137,7 +3356,7 @@ function createHLSPluginWith(loader, variant, config) {
3137
3356
  hasPlayedContent = true;
3138
3357
  api?.setState("source", { src, type: "application/x-mpegURL" });
3139
3358
  api?.emit("media:loaded", { src, type: "application/x-mpegURL" });
3140
- resolve();
3359
+ resolve2();
3141
3360
  }
3142
3361
  },
3143
3362
  onLevelSwitched: () => {
@@ -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$b,
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()) {
@@ -3635,7 +3859,462 @@ function createHLSPlugin(config) {
3635
3859
  config
3636
3860
  );
3637
3861
  }
3638
- var styles$1 = `
3862
+ var PKG_VERSION$a = "1.8.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$a,
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((resolve2, 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
+ resolve2();
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 UNKNOWN_RANK = 3;
4242
+ var DEFAULT_PRIORITY = {
4243
+ "bandwidth-indicator": { rank: 0, exit: "hide" },
4244
+ "skip-backward": { rank: 1, exit: "overflow" },
4245
+ "skip-forward": { rank: 1, exit: "overflow" },
4246
+ pip: { rank: 2, exit: "overflow" },
4247
+ chromecast: { rank: 4, exit: "overflow" },
4248
+ airplay: { rank: 4, exit: "overflow" },
4249
+ volume: { rank: 5, exit: "overflow" },
4250
+ captions: { rank: 6, exit: "overflow" },
4251
+ quality: { rank: 6, exit: "hide" },
4252
+ time: { rank: 7, exit: "hide" },
4253
+ play: { rank: "never", exit: "overflow" },
4254
+ "live-indicator": { rank: "never", exit: "overflow" },
4255
+ settings: { rank: "never", exit: "overflow" },
4256
+ fullscreen: { rank: "never", exit: "overflow" },
4257
+ spacer: { rank: "never", exit: "overflow" }
4258
+ };
4259
+ function resolveFitItems(layout, priority) {
4260
+ return layout.map((id) => {
4261
+ const rule = DEFAULT_PRIORITY[id];
4262
+ const rank = priority?.[id] ?? rule?.rank ?? UNKNOWN_RANK;
4263
+ return { id, rank, exit: rule?.exit ?? "overflow" };
4264
+ });
4265
+ }
4266
+ function assertFitLayout(layout, priority) {
4267
+ if (!layout.includes("quality") || layout.includes("settings")) {
4268
+ return;
4269
+ }
4270
+ const [quality] = resolveFitItems(["quality"], priority);
4271
+ if (quality.rank === "never") {
4272
+ return;
4273
+ }
4274
+ throw new Error(
4275
+ `uiPlugin: a layout with "quality" needs "settings" as well. The quality control hides when the bar does not fit, and the settings menu is where its Quality row lives. Add "settings" to controls, pin quality with priority: { quality: 'never' }, or set responsive: false.`
4276
+ );
4277
+ }
4278
+ function needed(widths, gap, overflowButtonWidth, trayUsed) {
4279
+ const content = widths.reduce((sum, width) => sum + width, 0);
4280
+ const gaps = gap * Math.max(0, widths.length - 1);
4281
+ const tray = trayUsed ? overflowButtonWidth + gap : 0;
4282
+ return content + gaps + tray;
4283
+ }
4284
+ function planFit(items, available, gap, overflowButtonWidth) {
4285
+ const ids = items.map((item) => item.id);
4286
+ if (available <= 0) {
4287
+ return { inBar: ids, overflow: [], hidden: [] };
4288
+ }
4289
+ const counted = items.filter((item) => item.visible && item.width > 0);
4290
+ const remaining = [...counted];
4291
+ const overflow = /* @__PURE__ */ new Set();
4292
+ const hidden = /* @__PURE__ */ new Set();
4293
+ while (needed(
4294
+ remaining.map((item) => item.width),
4295
+ gap,
4296
+ overflowButtonWidth,
4297
+ overflow.size > 0
4298
+ ) > available) {
4299
+ let victim = -1;
4300
+ for (let i = 0; i < remaining.length; i++) {
4301
+ const candidate = remaining[i];
4302
+ if (candidate.rank === "never") continue;
4303
+ if (victim === -1 || candidate.rank <= remaining[victim].rank) {
4304
+ victim = i;
4305
+ }
4306
+ }
4307
+ if (victim === -1) break;
4308
+ const [removed] = remaining.splice(victim, 1);
4309
+ (removed.exit === "hide" ? hidden : overflow).add(removed.id);
4310
+ }
4311
+ return {
4312
+ inBar: ids.filter((id) => !overflow.has(id) && !hidden.has(id)),
4313
+ overflow: ids.filter((id) => overflow.has(id)),
4314
+ hidden: ids.filter((id) => hidden.has(id))
4315
+ };
4316
+ }
4317
+ var styles$3 = `
3639
4318
  /* ============================================
3640
4319
  Container & Base
3641
4320
  ============================================ */
@@ -3695,7 +4374,17 @@ var styles$1 = `
3695
4374
  display: flex;
3696
4375
  align-items: center;
3697
4376
  padding: 0 12px 12px;
4377
+ /* Composed through a variable so the fullscreen rule below can add the
4378
+ device's own inset without restating the 12px. */
4379
+ padding-bottom: calc(12px + var(--sp-inset-bottom, 0px));
3698
4380
  gap: 4px;
4381
+ /* Declared on the bar because the fit needs the same number the volume rules
4382
+ below use: the slider expands mid-interaction and the plugin reserves the
4383
+ room in advance (see interactionReserve() in index.ts, which reads this
4384
+ property off this element at init). One declaration, so the stylesheet and
4385
+ the arithmetic cannot drift. Both the bar and the overflow tray are inside
4386
+ .sp-controls, so a volume control inherits it wherever the fit put it. */
4387
+ --sp-volume-slider-width: 64px;
3699
4388
  opacity: 0;
3700
4389
  transform: translateY(4px);
3701
4390
  transition: opacity 0.25s ease, transform 0.25s ease;
@@ -3713,12 +4402,37 @@ var styles$1 = `
3713
4402
  pointer-events: none;
3714
4403
  }
3715
4404
 
4405
+ /* ============================================
4406
+ Safe Area (fullscreen only)
4407
+
4408
+ Scoped to :fullscreen on purpose. Applied unconditionally, the inset would
4409
+ push an inline player's controls up on any page whose viewport meta says
4410
+ viewport-fit=cover, where there is no notch or home indicator over the
4411
+ player at all. Both the bar and the progress wrapper are direct children of
4412
+ the container, which is the element that goes fullscreen.
4413
+
4414
+ The :-webkit-full-screen twin is a separate rule because an unknown
4415
+ pseudo-class anywhere in a selector list invalidates the whole rule.
4416
+
4417
+ Nothing is needed in packages/embed/iframe.html: viewport-fit has no effect
4418
+ inside an iframe.
4419
+ ============================================ */
4420
+ :fullscreen > .sp-controls,
4421
+ :fullscreen > .sp-progress-wrapper {
4422
+ --sp-inset-bottom: env(safe-area-inset-bottom, 0px);
4423
+ }
4424
+
4425
+ :-webkit-full-screen > .sp-controls,
4426
+ :-webkit-full-screen > .sp-progress-wrapper {
4427
+ --sp-inset-bottom: env(safe-area-inset-bottom, 0px);
4428
+ }
4429
+
3716
4430
  /* ============================================
3717
4431
  Progress Bar (Above Controls)
3718
4432
  ============================================ */
3719
4433
  .sp-progress-wrapper {
3720
4434
  position: absolute;
3721
- bottom: 48px;
4435
+ bottom: calc(48px + var(--sp-inset-bottom, 0px));
3722
4436
  left: 12px;
3723
4437
  right: 12px;
3724
4438
  height: 20px;
@@ -3734,6 +4448,31 @@ var styles$1 = `
3734
4448
  opacity: 1;
3735
4449
  }
3736
4450
 
4451
+ /* Touch: a 20px wrapper is not a 20px target. The control bar is a later
4452
+ sibling at the same z-index and spans 0..56px from the bottom, so it wins
4453
+ hit-testing in the 48..56 overlap and the exclusive region for scrubbing is
4454
+ 12px. The wrapper grows UPWARD to 44px (48..92) because growing downward
4455
+ would be swallowed by the bar; the 3px bar itself stays exactly where it was
4456
+ (centred 8.5px above the wrapper's bottom edge, which is what
4457
+ align-items: center gave it inside 20px). The handle and tooltip
4458
+ enlargements are gated behind (hover: hover) and never match a finger, but
4459
+ .sp-progress--dragging is not, so the handle still appears mid-drag.
4460
+
4461
+ any-pointer, not pointer: (pointer: coarse) describes the PRIMARY pointer
4462
+ only, so a hybrid laptop with a mouse and a touchscreen reports fine and kept
4463
+ the 12px exclusive region under a finger. (any-pointer: coarse) is true
4464
+ whenever a coarse pointer is available at all, which is the population that
4465
+ needs the target. The cost on such a machine is 24px of extra hit area for
4466
+ the mouse, over the player's own bottom edge. */
4467
+ @media (any-pointer: coarse) {
4468
+ .sp-progress-wrapper {
4469
+ height: 44px;
4470
+ align-items: flex-end;
4471
+ padding-bottom: 8.5px;
4472
+ box-sizing: border-box;
4473
+ }
4474
+ }
4475
+
3737
4476
  .sp-progress {
3738
4477
  position: relative;
3739
4478
  width: 100%;
@@ -3942,15 +4681,70 @@ var styles$1 = `
3942
4681
  }
3943
4682
 
3944
4683
  /* ============================================
3945
- Time Display
4684
+ Overflow Tray
4685
+
4686
+ The wrapper is deliberately unpositioned: the strip is absolutely
4687
+ positioned against .sp-controls (the nearest positioned ancestor), so it
4688
+ spans the bar's width and sits directly above it instead of hanging off a
4689
+ 44px button.
4690
+
4691
+ The strip wraps horizontally and keeps overflow visible. A vertical menu of
4692
+ 44px rows would be taller than a portrait phone player (211px at 375px wide,
4693
+ measured 2026-09-05) and a scrolling one would clip the popovers registered
4694
+ controls own.
3946
4695
  ============================================ */
3947
- .sp-time {
3948
- font-size: 13px;
3949
- font-variant-numeric: tabular-nums;
3950
- color: rgba(255, 255, 255, 0.9);
3951
- white-space: nowrap;
3952
- padding: 0 4px;
3953
- letter-spacing: 0.02em;
4696
+ .sp-overflow {
4697
+ display: flex;
4698
+ align-items: center;
4699
+ flex-shrink: 0;
4700
+ }
4701
+
4702
+ .sp-overflow-tray {
4703
+ position: absolute;
4704
+ bottom: 100%;
4705
+ left: 0;
4706
+ right: 0;
4707
+ display: flex;
4708
+ flex-wrap: wrap;
4709
+ justify-content: flex-end;
4710
+ gap: 4px;
4711
+ padding: 8px 12px;
4712
+ background: rgba(20, 20, 20, 0.95);
4713
+ backdrop-filter: blur(8px);
4714
+ -webkit-backdrop-filter: blur(8px);
4715
+ border-radius: 8px;
4716
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
4717
+ overflow: visible;
4718
+ opacity: 0;
4719
+ visibility: hidden;
4720
+ transform: translateY(8px);
4721
+ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
4722
+ z-index: 20;
4723
+ }
4724
+
4725
+ .sp-overflow-tray--open {
4726
+ opacity: 1;
4727
+ visibility: visible;
4728
+ transform: translateY(0);
4729
+ }
4730
+
4731
+ /* Beats a control's own inline style.display = '' on its next update(), so a
4732
+ control the fit took off screen stays off screen until the fit says
4733
+ otherwise. */
4734
+ .sp-control--collapsed {
4735
+ display: none !important;
4736
+ }
4737
+
4738
+ /* ============================================
4739
+ Time Display
4740
+ ============================================ */
4741
+ .sp-time {
4742
+ font-size: 13px;
4743
+ font-variant-numeric: tabular-nums;
4744
+ color: rgba(255, 255, 255, 0.9);
4745
+ white-space: nowrap;
4746
+ padding: 0 4px;
4747
+ letter-spacing: 0.02em;
3954
4748
  }
3955
4749
 
3956
4750
  /* ============================================
@@ -3968,14 +4762,18 @@ var styles$1 = `
3968
4762
  transition: width 0.2s ease;
3969
4763
  }
3970
4764
 
4765
+ /* Both widths come from --sp-volume-slider-width on .sp-controls, which is also
4766
+ what the fit reserves for this control. focus-within is deliberately not
4767
+ gated on hover: a tap on the mute button focuses it, which is how the slider
4768
+ opens on a phone. */
3971
4769
  @media (hover: hover) {
3972
4770
  .sp-volume:hover .sp-volume__slider-wrap {
3973
- width: 64px;
4771
+ width: var(--sp-volume-slider-width);
3974
4772
  }
3975
4773
  }
3976
4774
 
3977
4775
  .sp-volume:focus-within .sp-volume__slider-wrap {
3978
- width: 64px;
4776
+ width: var(--sp-volume-slider-width);
3979
4777
  }
3980
4778
 
3981
4779
  .sp-volume__slider {
@@ -4076,6 +4874,14 @@ var styles$1 = `
4076
4874
  position: absolute;
4077
4875
  bottom: calc(100% + 8px);
4078
4876
  right: 0;
4877
+ /* Bounded to the player, see .sp-settings-panel. border-box because the
4878
+ bound is a content-box height by default and this menu adds 8px of padding
4879
+ top and bottom: at the 139px bound a 211px player gives, it rendered 155px
4880
+ and the host clipped the last 16px of it. */
4881
+ box-sizing: border-box;
4882
+ max-height: var(--sp-menu-max-height, none);
4883
+ overflow-y: auto;
4884
+ -webkit-overflow-scrolling: touch;
4079
4885
  background: rgba(20, 20, 20, 0.95);
4080
4886
  backdrop-filter: blur(8px);
4081
4887
  -webkit-backdrop-filter: blur(8px);
@@ -4144,6 +4950,29 @@ var styles$1 = `
4144
4950
  position: absolute;
4145
4951
  bottom: calc(100% + 8px);
4146
4952
  right: 0;
4953
+ /* Bounded to the room above the control bar, written by the UI plugin's
4954
+ ResizeObserver as max(120px, container height - the bar's measured height
4955
+ - 16px). The bar is measured rather than assumed because its
4956
+ padding-bottom carries the safe-area inset in fullscreen, which moves the
4957
+ anchor these menus hang from. The Speed sub-panel is 253px (a
4958
+ 37px header plus six 36px rows) against a 211px portrait phone player, so
4959
+ without this the host's overflow: hidden cuts off the Back header and the
4960
+ first three speeds and playback speed is unreachable (measured at 375x211
4961
+ on 2026-09-05). With the variable unset the panel behaves exactly as it
4962
+ did before.
4963
+
4964
+ Not applied to .sp-overflow-tray: that one has to keep overflow visible so
4965
+ the popovers its adopted controls own are not clipped.
4966
+
4967
+ border-box because max-height bounds the content box: .sp-settings-panel--main
4968
+ is this same element with 4px of padding top and bottom, so wherever the
4969
+ bound binds the main menu, it rendered 8px past it and the host clipped the
4970
+ difference. The --sub views set padding: 0 and were already exact, which is
4971
+ why the browser harness's speed-panel check could not see this. */
4972
+ box-sizing: border-box;
4973
+ max-height: var(--sp-menu-max-height, none);
4974
+ overflow-y: auto;
4975
+ -webkit-overflow-scrolling: touch;
4147
4976
  background: rgba(20, 20, 20, 0.95);
4148
4977
  backdrop-filter: blur(8px);
4149
4978
  -webkit-backdrop-filter: blur(8px);
@@ -4155,7 +4984,6 @@ var styles$1 = `
4155
4984
  transform: translateY(8px);
4156
4985
  transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
4157
4986
  z-index: 20;
4158
- overflow: hidden;
4159
4987
  }
4160
4988
 
4161
4989
  .sp-settings-panel--open {
@@ -4302,6 +5130,70 @@ var styles$1 = `
4302
5130
  opacity: 0.4;
4303
5131
  }
4304
5132
 
5133
+ /* ============================================
5134
+ Big Play Button
5135
+
5136
+ z-index 12 puts it above the gradient (5) and above the gestures plugin's
5137
+ tap surface (6), so a tap lands on the button and starts playback instead
5138
+ of being read as a tap-to-toggle-controls gesture - exactly how the control
5139
+ bar's play button (10) already behaves. It stays below the spinner (15) and
5140
+ the error overlay (25), both of which own the middle of the picture when
5141
+ they are up.
5142
+
5143
+ Hidden with visibility, not opacity alone, so it takes no pointer events
5144
+ while it is away.
5145
+ ============================================ */
5146
+ .sp-big-play {
5147
+ position: absolute;
5148
+ top: 50%;
5149
+ left: 50%;
5150
+ transform: translate(-50%, -50%);
5151
+ z-index: 12;
5152
+ display: flex;
5153
+ align-items: center;
5154
+ justify-content: center;
5155
+ /* Comfortably past the 44px minimum touch target the control bar uses. */
5156
+ width: 72px;
5157
+ height: 72px;
5158
+ padding: 0;
5159
+ border: none;
5160
+ border-radius: 50%;
5161
+ background: var(--sp-accent, #e50914);
5162
+ color: #fff;
5163
+ cursor: pointer;
5164
+ opacity: 0;
5165
+ visibility: hidden;
5166
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
5167
+ transition: opacity 0.2s ease, visibility 0.2s, transform 0.15s ease,
5168
+ background 0.15s ease;
5169
+ }
5170
+
5171
+ .sp-big-play--visible {
5172
+ opacity: 1;
5173
+ visibility: visible;
5174
+ }
5175
+
5176
+ .sp-big-play svg {
5177
+ width: 36px;
5178
+ height: 36px;
5179
+ fill: currentColor;
5180
+ /* Optical centring: the play triangle's mass sits left of the glyph box. */
5181
+ margin-left: 3px;
5182
+ }
5183
+
5184
+ .sp-big-play:hover {
5185
+ transform: translate(-50%, -50%) scale(1.06);
5186
+ }
5187
+
5188
+ .sp-big-play:active {
5189
+ transform: translate(-50%, -50%) scale(0.96);
5190
+ }
5191
+
5192
+ .sp-big-play:focus-visible {
5193
+ outline: 2px solid #fff;
5194
+ outline-offset: 3px;
5195
+ }
5196
+
4305
5197
  /* ============================================
4306
5198
  Error Overlay
4307
5199
  ============================================ */
@@ -4476,17 +5368,24 @@ var styles$1 = `
4476
5368
  .sp-control,
4477
5369
  .sp-volume__slider-wrap,
4478
5370
  .sp-quality-menu,
5371
+ .sp-overflow-tray,
4479
5372
  .sp-settings-panel,
4480
5373
  .sp-settings-panel__row,
4481
5374
  .sp-settings-panel__item,
4482
5375
  .sp-settings-panel__header,
4483
5376
  .sp-buffering,
5377
+ .sp-big-play,
4484
5378
  .sp-error-overlay,
4485
5379
  .sp-error-overlay__retry,
4486
5380
  .sp-error-overlay__dismiss {
4487
5381
  transition: none;
4488
5382
  }
4489
5383
 
5384
+ .sp-big-play:hover,
5385
+ .sp-big-play:active {
5386
+ transform: translate(-50%, -50%);
5387
+ }
5388
+
4490
5389
  .sp-live__dot,
4491
5390
  .sp-spin {
4492
5391
  animation: none;
@@ -4504,7 +5403,7 @@ var styles$1 = `
4504
5403
  --sp-icon-size: 24px;
4505
5404
  }
4506
5405
  `;
4507
- var icons = {
5406
+ var icons$1 = {
4508
5407
  play: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`,
4509
5408
  pause: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>`,
4510
5409
  replay: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>`,
@@ -4523,6 +5422,8 @@ var icons = {
4523
5422
  captionsOff: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.5 5.5v13h-15v-13h15zM19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2z"/></svg>`,
4524
5423
  checkmark: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`,
4525
5424
  chevronUp: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"/></svg>`,
5425
+ /** Vertical ellipsis for the overflow tray button. */
5426
+ more: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>`,
4526
5427
  chevronDown: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></svg>`,
4527
5428
  spinner: `<svg viewBox="0 0 24 24" fill="currentColor" class="sp-spin"><path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/></svg>`,
4528
5429
  forward10: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/><path d="M10.9 16V11.73l-.72.36-.48-.86 1.48-.73h.85V16h-1.13zm2.77-2.14c0-.66.13-1.2.38-1.6.26-.41.66-.62 1.2-.62.55 0 .95.21 1.21.62.25.4.38.94.38 1.6 0 .67-.13 1.2-.38 1.61-.26.41-.66.61-1.21.61-.54 0-.94-.2-1.2-.61-.25-.41-.38-.94-.38-1.61zm1.12 0c0 .45.05.79.15 1.03.1.23.26.35.48.35s.38-.12.49-.35c.1-.24.15-.58.15-1.03s-.05-.78-.15-1.02c-.11-.23-.27-.35-.49-.35s-.38.12-.48.35c-.1.24-.15.57-.15 1.02z"/></svg>`,
@@ -4595,7 +5496,7 @@ var PlayButton = class {
4595
5496
  this.toggle();
4596
5497
  };
4597
5498
  this.api = api;
4598
- this.el = createButton("sp-play", "Play", icons.play);
5499
+ this.el = createButton("sp-play", "Play", icons$1.play);
4599
5500
  this.el.addEventListener("click", this.clickHandler);
4600
5501
  }
4601
5502
  render() {
@@ -4607,13 +5508,13 @@ var PlayButton = class {
4607
5508
  let icon;
4608
5509
  let label;
4609
5510
  if (ended) {
4610
- icon = icons.replay;
5511
+ icon = icons$1.replay;
4611
5512
  label = "Replay";
4612
5513
  } else if (playing) {
4613
- icon = icons.pause;
5514
+ icon = icons$1.pause;
4614
5515
  label = "Pause";
4615
5516
  } else {
4616
- icon = icons.play;
5517
+ icon = icons$1.play;
4617
5518
  label = "Play";
4618
5519
  }
4619
5520
  setHTML(this.el, icon);
@@ -4639,6 +5540,102 @@ var PlayButton = class {
4639
5540
  this.el.remove();
4640
5541
  }
4641
5542
  };
5543
+ var BigPlayButton = class {
5544
+ /**
5545
+ * @param api - Plugin API for state and container access
5546
+ * @param isOverlayVisible - Whether the error overlay is showing; the button
5547
+ * must not sit on top of it, and `error` state alone does not say (a
5548
+ * dismissed overlay leaves the error behind)
5549
+ */
5550
+ constructor(api, isOverlayVisible = () => false) {
5551
+ this.hasStarted = false;
5552
+ this.clickHandler = () => {
5553
+ this.start();
5554
+ };
5555
+ this.api = api;
5556
+ this.isOverlayVisible = isOverlayVisible;
5557
+ const btn = document.createElement("button");
5558
+ btn.className = "sp-big-play";
5559
+ btn.setAttribute("type", "button");
5560
+ btn.setAttribute("aria-label", "Play");
5561
+ setHTML(btn, icons$1.play);
5562
+ btn.addEventListener("click", this.clickHandler);
5563
+ this.el = btn;
5564
+ }
5565
+ render() {
5566
+ return this.el;
5567
+ }
5568
+ /**
5569
+ * Show or hide the button, and swap in the replay glyph after `ended`.
5570
+ *
5571
+ * Driven by the same `scheduleUpdate()` pass as every other control, so the
5572
+ * button cannot disagree with the control bar about what state playback is
5573
+ * in.
5574
+ */
5575
+ update() {
5576
+ const playing = this.api.getState("playing");
5577
+ const ended = this.hasEnded();
5578
+ const currentTime = this.api.getState("currentTime");
5579
+ const playbackState = this.api.getState("playbackState");
5580
+ const error = this.api.getState("error");
5581
+ if (playing) {
5582
+ this.hasStarted = true;
5583
+ }
5584
+ let visible;
5585
+ if (error || this.isOverlayVisible()) {
5586
+ visible = false;
5587
+ } else if (playbackState === "loading") {
5588
+ visible = false;
5589
+ } else if (playing) {
5590
+ visible = false;
5591
+ } else if (ended) {
5592
+ visible = true;
5593
+ } else if (this.hasStarted || currentTime !== 0) {
5594
+ visible = false;
5595
+ } else {
5596
+ visible = playbackState === "idle" || playbackState === "ready";
5597
+ }
5598
+ setHTML(this.el, ended ? icons$1.replay : icons$1.play);
5599
+ setAttr(this.el, "aria-label", ended ? "Replay" : "Play");
5600
+ this.el.classList.toggle("sp-big-play--visible", visible);
5601
+ }
5602
+ /**
5603
+ * Whether playback has actually ended, asked of the media element.
5604
+ *
5605
+ * NOT the `ended` state key. Measured in Chrome on 2026-09-02: neither
5606
+ * provider clears that key on a replay (only `load()` does), so after a
5607
+ * viewer replays a video it stays true for the rest of the session, while
5608
+ * `video.ended` correctly goes false the moment the position leaves the
5609
+ * end. Trusting the key would leave this button sitting over playing video,
5610
+ * and would make a later pause bring it back as Replay. The key is the
5611
+ * fallback for the window before a provider has created an element.
5612
+ */
5613
+ hasEnded() {
5614
+ const video = getVideo(this.api.container);
5615
+ return video ? video.ended : Boolean(this.api.getState("ended"));
5616
+ }
5617
+ /**
5618
+ * Start (or restart) playback.
5619
+ *
5620
+ * The same two branches as the control bar's play button: restart from zero
5621
+ * after the video ended, otherwise just play. There is no pause branch,
5622
+ * because this button is never on screen while playback is running. It reads
5623
+ * `video.ended` for the same reason `hasEnded()` does.
5624
+ */
5625
+ start() {
5626
+ const video = getVideo(this.api.container);
5627
+ if (!video) return;
5628
+ if (video.ended) {
5629
+ video.currentTime = 0;
5630
+ }
5631
+ video.play().catch(() => {
5632
+ });
5633
+ }
5634
+ destroy() {
5635
+ this.el.removeEventListener("click", this.clickHandler);
5636
+ this.el.remove();
5637
+ }
5638
+ };
4642
5639
  var ThumbnailPreview = class {
4643
5640
  constructor() {
4644
5641
  this.config = null;
@@ -5146,7 +6143,7 @@ var VolumeControl = class {
5146
6143
  "aria-label": "Mute",
5147
6144
  type: "button"
5148
6145
  });
5149
- this.btn.innerHTML = icons.volumeHigh;
6146
+ this.btn.innerHTML = icons$1.volumeHigh;
5150
6147
  this.btn.onclick = () => this.toggleMute();
5151
6148
  const sliderWrap = createElement("div", { className: "sp-volume__slider-wrap" });
5152
6149
  this.slider = createElement("div", { className: "sp-volume__slider" });
@@ -5178,13 +6175,13 @@ var VolumeControl = class {
5178
6175
  let icon;
5179
6176
  let label;
5180
6177
  if (muted || volume === 0) {
5181
- icon = icons.volumeMute;
6178
+ icon = icons$1.volumeMute;
5182
6179
  label = "Unmute";
5183
6180
  } else if (volume < 0.5) {
5184
- icon = icons.volumeLow;
6181
+ icon = icons$1.volumeLow;
5185
6182
  label = "Mute";
5186
6183
  } else {
5187
- icon = icons.volumeHigh;
6184
+ icon = icons$1.volumeHigh;
5188
6185
  label = "Mute";
5189
6186
  }
5190
6187
  setHTML(this.btn, icon);
@@ -5291,7 +6288,7 @@ var QualityMenu = class {
5291
6288
  this.lastQualitiesJson = "";
5292
6289
  this.api = api;
5293
6290
  this.el = createElement("div", { className: "sp-quality" });
5294
- this.btn = createButton("sp-quality__btn", "Quality", icons.settings);
6291
+ this.btn = createButton("sp-quality__btn", "Quality", icons$1.settings);
5295
6292
  this.btnLabel = createElement("span", { className: "sp-quality__label" });
5296
6293
  this.btnLabel.textContent = "Auto";
5297
6294
  this.btn.appendChild(this.btnLabel);
@@ -5403,7 +6400,7 @@ var CastButton = class {
5403
6400
  this.api = api;
5404
6401
  this.type = type;
5405
6402
  this.supported = type === "chromecast" ? isChromecastSupported() : isAirPlaySupported();
5406
- const icon = type === "chromecast" ? icons.chromecast : icons.airplay;
6403
+ const icon = type === "chromecast" ? icons$1.chromecast : icons$1.airplay;
5407
6404
  const label = type === "chromecast" ? "Cast" : "AirPlay";
5408
6405
  this.el = createButton(`sp-cast sp-cast--${type}`, label, icon);
5409
6406
  this.el.addEventListener("click", () => this.handleClick());
@@ -5427,10 +6424,10 @@ var CastButton = class {
5427
6424
  this.el.classList.toggle("sp-cast--active", !!active);
5428
6425
  this.el.classList.toggle("sp-cast--unavailable", !available && !active);
5429
6426
  if (active) {
5430
- setHTML(this.el, icons.chromecastConnected);
6427
+ setHTML(this.el, icons$1.chromecastConnected);
5431
6428
  setAttr(this.el, "aria-label", "Stop casting");
5432
6429
  } else {
5433
- setHTML(this.el, icons.chromecast);
6430
+ setHTML(this.el, icons$1.chromecast);
5434
6431
  setAttr(this.el, "aria-label", available ? "Cast" : "No Cast devices found");
5435
6432
  }
5436
6433
  } else {
@@ -5481,7 +6478,7 @@ var PipButton = class {
5481
6478
  this.api = api;
5482
6479
  const probe = document.createElement("video");
5483
6480
  this.supported = "pictureInPictureEnabled" in document || "webkitSetPresentationMode" in probe;
5484
- this.el = createButton("sp-pip", "Picture-in-Picture", icons.pip);
6481
+ this.el = createButton("sp-pip", "Picture-in-Picture", icons$1.pip);
5485
6482
  this.el.addEventListener("click", this.clickHandler);
5486
6483
  if (!this.supported) {
5487
6484
  this.el.style.display = "none";
@@ -5504,7 +6501,7 @@ var PipButton = class {
5504
6501
  const enabled = pip || this.isMediaReady();
5505
6502
  this.el.disabled = !enabled;
5506
6503
  setAttr(this.el, "aria-disabled", String(!enabled));
5507
- setHTML(this.el, pip ? icons.exitPip : icons.pip);
6504
+ setHTML(this.el, pip ? icons$1.exitPip : icons$1.pip);
5508
6505
  setAttr(this.el, "aria-label", pip ? "Exit Picture-in-Picture" : "Picture-in-Picture");
5509
6506
  this.el.classList.toggle("sp-pip--active", pip);
5510
6507
  }
@@ -5553,7 +6550,7 @@ var FullscreenButton = class {
5553
6550
  this.toggle();
5554
6551
  };
5555
6552
  this.api = api;
5556
- this.el = createButton("sp-fullscreen", "Fullscreen", icons.fullscreen);
6553
+ this.el = createButton("sp-fullscreen", "Fullscreen", icons$1.fullscreen);
5557
6554
  this.el.addEventListener("click", this.clickHandler);
5558
6555
  }
5559
6556
  render() {
@@ -5562,23 +6559,29 @@ var FullscreenButton = class {
5562
6559
  update() {
5563
6560
  const fullscreen = this.api.getState("fullscreen");
5564
6561
  if (fullscreen) {
5565
- setHTML(this.el, icons.exitFullscreen);
6562
+ setHTML(this.el, icons$1.exitFullscreen);
5566
6563
  setAttr(this.el, "aria-label", "Exit fullscreen");
5567
6564
  } else {
5568
- setHTML(this.el, icons.fullscreen);
6565
+ setHTML(this.el, icons$1.fullscreen);
5569
6566
  setAttr(this.el, "aria-label", "Fullscreen");
5570
6567
  }
5571
6568
  }
6569
+ /**
6570
+ * Enter or leave fullscreen.
6571
+ *
6572
+ * The direction comes from the browser rather than from the `fullscreen`
6573
+ * state key: state is a report of what happened, and a stale one would invert
6574
+ * the button. Rejections are swallowed because the browser refuses these
6575
+ * routinely (no user gesture, denied by permission policy) and an unhandled
6576
+ * rejection helps nobody.
6577
+ */
5572
6578
  async toggle() {
5573
6579
  const container = this.api.container;
5574
- const video = getVideo(container);
5575
6580
  try {
5576
- if (document.fullscreenElement) {
5577
- await document.exitFullscreen();
5578
- } else if (container.requestFullscreen) {
5579
- await container.requestFullscreen();
5580
- } else if (video?.webkitEnterFullscreen) {
5581
- video.webkitEnterFullscreen();
6581
+ if (isFullscreen(container)) {
6582
+ await exitFullscreen(container);
6583
+ } else {
6584
+ await enterFullscreen(container);
5582
6585
  }
5583
6586
  } catch {
5584
6587
  }
@@ -5669,7 +6672,7 @@ var ErrorOverlay = class {
5669
6672
  content.className = "sp-error-overlay__content";
5670
6673
  const iconEl = document.createElement("div");
5671
6674
  iconEl.className = "sp-error-overlay__icon";
5672
- iconEl.innerHTML = icons.error;
6675
+ iconEl.innerHTML = icons$1.error;
5673
6676
  const messageEl = document.createElement("p");
5674
6677
  messageEl.className = "sp-error-overlay__message";
5675
6678
  messageEl.textContent = "Something went wrong.";
@@ -5775,7 +6778,7 @@ var SettingsMenu = class {
5775
6778
  this.lastQualitiesJson = "";
5776
6779
  this.api = api;
5777
6780
  this.el = createElement("div", { className: "sp-settings" });
5778
- this.btn = createButton("sp-settings__btn", "Settings", icons.settings);
6781
+ this.btn = createButton("sp-settings__btn", "Settings", icons$1.settings);
5779
6782
  this.btn.setAttribute("aria-haspopup", "true");
5780
6783
  this.btn.setAttribute("aria-expanded", "false");
5781
6784
  this.btn.addEventListener("click", (e) => {
@@ -5920,7 +6923,7 @@ var SettingsMenu = class {
5920
6923
  const rightSide = createElement("span", { className: "sp-settings-panel__value" });
5921
6924
  rightSide.textContent = value;
5922
6925
  const arrow = createElement("span", { className: "sp-settings-panel__arrow" });
5923
- arrow.innerHTML = icons.chevronDown;
6926
+ arrow.innerHTML = icons$1.chevronDown;
5924
6927
  rightSide.appendChild(arrow);
5925
6928
  row.appendChild(labelEl);
5926
6929
  row.appendChild(rightSide);
@@ -6020,7 +7023,7 @@ var SettingsMenu = class {
6020
7023
  header.setAttribute("role", "menuitem");
6021
7024
  header.setAttribute("tabindex", "0");
6022
7025
  const backArrow = createElement("span", { className: "sp-settings-panel__back" });
6023
- backArrow.innerHTML = icons.chevronUp;
7026
+ backArrow.innerHTML = icons$1.chevronUp;
6024
7027
  const label = createElement("span", { className: "sp-settings-panel__header-label" });
6025
7028
  label.textContent = title;
6026
7029
  header.appendChild(backArrow);
@@ -6047,7 +7050,7 @@ var SettingsMenu = class {
6047
7050
  const labelEl = createElement("span");
6048
7051
  labelEl.textContent = label;
6049
7052
  const check = createElement("span", { className: "sp-settings-panel__check" });
6050
- check.innerHTML = icons.checkmark;
7053
+ check.innerHTML = icons$1.checkmark;
6051
7054
  item.appendChild(labelEl);
6052
7055
  item.appendChild(check);
6053
7056
  item.addEventListener("keydown", (e) => {
@@ -6141,7 +7144,7 @@ var SkipButton = class {
6141
7144
  this.api = api;
6142
7145
  this.direction = direction;
6143
7146
  this.seconds = seconds;
6144
- const icon = direction === "backward" ? icons.replay10 : icons.forward10;
7147
+ const icon = direction === "backward" ? icons$1.replay10 : icons$1.forward10;
6145
7148
  const label = direction === "backward" ? `Rewind ${seconds} seconds` : `Forward ${seconds} seconds`;
6146
7149
  this.el = createButton(
6147
7150
  `sp-skip sp-skip--${direction}`,
@@ -6203,7 +7206,7 @@ var CaptionsButton = class {
6203
7206
  this.toggle();
6204
7207
  };
6205
7208
  this.api = api;
6206
- this.el = createButton("sp-captions", "Captions", icons.captionsOff);
7209
+ this.el = createButton("sp-captions", "Captions", icons$1.captionsOff);
6207
7210
  this.el.addEventListener("click", this.clickHandler);
6208
7211
  }
6209
7212
  render() {
@@ -6218,11 +7221,11 @@ var CaptionsButton = class {
6218
7221
  }
6219
7222
  this.el.style.display = "";
6220
7223
  if (currentTrack) {
6221
- setHTML(this.el, icons.captions);
7224
+ setHTML(this.el, icons$1.captions);
6222
7225
  setAttr(this.el, "aria-label", `Captions: ${currentTrack.label}`);
6223
7226
  this.el.classList.add("sp-captions--active");
6224
7227
  } else {
6225
- setHTML(this.el, icons.captionsOff);
7228
+ setHTML(this.el, icons$1.captionsOff);
6226
7229
  setAttr(this.el, "aria-label", "Captions");
6227
7230
  this.el.classList.remove("sp-captions--active");
6228
7231
  }
@@ -6273,6 +7276,153 @@ var BandwidthIndicator = class {
6273
7276
  this.el.remove();
6274
7277
  }
6275
7278
  };
7279
+ var OverflowTray = class {
7280
+ /**
7281
+ * @param api - Plugin API, kept for parity with the other controls and for
7282
+ * logging; the tray itself reads no state
7283
+ */
7284
+ constructor(api) {
7285
+ this.api = api;
7286
+ this.isOpen = false;
7287
+ this.toggleHandler = () => {
7288
+ this.isOpen ? this.close() : this.open();
7289
+ };
7290
+ this.el = createElement("div", { className: "sp-overflow" });
7291
+ this.btn = createButton("sp-overflow__btn", "More controls", icons$1.more);
7292
+ this.btn.setAttribute("aria-haspopup", "true");
7293
+ this.btn.setAttribute("aria-expanded", "false");
7294
+ this.btn.addEventListener("click", this.toggleHandler);
7295
+ this.panel = createElement("div", {
7296
+ className: "sp-overflow-tray",
7297
+ role: "group",
7298
+ "aria-label": "More controls"
7299
+ });
7300
+ this.el.appendChild(this.btn);
7301
+ this.el.appendChild(this.panel);
7302
+ this.el.style.display = "none";
7303
+ this.closeHandler = (e) => {
7304
+ if (!this.el.contains(e.target)) {
7305
+ this.close();
7306
+ }
7307
+ };
7308
+ document.addEventListener("click", this.closeHandler);
7309
+ this.keyHandler = (e) => {
7310
+ if (!this.isOpen || e.key !== "Escape") return;
7311
+ e.preventDefault();
7312
+ e.stopPropagation();
7313
+ this.close();
7314
+ this.btn.focus();
7315
+ };
7316
+ document.addEventListener("keydown", this.keyHandler);
7317
+ }
7318
+ /**
7319
+ * The bar item to place in the control bar.
7320
+ *
7321
+ * @returns The wrapper holding the button and the strip
7322
+ */
7323
+ render() {
7324
+ return this.el;
7325
+ }
7326
+ /**
7327
+ * No state of its own: the fit loop owns what is inside it.
7328
+ */
7329
+ update() {
7330
+ }
7331
+ /**
7332
+ * Move a control's element into the tray.
7333
+ *
7334
+ * The element is moved as-is, so its class, icon, aria-label, event handlers
7335
+ * and `update()` all keep working.
7336
+ *
7337
+ * @param el - The control element leaving the bar
7338
+ */
7339
+ adopt(el) {
7340
+ this.panel.appendChild(el);
7341
+ this.syncVisibility();
7342
+ }
7343
+ /**
7344
+ * Take a control's element back out of the tray.
7345
+ *
7346
+ * The caller decides where in the bar it goes; this only detaches it and
7347
+ * updates the button's visibility.
7348
+ *
7349
+ * @param el - The control element returning to the bar
7350
+ * @returns The same element, detached
7351
+ */
7352
+ release(el) {
7353
+ if (el.parentNode === this.panel) {
7354
+ this.panel.removeChild(el);
7355
+ }
7356
+ this.syncVisibility();
7357
+ return el;
7358
+ }
7359
+ /**
7360
+ * Whether an element is currently held by the tray.
7361
+ *
7362
+ * @param el - Element to test
7363
+ * @returns True when the tray is its parent
7364
+ */
7365
+ holds(el) {
7366
+ return el.parentNode === this.panel;
7367
+ }
7368
+ /**
7369
+ * Open the strip.
7370
+ */
7371
+ open() {
7372
+ if (this.isOpen) return;
7373
+ this.isOpen = true;
7374
+ this.panel.classList.add("sp-overflow-tray--open");
7375
+ this.btn.setAttribute("aria-expanded", "true");
7376
+ }
7377
+ /**
7378
+ * Close the strip.
7379
+ *
7380
+ * Deliberately not called after a control inside it is used: skip, PiP and
7381
+ * cast are things a viewer taps more than once in a row.
7382
+ */
7383
+ close() {
7384
+ if (!this.isOpen) return;
7385
+ this.isOpen = false;
7386
+ this.panel.classList.remove("sp-overflow-tray--open");
7387
+ this.btn.setAttribute("aria-expanded", "false");
7388
+ }
7389
+ /**
7390
+ * Show the button only while the tray holds something the viewer can see.
7391
+ *
7392
+ * A control that hid itself (no cast device on the network, no text tracks)
7393
+ * can be sitting in the tray with `display: none`, and a button that opens an
7394
+ * empty strip is worse than no button at all.
7395
+ */
7396
+ syncVisibility() {
7397
+ const usable = Array.from(this.panel.children).some(
7398
+ (child) => child.style.display !== "none"
7399
+ );
7400
+ this.el.style.display = usable ? "" : "none";
7401
+ if (!usable && this.isOpen) {
7402
+ this.close();
7403
+ }
7404
+ }
7405
+ /**
7406
+ * Re-check the button's visibility after the controls have updated
7407
+ * themselves.
7408
+ */
7409
+ refresh() {
7410
+ this.syncVisibility();
7411
+ }
7412
+ /**
7413
+ * Remove the document listeners and the tray itself.
7414
+ *
7415
+ * Adopted elements are left where they are: they belong to their own
7416
+ * controls, which are destroyed by the plugin alongside this one.
7417
+ */
7418
+ destroy() {
7419
+ document.removeEventListener("click", this.closeHandler);
7420
+ document.removeEventListener("keydown", this.keyHandler);
7421
+ this.btn.removeEventListener("click", this.toggleHandler);
7422
+ this.el.remove();
7423
+ this.api.logger.debug("Overflow tray destroyed");
7424
+ }
7425
+ };
6276
7426
  var registry = /* @__PURE__ */ new Map();
6277
7427
  var listeners = /* @__PURE__ */ new Set();
6278
7428
  function registerControl(id, factory) {
@@ -6290,6 +7440,7 @@ function onControlRegistered(listener) {
6290
7440
  listeners.delete(listener);
6291
7441
  };
6292
7442
  }
7443
+ var PKG_VERSION$9 = "1.8.1";
6293
7444
  var DEFAULT_LAYOUT = [
6294
7445
  "play",
6295
7446
  "skip-backward",
@@ -6307,6 +7458,14 @@ var DEFAULT_LAYOUT = [
6307
7458
  "fullscreen"
6308
7459
  ];
6309
7460
  var DEFAULT_HIDE_DELAY = 3e3;
7461
+ var UNMEASURED_CONTROL_WIDTH = 48;
7462
+ var FALLBACK_VOLUME_SLIDER_WIDTH = 64;
7463
+ var OVERFLOW_BUTTON_WIDTH = 44;
7464
+ var FALLBACK_BAR_PADDING_X = 24;
7465
+ var FALLBACK_BAR_GAP = 4;
7466
+ var MENU_HEIGHT_RESERVE = 16;
7467
+ var FALLBACK_BAR_HEIGHT = 56;
7468
+ var MIN_MENU_HEIGHT = 120;
6310
7469
  function uiPlugin(config = {}) {
6311
7470
  let api;
6312
7471
  let controlBar = null;
@@ -6314,6 +7473,7 @@ function uiPlugin(config = {}) {
6314
7473
  let progressBar = null;
6315
7474
  let bufferingIndicator = null;
6316
7475
  let errorOverlay = null;
7476
+ let bigPlayButton = null;
6317
7477
  let styleEl = null;
6318
7478
  let controls = [];
6319
7479
  let hideTimeout = null;
@@ -6324,8 +7484,22 @@ function uiPlugin(config = {}) {
6324
7484
  let recoveredUnsubscribe = null;
6325
7485
  let controlsVisible = true;
6326
7486
  let rafHandle = null;
7487
+ let tray = null;
7488
+ let entries = [];
7489
+ let timeEntry = null;
7490
+ let resizeObserver = null;
7491
+ let barPaddingX = FALLBACK_BAR_PADDING_X;
7492
+ let barGap = FALLBACK_BAR_GAP;
7493
+ let volumeSliderWidth = FALLBACK_VOLUME_SLIDER_WIDTH;
7494
+ let lastFitSignature = null;
7495
+ let fitPending = true;
6327
7496
  const layout = config.controls || DEFAULT_LAYOUT;
6328
7497
  const hideDelay = config.hideDelay ?? DEFAULT_HIDE_DELAY;
7498
+ const showBigPlayButton = config.bigPlayButton !== false;
7499
+ const responsive = config.responsive !== false;
7500
+ if (responsive) {
7501
+ assertFitLayout(layout, config.priority);
7502
+ }
6329
7503
  const createControl = (slot) => {
6330
7504
  switch (slot) {
6331
7505
  case "play":
@@ -6379,13 +7553,179 @@ function uiPlugin(config = {}) {
6379
7553
  if (!controlBar) {
6380
7554
  return;
6381
7555
  }
7556
+ const rules = new Map(
7557
+ resolveFitItems(layout, config.priority).map((template) => [template.id, template])
7558
+ );
6382
7559
  for (const slot of layout) {
6383
7560
  const control = createControl(slot);
6384
- if (control) {
6385
- controls.push(control);
6386
- controlBar.appendChild(control.render());
7561
+ if (!control) {
7562
+ continue;
7563
+ }
7564
+ controls.push(control);
7565
+ const el = control.render();
7566
+ controlBar.appendChild(el);
7567
+ const rule = rules.get(slot);
7568
+ const entry = {
7569
+ slot,
7570
+ control,
7571
+ el,
7572
+ rank: rule?.rank ?? "never",
7573
+ exit: rule?.exit ?? "overflow",
7574
+ width: -1
7575
+ };
7576
+ entries.push(entry);
7577
+ if (slot === "time") {
7578
+ timeEntry = entry;
7579
+ }
7580
+ }
7581
+ if (responsive) {
7582
+ tray = new OverflowTray(api);
7583
+ controls.push(tray);
7584
+ controlBar.appendChild(tray.render());
7585
+ placeTrayButton();
7586
+ }
7587
+ };
7588
+ const placeTrayButton = () => {
7589
+ if (!controlBar || !tray) {
7590
+ return;
7591
+ }
7592
+ const trayEl = tray.render();
7593
+ const fullscreen = entries.find((entry) => entry.slot === "fullscreen");
7594
+ const before = fullscreen && fullscreen.el.parentNode === controlBar ? fullscreen.el : null;
7595
+ if (before) {
7596
+ if (trayEl.nextSibling !== before) {
7597
+ controlBar.insertBefore(trayEl, before);
7598
+ }
7599
+ return;
7600
+ }
7601
+ if (controlBar.lastChild !== trayEl) {
7602
+ controlBar.appendChild(trayEl);
7603
+ }
7604
+ };
7605
+ const visibilitySignature = () => {
7606
+ let flags = "";
7607
+ for (const entry of entries) {
7608
+ flags += entry.el.style.display === "none" ? "0" : "1";
7609
+ }
7610
+ return `${flags}:${timeEntry?.el.textContent?.length ?? 0}`;
7611
+ };
7612
+ const applyFit = (plan) => {
7613
+ if (!controlBar || !tray) {
7614
+ return;
7615
+ }
7616
+ const overflow = new Set(plan.overflow);
7617
+ const hidden = new Set(plan.hidden);
7618
+ for (const entry of entries) {
7619
+ if (entry.slot === "spacer") {
7620
+ continue;
7621
+ }
7622
+ if (hidden.has(entry.slot)) {
7623
+ if (tray.holds(entry.el)) {
7624
+ returnToBar(entry);
7625
+ }
7626
+ entry.el.classList.add("sp-control--collapsed");
7627
+ continue;
7628
+ }
7629
+ entry.el.classList.remove("sp-control--collapsed");
7630
+ if (overflow.has(entry.slot)) {
7631
+ if (!tray.holds(entry.el)) {
7632
+ tray.adopt(entry.el);
7633
+ }
7634
+ } else if (tray.holds(entry.el)) {
7635
+ returnToBar(entry);
7636
+ }
7637
+ }
7638
+ tray.refresh();
7639
+ placeTrayButton();
7640
+ };
7641
+ const returnToBar = (entry) => {
7642
+ if (!controlBar || !tray) {
7643
+ return;
7644
+ }
7645
+ const el = tray.release(entry.el);
7646
+ const trayEl = tray.render();
7647
+ let before = trayEl.parentNode === controlBar ? trayEl : null;
7648
+ for (let i = entries.indexOf(entry) + 1; i < entries.length; i++) {
7649
+ if (entries[i].el.parentNode === controlBar) {
7650
+ before = entries[i].el;
7651
+ break;
6387
7652
  }
6388
7653
  }
7654
+ controlBar.insertBefore(el, before);
7655
+ };
7656
+ const expandedWidth = (entry) => {
7657
+ if (entry.slot !== "volume") {
7658
+ return 0;
7659
+ }
7660
+ const wrap = entry.el.querySelector(".sp-volume__slider-wrap");
7661
+ return wrap ? wrap.getBoundingClientRect().width : 0;
7662
+ };
7663
+ const interactionReserve = (entry) => entry.slot === "volume" ? volumeSliderWidth : 0;
7664
+ const readBarMetrics = (bar) => {
7665
+ const barStyle = getComputedStyle(bar);
7666
+ const paddingLeft = parseFloat(barStyle.paddingLeft);
7667
+ const paddingRight = parseFloat(barStyle.paddingRight);
7668
+ const gap = parseFloat(barStyle.columnGap || barStyle.gap);
7669
+ const sliderWidth = parseFloat(
7670
+ barStyle.getPropertyValue("--sp-volume-slider-width")
7671
+ );
7672
+ barPaddingX = Number.isFinite(paddingLeft) && Number.isFinite(paddingRight) ? paddingLeft + paddingRight : FALLBACK_BAR_PADDING_X;
7673
+ barGap = Number.isFinite(gap) ? gap : FALLBACK_BAR_GAP;
7674
+ volumeSliderWidth = Number.isFinite(sliderWidth) ? sliderWidth : FALLBACK_VOLUME_SLIDER_WIDTH;
7675
+ };
7676
+ const fitControls = () => {
7677
+ if (!responsive || !controlBar || !tray) {
7678
+ return;
7679
+ }
7680
+ if (controlBar.clientWidth === 0) {
7681
+ return;
7682
+ }
7683
+ readBarMetrics(controlBar);
7684
+ const spacerGaps = entries.filter(
7685
+ (entry) => entry.slot === "spacer" && entry.el.style.display !== "none"
7686
+ ).length;
7687
+ const available = controlBar.clientWidth - barPaddingX - spacerGaps * barGap;
7688
+ const items = [];
7689
+ for (const entry of entries) {
7690
+ if (entry.slot === "spacer") {
7691
+ continue;
7692
+ }
7693
+ const visible = entry.el.style.display !== "none";
7694
+ const measurable = visible && entry.el.parentNode === controlBar && !entry.el.classList.contains("sp-control--collapsed");
7695
+ if (measurable) {
7696
+ entry.width = entry.el.getBoundingClientRect().width - expandedWidth(entry);
7697
+ } else if (entry.width < 0) {
7698
+ entry.width = UNMEASURED_CONTROL_WIDTH;
7699
+ }
7700
+ items.push({
7701
+ id: entry.slot,
7702
+ rank: entry.rank,
7703
+ exit: entry.exit,
7704
+ width: entry.width + interactionReserve(entry),
7705
+ visible
7706
+ });
7707
+ }
7708
+ const trayEl = tray.render();
7709
+ const trayWidth = trayEl.style.display === "none" ? 0 : trayEl.getBoundingClientRect().width;
7710
+ applyFit(planFit(items, available, barGap, trayWidth || OVERFLOW_BUTTON_WIDTH));
7711
+ lastFitSignature = visibilitySignature();
7712
+ fitPending = false;
7713
+ };
7714
+ const maybeFit = () => {
7715
+ if (!responsive) {
7716
+ return;
7717
+ }
7718
+ if (!fitPending && visibilitySignature() === lastFitSignature) {
7719
+ return;
7720
+ }
7721
+ fitControls();
7722
+ };
7723
+ const applyMenuBounds = (height) => {
7724
+ const barHeight = controlBar?.offsetHeight || FALLBACK_BAR_HEIGHT;
7725
+ api?.container?.style.setProperty(
7726
+ "--sp-menu-max-height",
7727
+ `${Math.max(MIN_MENU_HEIGHT, Math.round(height) - barHeight - MENU_HEIGHT_RESERVE)}px`
7728
+ );
6389
7729
  };
6390
7730
  const rebuildControlBar = () => {
6391
7731
  if (!controlBar) {
@@ -6393,7 +7733,12 @@ function uiPlugin(config = {}) {
6393
7733
  }
6394
7734
  controls.forEach((c) => c.destroy());
6395
7735
  controls = [];
7736
+ entries = [];
7737
+ timeEntry = null;
7738
+ tray = null;
6396
7739
  controlBar.replaceChildren();
7740
+ lastFitSignature = null;
7741
+ fitPending = true;
6397
7742
  populateControlBar();
6398
7743
  updateControls();
6399
7744
  };
@@ -6407,6 +7752,8 @@ function uiPlugin(config = {}) {
6407
7752
  const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
6408
7753
  bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
6409
7754
  errorOverlay?.update();
7755
+ bigPlayButton?.update();
7756
+ maybeFit();
6410
7757
  };
6411
7758
  const scheduleUpdate = () => {
6412
7759
  if (rafHandle !== null) return;
@@ -6485,11 +7832,11 @@ function uiPlugin(config = {}) {
6485
7832
  break;
6486
7833
  case "f":
6487
7834
  e.preventDefault();
6488
- if (document.fullscreenElement) {
6489
- document.exitFullscreen().catch(() => {
7835
+ if (isFullscreen(api.container)) {
7836
+ exitFullscreen(api.container).catch(() => {
6490
7837
  });
6491
7838
  } else {
6492
- api.container.requestFullscreen?.().catch(() => {
7839
+ enterFullscreen(api.container).catch(() => {
6493
7840
  });
6494
7841
  }
6495
7842
  break;
@@ -6527,11 +7874,11 @@ function uiPlugin(config = {}) {
6527
7874
  id: "ui-controls",
6528
7875
  name: "UI Controls",
6529
7876
  type: "ui",
6530
- version: "1.0.0",
7877
+ version: PKG_VERSION$9,
6531
7878
  async init(pluginApi) {
6532
7879
  api = pluginApi;
6533
7880
  styleEl = document.createElement("style");
6534
- styleEl.textContent = styles$1;
7881
+ styleEl.textContent = styles$3;
6535
7882
  document.head.appendChild(styleEl);
6536
7883
  if (config.theme) {
6537
7884
  this.setTheme(config.theme);
@@ -6551,7 +7898,7 @@ function uiPlugin(config = {}) {
6551
7898
  container.appendChild(gradient);
6552
7899
  bufferingIndicator = document.createElement("div");
6553
7900
  bufferingIndicator.className = "sp-buffering";
6554
- bufferingIndicator.innerHTML = icons.spinner;
7901
+ bufferingIndicator.innerHTML = icons$1.spinner;
6555
7902
  bufferingIndicator.setAttribute("aria-hidden", "true");
6556
7903
  container.appendChild(bufferingIndicator);
6557
7904
  errorOverlay = new ErrorOverlay(api);
@@ -6568,6 +7915,10 @@ function uiPlugin(config = {}) {
6568
7915
  recoveredUnsubscribe = api.on("error:recovered", () => {
6569
7916
  errorOverlay?.hide();
6570
7917
  });
7918
+ if (showBigPlayButton) {
7919
+ bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);
7920
+ container.appendChild(bigPlayButton.render());
7921
+ }
6571
7922
  progressBar = new ProgressBar(api);
6572
7923
  container.appendChild(progressBar.render());
6573
7924
  if (!isPlaying) {
@@ -6579,6 +7930,14 @@ function uiPlugin(config = {}) {
6579
7930
  controlBar.setAttribute("aria-label", "Video controls");
6580
7931
  populateControlBar();
6581
7932
  container.appendChild(controlBar);
7933
+ if (responsive && typeof ResizeObserver === "function") {
7934
+ resizeObserver = new ResizeObserver((observed) => {
7935
+ applyMenuBounds(observed[0]?.contentRect.height ?? container.clientHeight);
7936
+ fitPending = true;
7937
+ scheduleUpdate();
7938
+ });
7939
+ resizeObserver.observe(container);
7940
+ }
6582
7941
  controlRegistryUnsubscribe = onControlRegistered((id) => {
6583
7942
  if (!layout.includes(id)) {
6584
7943
  return;
@@ -6595,7 +7954,6 @@ function uiPlugin(config = {}) {
6595
7954
  container.addEventListener("click", handleInteraction);
6596
7955
  document.addEventListener("keydown", handleKeyDown);
6597
7956
  stateUnsubscribe = api.subscribeToState(scheduleUpdate);
6598
- document.addEventListener("fullscreenchange", scheduleUpdate);
6599
7957
  updateControls();
6600
7958
  if (!container.hasAttribute("tabindex")) {
6601
7959
  container.setAttribute("tabindex", "0");
@@ -6616,6 +7974,9 @@ function uiPlugin(config = {}) {
6616
7974
  cancelAnimationFrame(rafHandle);
6617
7975
  rafHandle = null;
6618
7976
  }
7977
+ resizeObserver?.disconnect();
7978
+ resizeObserver = null;
7979
+ api?.container?.style.removeProperty("--sp-menu-max-height");
6619
7980
  stateUnsubscribe?.();
6620
7981
  stateUnsubscribe = null;
6621
7982
  errorUnsubscribe?.();
@@ -6634,15 +7995,19 @@ function uiPlugin(config = {}) {
6634
7995
  api.container.removeEventListener("click", handleInteraction);
6635
7996
  }
6636
7997
  document.removeEventListener("keydown", handleKeyDown);
6637
- document.removeEventListener("fullscreenchange", scheduleUpdate);
6638
7998
  controlRegistryUnsubscribe?.();
6639
7999
  controlRegistryUnsubscribe = null;
6640
8000
  controls.forEach((c) => c.destroy());
6641
8001
  controls = [];
8002
+ entries = [];
8003
+ timeEntry = null;
8004
+ tray = null;
6642
8005
  progressBar?.destroy();
6643
8006
  progressBar = null;
6644
8007
  errorOverlay?.destroy();
6645
8008
  errorOverlay = null;
8009
+ bigPlayButton?.destroy();
8010
+ bigPlayButton = null;
6646
8011
  controlBar?.remove();
6647
8012
  controlBar = null;
6648
8013
  gradient?.remove();
@@ -6690,14 +8055,19 @@ function uiPlugin(config = {}) {
6690
8055
  }
6691
8056
  const index = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
6692
8057
  __proto__: null,
8058
+ DEFAULT_PRIORITY,
8059
+ assertFitLayout,
6693
8060
  formatLiveTime,
6694
8061
  formatTime: formatTime$1,
6695
8062
  getControlFactory,
6696
- icons,
8063
+ icons: icons$1,
8064
+ planFit,
6697
8065
  registerControl,
6698
- styles: styles$1,
8066
+ resolveFitItems,
8067
+ styles: styles$3,
6699
8068
  uiPlugin
6700
8069
  }, Symbol.toStringTag, { value: "Module" }));
8070
+ var PKG_VERSION$8 = "1.8.1";
6701
8071
  var DEFAULT_THEME = {
6702
8072
  primary: "#6366f1",
6703
8073
  background: "#18181b",
@@ -7324,7 +8694,7 @@ function createAudioUIPlugin(config) {
7324
8694
  const plugin = {
7325
8695
  id: "audio-ui",
7326
8696
  name: "Audio UI",
7327
- version: "1.0.0",
8697
+ version: PKG_VERSION$8,
7328
8698
  type: "ui",
7329
8699
  description: "Compact audio player interface",
7330
8700
  async init(pluginApi) {
@@ -7629,13 +8999,26 @@ function safeStringify(data) {
7629
8999
  return "{}";
7630
9000
  }
7631
9001
  }
7632
- var PLUGIN_VERSION = "0.1.0";
7633
- var PLUGIN_NAME = "scarlett-player";
7634
- var DEFAULT_CONFIG$2 = {
7635
- heartbeatInterval: 1e4,
7636
- errorSampleRate: 1,
7637
- disableInDev: false
7638
- };
9002
+ function isHttpsUrl(url) {
9003
+ if (!url || typeof url !== "string" || !url.trim()) {
9004
+ return false;
9005
+ }
9006
+ try {
9007
+ const base = typeof window !== "undefined" && window.location?.href ? window.location.href : void 0;
9008
+ const parsed = base ? new URL(url, base) : new URL(url);
9009
+ return parsed.protocol === "https:";
9010
+ } catch {
9011
+ return false;
9012
+ }
9013
+ }
9014
+ var PKG_VERSION$7 = "1.8.1";
9015
+ var PLUGIN_VERSION = PKG_VERSION$7;
9016
+ var PLUGIN_NAME = "scarlett-player";
9017
+ var DEFAULT_CONFIG$2 = {
9018
+ heartbeatInterval: 1e4,
9019
+ errorSampleRate: 1,
9020
+ disableInDev: false
9021
+ };
7639
9022
  function createAnalyticsPlugin(config) {
7640
9023
  if (!config.beaconUrl) {
7641
9024
  throw new Error("Analytics plugin requires beaconUrl");
@@ -7723,11 +9106,14 @@ function createAnalyticsPlugin(config) {
7723
9106
  });
7724
9107
  navigator.sendBeacon(mergedConfig.beaconUrl, blob);
7725
9108
  } else {
9109
+ const shouldAttachApiKey = Boolean(
9110
+ mergedConfig.apiKey && isHttpsUrl(mergedConfig.beaconUrl)
9111
+ );
7726
9112
  fetch(mergedConfig.beaconUrl, {
7727
9113
  method: "POST",
7728
9114
  headers: {
7729
9115
  "Content-Type": "application/json",
7730
- ...mergedConfig.apiKey ? { "X-API-Key": mergedConfig.apiKey } : {}
9116
+ ...shouldAttachApiKey ? { "X-API-Key": mergedConfig.apiKey } : {}
7731
9117
  },
7732
9118
  body: safeStringify(payload),
7733
9119
  keepalive: true
@@ -8167,8 +9553,8 @@ var PlaylistPanel = class {
8167
9553
  });
8168
9554
  }
8169
9555
  };
8170
- var STYLE_ID = "sp-playlist-styles";
8171
- var styles = `
9556
+ var STYLE_ID$2 = "sp-playlist-styles";
9557
+ var styles$2 = `
8172
9558
  .sp-playlist-skip[disabled] {
8173
9559
  opacity: 0.4;
8174
9560
  cursor: default;
@@ -8268,15 +9654,16 @@ var styles = `
8268
9654
  }
8269
9655
  `;
8270
9656
  function injectStyles() {
8271
- if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
9657
+ if (typeof document === "undefined" || document.getElementById(STYLE_ID$2)) {
8272
9658
  return null;
8273
9659
  }
8274
9660
  const el = document.createElement("style");
8275
- el.id = STYLE_ID;
8276
- el.textContent = styles;
9661
+ el.id = STYLE_ID$2;
9662
+ el.textContent = styles$2;
8277
9663
  document.head.appendChild(el);
8278
9664
  return el;
8279
9665
  }
9666
+ var PKG_VERSION$6 = "1.8.1";
8280
9667
  var DEFAULT_CONFIG$1 = {
8281
9668
  autoAdvance: true,
8282
9669
  preloadNext: true,
@@ -8425,9 +9812,7 @@ function createPlaylistPlugin(config) {
8425
9812
  currentIndex = index2;
8426
9813
  api?.logger.info("Track changed", { index: index2, title: track.title, src: track.src });
8427
9814
  api?.setState("title", track.title || "");
8428
- if (track.artwork) {
8429
- api?.setState("poster", track.artwork);
8430
- }
9815
+ api?.setState("poster", track.artwork || "");
8431
9816
  api?.setState("mediaType", track.type || "audio");
8432
9817
  emitChange();
8433
9818
  if (mergedConfig.autoLoad !== false && track.src) {
@@ -8437,7 +9822,7 @@ function createPlaylistPlugin(config) {
8437
9822
  const plugin = {
8438
9823
  id: "playlist",
8439
9824
  name: "Playlist",
8440
- version: "1.0.0",
9825
+ version: PKG_VERSION$6,
8441
9826
  type: "feature",
8442
9827
  description: "Playlist management with shuffle, repeat, and gapless playback",
8443
9828
  async init(pluginApi) {
@@ -8693,6 +10078,7 @@ function createPlaylistPlugin(config) {
8693
10078
  };
8694
10079
  return plugin;
8695
10080
  }
10081
+ var PKG_VERSION$5 = "1.8.1";
8696
10082
  var DEFAULT_CONFIG = {
8697
10083
  enablePlayPause: true,
8698
10084
  enableSeek: true,
@@ -8840,7 +10226,7 @@ function createMediaSessionPlugin(config) {
8840
10226
  const plugin = {
8841
10227
  id: "media-session",
8842
10228
  name: "Media Session",
8843
- version: "1.0.0",
10229
+ version: PKG_VERSION$5,
8844
10230
  type: "feature",
8845
10231
  description: "Media Session API integration for system-level media controls",
8846
10232
  async init(pluginApi) {
@@ -8951,6 +10337,7 @@ function createMediaSessionPlugin(config) {
8951
10337
  };
8952
10338
  return plugin;
8953
10339
  }
10340
+ var PKG_VERSION$4 = "1.8.1";
8954
10341
  var POSITIONS = ["top-left", "top-right", "bottom-left", "bottom-right", "center"];
8955
10342
  function getPositionStyles(padding, bottomPadding) {
8956
10343
  return {
@@ -9060,7 +10447,7 @@ function createWatermarkPlugin(config = {}) {
9060
10447
  return {
9061
10448
  id: "watermark",
9062
10449
  name: "Watermark",
9063
- version: "1.0.0",
10450
+ version: PKG_VERSION$4,
9064
10451
  type: "feature",
9065
10452
  description: "Anti-piracy watermark overlay with text/image support and dynamic repositioning",
9066
10453
  init(pluginApi) {
@@ -9145,6 +10532,7 @@ function createWatermarkPlugin(config = {}) {
9145
10532
  }
9146
10533
  };
9147
10534
  }
10535
+ var PKG_VERSION$3 = "1.8.1";
9148
10536
  var HLS_SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated";
9149
10537
  var HLS_INSTANCE_RETRY_MS = 500;
9150
10538
  function createCaptionsPlugin(config = {}) {
@@ -9315,7 +10703,7 @@ function createCaptionsPlugin(config = {}) {
9315
10703
  return {
9316
10704
  id: "captions",
9317
10705
  name: "Captions",
9318
- version: "1.0.0",
10706
+ version: PKG_VERSION$3,
9319
10707
  type: "feature",
9320
10708
  description: "WebVTT subtitles and closed captions with HLS extraction",
9321
10709
  init(pluginApi) {
@@ -9364,6 +10752,1305 @@ function createCaptionsPlugin(config = {}) {
9364
10752
  }
9365
10753
  };
9366
10754
  }
10755
+ var ZONE_HYSTERESIS = 0.05;
10756
+ var DEFAULT_RECOGNIZER_OPTIONS = {
10757
+ doubleTapWindowMs: 275,
10758
+ accumulationWindowMs: 650,
10759
+ leftZone: 0.33,
10760
+ rightZone: 0.33,
10761
+ slopPx: 10
10762
+ };
10763
+ function zoneFor(fraction, options) {
10764
+ if (fraction <= options.leftZone) return "left";
10765
+ if (fraction >= 1 - options.rightZone) return "right";
10766
+ return "middle";
10767
+ }
10768
+ function createRecognizer(options = {}) {
10769
+ const config = { ...DEFAULT_RECOGNIZER_OPTIONS, ...options };
10770
+ let pending = null;
10771
+ let lastTapZone = null;
10772
+ let lastTapAt = 0;
10773
+ let accumulatingZone = null;
10774
+ let accumulatedCount = 0;
10775
+ let lastAccumulateAt = 0;
10776
+ let activePointers = 0;
10777
+ const resetSequence = () => {
10778
+ lastTapZone = null;
10779
+ lastTapAt = 0;
10780
+ accumulatingZone = null;
10781
+ accumulatedCount = 0;
10782
+ lastAccumulateAt = 0;
10783
+ };
10784
+ const resolveZone = (fraction) => {
10785
+ const raw = zoneFor(fraction, config);
10786
+ const active = accumulatingZone ?? lastTapZone;
10787
+ if (!active || raw === active) return raw;
10788
+ if (active === "left" && fraction <= config.leftZone + ZONE_HYSTERESIS) return "left";
10789
+ if (active === "right" && fraction >= 1 - config.rightZone - ZONE_HYSTERESIS) return "right";
10790
+ return raw;
10791
+ };
10792
+ const expire = (now) => {
10793
+ if (accumulatingZone && now - lastAccumulateAt > config.accumulationWindowMs) {
10794
+ resetSequence();
10795
+ } else if (!accumulatingZone && lastTapZone && now - lastTapAt > config.doubleTapWindowMs) {
10796
+ lastTapZone = null;
10797
+ lastTapAt = 0;
10798
+ }
10799
+ return [];
10800
+ };
10801
+ return {
10802
+ handle(record) {
10803
+ const events = [];
10804
+ switch (record.type) {
10805
+ case "down": {
10806
+ activePointers += 1;
10807
+ if (activePointers > 1) {
10808
+ if (pending) pending.invalid = true;
10809
+ if (accumulatingZone || lastTapZone) {
10810
+ resetSequence();
10811
+ events.push({ type: "cancel" });
10812
+ }
10813
+ return events;
10814
+ }
10815
+ expire(record.timeStamp);
10816
+ pending = {
10817
+ pointerId: record.pointerId,
10818
+ x: record.x,
10819
+ y: record.y,
10820
+ fraction: record.fraction,
10821
+ timeStamp: record.timeStamp,
10822
+ invalid: false
10823
+ };
10824
+ return events;
10825
+ }
10826
+ case "move": {
10827
+ if (!pending || pending.pointerId !== record.pointerId || pending.invalid) {
10828
+ return events;
10829
+ }
10830
+ const dx = record.x - pending.x;
10831
+ const dy = record.y - pending.y;
10832
+ if (Math.hypot(dx, dy) > config.slopPx) {
10833
+ pending.invalid = true;
10834
+ if (accumulatingZone || lastTapZone) {
10835
+ resetSequence();
10836
+ events.push({ type: "cancel" });
10837
+ }
10838
+ }
10839
+ return events;
10840
+ }
10841
+ case "up": {
10842
+ activePointers = Math.max(0, activePointers - 1);
10843
+ const candidate = pending;
10844
+ pending = null;
10845
+ if (!candidate || candidate.pointerId !== record.pointerId || candidate.invalid) {
10846
+ return events;
10847
+ }
10848
+ expire(record.timeStamp);
10849
+ const zone = resolveZone(record.fraction);
10850
+ if (accumulatingZone) {
10851
+ if (zone === accumulatingZone) {
10852
+ accumulatedCount += 1;
10853
+ lastAccumulateAt = record.timeStamp;
10854
+ events.push({ type: "accumulate", zone, count: accumulatedCount });
10855
+ } else {
10856
+ resetSequence();
10857
+ events.push({ type: "cancel" });
10858
+ }
10859
+ return events;
10860
+ }
10861
+ if (lastTapZone && zone === lastTapZone && record.timeStamp - lastTapAt <= config.doubleTapWindowMs) {
10862
+ accumulatingZone = zone;
10863
+ accumulatedCount = 1;
10864
+ lastAccumulateAt = record.timeStamp;
10865
+ lastTapZone = null;
10866
+ lastTapAt = 0;
10867
+ events.push({ type: "double-tap", zone, count: 1 });
10868
+ return events;
10869
+ }
10870
+ lastTapZone = zone;
10871
+ lastTapAt = record.timeStamp;
10872
+ events.push({ type: "tap", zone });
10873
+ return events;
10874
+ }
10875
+ case "cancel": {
10876
+ activePointers = Math.max(0, activePointers - 1);
10877
+ pending = null;
10878
+ if (accumulatingZone || lastTapZone) {
10879
+ resetSequence();
10880
+ events.push({ type: "cancel" });
10881
+ }
10882
+ return events;
10883
+ }
10884
+ default:
10885
+ return events;
10886
+ }
10887
+ },
10888
+ tick(now) {
10889
+ return expire(now);
10890
+ },
10891
+ reset() {
10892
+ pending = null;
10893
+ activePointers = 0;
10894
+ resetSequence();
10895
+ },
10896
+ isAccumulating() {
10897
+ return accumulatingZone !== null;
10898
+ }
10899
+ };
10900
+ }
10901
+ var STYLE_ID$1 = "sp-gestures-styles";
10902
+ var styles$1 = `
10903
+ .sp-gestures {
10904
+ position: absolute;
10905
+ top: 0;
10906
+ left: 0;
10907
+ right: 0;
10908
+ /* The bottom strip belongs to the progress bar and control bar. */
10909
+ bottom: 64px;
10910
+ z-index: 6;
10911
+ touch-action: manipulation;
10912
+ user-select: none;
10913
+ -webkit-user-select: none;
10914
+ -webkit-touch-callout: none;
10915
+ }
10916
+
10917
+ .sp-gestures__zone {
10918
+ position: absolute;
10919
+ top: 0;
10920
+ bottom: 0;
10921
+ display: flex;
10922
+ align-items: center;
10923
+ justify-content: center;
10924
+ pointer-events: none;
10925
+ opacity: 0;
10926
+ color: #fff;
10927
+ transition: opacity 0.25s ease;
10928
+ }
10929
+
10930
+ .sp-gestures__zone--left {
10931
+ left: 0;
10932
+ border-radius: 0 50% 50% 0;
10933
+ }
10934
+
10935
+ .sp-gestures__zone--right {
10936
+ right: 0;
10937
+ border-radius: 50% 0 0 50%;
10938
+ }
10939
+
10940
+ .sp-gestures__zone--active {
10941
+ opacity: 1;
10942
+ background: rgba(255, 255, 255, 0.12);
10943
+ }
10944
+
10945
+ .sp-gestures__label {
10946
+ font-size: 13px;
10947
+ font-weight: 600;
10948
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
10949
+ }
10950
+
10951
+ .sp-gestures__live {
10952
+ position: absolute;
10953
+ width: 1px;
10954
+ height: 1px;
10955
+ margin: -1px;
10956
+ padding: 0;
10957
+ overflow: hidden;
10958
+ clip: rect(0 0 0 0);
10959
+ white-space: nowrap;
10960
+ border: 0;
10961
+ }
10962
+
10963
+ @media (prefers-reduced-motion: reduce) {
10964
+ .sp-gestures__zone {
10965
+ transition: none;
10966
+ }
10967
+ }
10968
+ `;
10969
+ var GestureOverlay = class {
10970
+ constructor(container, options) {
10971
+ this.container = container;
10972
+ this.options = options;
10973
+ this.styleEl = null;
10974
+ this.hideTimer = null;
10975
+ this.pointerHandler = (event) => {
10976
+ if (event.pointerType !== "touch") return;
10977
+ const rect = this.el.getBoundingClientRect();
10978
+ const width = rect.width || 1;
10979
+ this.options.onPointer({
10980
+ type: event.type === "pointerdown" ? "down" : event.type === "pointermove" ? "move" : event.type === "pointerup" ? "up" : "cancel",
10981
+ x: event.clientX,
10982
+ y: event.clientY,
10983
+ fraction: Math.max(0, Math.min(1, (event.clientX - rect.left) / width)),
10984
+ pointerId: event.pointerId,
10985
+ timeStamp: event.timeStamp
10986
+ });
10987
+ };
10988
+ this.injectStyles();
10989
+ this.el = document.createElement("div");
10990
+ this.el.className = "sp-gestures";
10991
+ const left = this.createZone("left");
10992
+ const right = this.createZone("right");
10993
+ this.zones = { left: left.zone, right: right.zone };
10994
+ this.labels = { left: left.label, right: right.label };
10995
+ this.live = document.createElement("div");
10996
+ this.live.className = "sp-gestures__live";
10997
+ this.live.setAttribute("aria-live", "polite");
10998
+ this.live.setAttribute("role", "status");
10999
+ this.el.appendChild(left.zone);
11000
+ this.el.appendChild(right.zone);
11001
+ this.el.appendChild(this.live);
11002
+ this.el.addEventListener("pointerdown", this.pointerHandler);
11003
+ this.el.addEventListener("pointermove", this.pointerHandler);
11004
+ this.el.addEventListener("pointerup", this.pointerHandler);
11005
+ this.el.addEventListener("pointercancel", this.pointerHandler);
11006
+ container.appendChild(this.el);
11007
+ }
11008
+ /** Size the zones to match the recognizer's split. */
11009
+ setZoneWidths(left, right) {
11010
+ this.zones.left.style.width = `${left * 100}%`;
11011
+ this.zones.right.style.width = `${right * 100}%`;
11012
+ }
11013
+ /**
11014
+ * Show the cumulative seek for a zone.
11015
+ *
11016
+ * @param zone - Which side was tapped
11017
+ * @param seconds - Total seconds this sequence has moved
11018
+ */
11019
+ showSeek(zone, seconds) {
11020
+ if (zone === "middle") return;
11021
+ const direction = zone === "right" ? "forward" : "back";
11022
+ this.live.textContent = `${seconds} seconds ${direction}`;
11023
+ if (!this.options.feedback) return;
11024
+ const target = this.zones[zone];
11025
+ this.labels[zone].textContent = `${seconds} seconds`;
11026
+ target.classList.add("sp-gestures__zone--active");
11027
+ if (this.hideTimer) clearTimeout(this.hideTimer);
11028
+ this.hideTimer = setTimeout(() => {
11029
+ this.zones.left.classList.remove("sp-gestures__zone--active");
11030
+ this.zones.right.classList.remove("sp-gestures__zone--active");
11031
+ this.hideTimer = null;
11032
+ }, 600);
11033
+ }
11034
+ /** Announce that a forward seek was refused because the viewer is at the live edge. */
11035
+ announceLiveEdge() {
11036
+ this.live.textContent = "Already at the live edge";
11037
+ }
11038
+ destroy() {
11039
+ if (this.hideTimer) {
11040
+ clearTimeout(this.hideTimer);
11041
+ this.hideTimer = null;
11042
+ }
11043
+ this.el.removeEventListener("pointerdown", this.pointerHandler);
11044
+ this.el.removeEventListener("pointermove", this.pointerHandler);
11045
+ this.el.removeEventListener("pointerup", this.pointerHandler);
11046
+ this.el.removeEventListener("pointercancel", this.pointerHandler);
11047
+ this.el.remove();
11048
+ this.styleEl?.remove();
11049
+ this.styleEl = null;
11050
+ }
11051
+ /** Exposed for tests and for hosts that want to inspect the surface. */
11052
+ getElement() {
11053
+ return this.el;
11054
+ }
11055
+ createZone(side) {
11056
+ const zone = document.createElement("div");
11057
+ zone.className = `sp-gestures__zone sp-gestures__zone--${side}`;
11058
+ zone.setAttribute("aria-hidden", "true");
11059
+ const label = document.createElement("span");
11060
+ label.className = "sp-gestures__label";
11061
+ zone.appendChild(label);
11062
+ return { zone, label };
11063
+ }
11064
+ injectStyles() {
11065
+ if (document.getElementById(STYLE_ID$1)) return;
11066
+ this.styleEl = document.createElement("style");
11067
+ this.styleEl.id = STYLE_ID$1;
11068
+ this.styleEl.textContent = styles$1;
11069
+ document.head.appendChild(this.styleEl);
11070
+ }
11071
+ };
11072
+ var PKG_VERSION$2 = "1.8.1";
11073
+ function hasCoarsePointer() {
11074
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
11075
+ return false;
11076
+ }
11077
+ try {
11078
+ return window.matchMedia("(any-pointer: coarse)").matches;
11079
+ } catch {
11080
+ return false;
11081
+ }
11082
+ }
11083
+ function createGesturesPlugin(config = {}) {
11084
+ let api = null;
11085
+ let overlay = null;
11086
+ let recognizer = null;
11087
+ let active = false;
11088
+ let runSeconds = 0;
11089
+ let pendingHide = null;
11090
+ const seekSeconds = config.seekSeconds ?? 10;
11091
+ const doubleTapWindowMs = config.doubleTapWindowMs ?? DEFAULT_RECOGNIZER_OPTIONS.doubleTapWindowMs;
11092
+ const leftZone = config.zones?.left ?? DEFAULT_RECOGNIZER_OPTIONS.leftZone;
11093
+ const rightZone = config.zones?.right ?? DEFAULT_RECOGNIZER_OPTIONS.rightZone;
11094
+ const feedback = config.feedback !== false;
11095
+ const haptics = config.haptics !== false;
11096
+ const tapToToggleControls = config.tapToToggleControls !== false;
11097
+ const canSeek = () => {
11098
+ if (!api) return false;
11099
+ if (api.getState("chromecastActive") || api.getState("airplayActive")) return false;
11100
+ const live = api.getState("live");
11101
+ const seekableRange = api.getState("seekableRange");
11102
+ if (live && !seekableRange) return false;
11103
+ const duration = api.getState("duration");
11104
+ if (!live && (!duration || !Number.isFinite(duration))) return false;
11105
+ return true;
11106
+ };
11107
+ const applySeek = (zone) => {
11108
+ if (!api || zone === "middle") return false;
11109
+ const video = api.container.querySelector("video");
11110
+ if (!video) return false;
11111
+ const delta = zone === "right" ? seekSeconds : -seekSeconds;
11112
+ const live = api.getState("live");
11113
+ const seekableRange = api.getState("seekableRange");
11114
+ const current = video.currentTime;
11115
+ let target;
11116
+ if (live && seekableRange) {
11117
+ target = Math.max(seekableRange.start, Math.min(seekableRange.end, current + delta));
11118
+ if (zone === "right" && target <= current) {
11119
+ overlay?.announceLiveEdge();
11120
+ return false;
11121
+ }
11122
+ } else {
11123
+ const duration = video.duration;
11124
+ const max = Number.isFinite(duration) && duration > 0 ? duration - 0.25 : current;
11125
+ target = Math.max(0, Math.min(max, current + delta));
11126
+ }
11127
+ video.currentTime = target;
11128
+ api.emit("playback:seeking", { time: target });
11129
+ if (haptics && typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
11130
+ navigator.vibrate(10);
11131
+ }
11132
+ return true;
11133
+ };
11134
+ const ui = () => api?.getPlugin("ui-controls") ?? null;
11135
+ const clearPendingHide = () => {
11136
+ if (pendingHide) {
11137
+ clearTimeout(pendingHide);
11138
+ pendingHide = null;
11139
+ }
11140
+ };
11141
+ const handleTap = () => {
11142
+ if (!tapToToggleControls || !api) return;
11143
+ const controls = ui();
11144
+ if (!controls) return;
11145
+ const visible = Boolean(api.getState("controlsVisible"));
11146
+ const paused = Boolean(api.getState("paused"));
11147
+ if (!visible) {
11148
+ controls.show();
11149
+ return;
11150
+ }
11151
+ if (paused) return;
11152
+ clearPendingHide();
11153
+ pendingHide = setTimeout(() => {
11154
+ pendingHide = null;
11155
+ controls.hide();
11156
+ }, doubleTapWindowMs);
11157
+ };
11158
+ const handleSeekStep = (zone) => {
11159
+ clearPendingHide();
11160
+ if (!canSeek()) return;
11161
+ const moved = applySeek(zone);
11162
+ if (!moved) return;
11163
+ runSeconds += seekSeconds;
11164
+ overlay?.showSeek(zone, runSeconds);
11165
+ api?.emit("gesture:seek", {
11166
+ direction: zone === "right" ? "forward" : "backward",
11167
+ seconds: seekSeconds,
11168
+ cumulative: runSeconds
11169
+ });
11170
+ };
11171
+ const onPointer = (record) => {
11172
+ if (!recognizer) return;
11173
+ for (const event of recognizer.handle(record)) {
11174
+ switch (event.type) {
11175
+ case "tap":
11176
+ api?.emit("gesture:tap", { zone: event.zone });
11177
+ handleTap();
11178
+ break;
11179
+ case "double-tap":
11180
+ runSeconds = 0;
11181
+ handleSeekStep(event.zone);
11182
+ break;
11183
+ case "accumulate":
11184
+ handleSeekStep(event.zone);
11185
+ break;
11186
+ case "cancel":
11187
+ runSeconds = 0;
11188
+ clearPendingHide();
11189
+ break;
11190
+ }
11191
+ }
11192
+ };
11193
+ return {
11194
+ id: "gestures",
11195
+ name: "Gestures",
11196
+ version: PKG_VERSION$2,
11197
+ type: "feature",
11198
+ init(pluginApi) {
11199
+ api = pluginApi;
11200
+ const enabled = config.enabled ?? "auto";
11201
+ active = enabled === "auto" ? hasCoarsePointer() : Boolean(enabled);
11202
+ if (!active) {
11203
+ api.logger.debug("[gestures] no coarse pointer, gesture surface not installed");
11204
+ return;
11205
+ }
11206
+ if (api.getState("mediaType") === "audio") {
11207
+ active = false;
11208
+ return;
11209
+ }
11210
+ recognizer = createRecognizer({
11211
+ doubleTapWindowMs,
11212
+ accumulationWindowMs: config.accumulationWindowMs,
11213
+ leftZone,
11214
+ rightZone,
11215
+ slopPx: config.slopPx
11216
+ });
11217
+ overlay = new GestureOverlay(api.container, { onPointer, feedback });
11218
+ overlay.setZoneWidths(leftZone, rightZone);
11219
+ api.onDestroy(() => {
11220
+ clearPendingHide();
11221
+ overlay?.destroy();
11222
+ overlay = null;
11223
+ recognizer?.reset();
11224
+ recognizer = null;
11225
+ });
11226
+ },
11227
+ destroy() {
11228
+ clearPendingHide();
11229
+ overlay?.destroy();
11230
+ overlay = null;
11231
+ recognizer?.reset();
11232
+ recognizer = null;
11233
+ active = false;
11234
+ runSeconds = 0;
11235
+ api = null;
11236
+ },
11237
+ ownsTapInteraction() {
11238
+ return active && tapToToggleControls;
11239
+ }
11240
+ };
11241
+ }
11242
+ function resolve(value, fallback) {
11243
+ if (typeof value === "function") {
11244
+ return value();
11245
+ }
11246
+ if (typeof value === "string" && value.length > 0) {
11247
+ return value;
11248
+ }
11249
+ return fallback();
11250
+ }
11251
+ function resolveBaseUrl(config) {
11252
+ return resolve(
11253
+ config.url,
11254
+ () => typeof window === "undefined" ? "" : window.location.href
11255
+ );
11256
+ }
11257
+ function resolveTitle(config) {
11258
+ return resolve(config.title, () => typeof document === "undefined" ? "" : document.title);
11259
+ }
11260
+ function applyTimestamp(baseUrl, currentTime, isLive, config) {
11261
+ const enabled = config.withTimestamp !== false;
11262
+ if (!enabled || isLive || !Number.isFinite(currentTime) || currentTime <= 0) {
11263
+ return baseUrl;
11264
+ }
11265
+ const param = config.timestampParam ?? "t";
11266
+ const rounded = config.roundTimestamp === false ? currentTime : Math.floor(currentTime);
11267
+ if (rounded <= 0) {
11268
+ return baseUrl;
11269
+ }
11270
+ try {
11271
+ const base = typeof window === "undefined" ? void 0 : window.location.href;
11272
+ const url = new URL(baseUrl, base);
11273
+ url.searchParams.set(param, String(rounded));
11274
+ return url.toString();
11275
+ } catch {
11276
+ return baseUrl;
11277
+ }
11278
+ }
11279
+ var icons = {
11280
+ /**
11281
+ * Three connected nodes. The one share glyph that reads the same everywhere:
11282
+ * it is the Material/Android share icon, and it carries no platform baggage.
11283
+ * The tray-and-arrow below is native on iOS but reads as "upload" or "export"
11284
+ * to everyone else, which is why it is no longer the default.
11285
+ */
11286
+ share: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="10.6" x2="15.4" y2="6.4"/><line x1="8.6" y1="13.4" x2="15.4" y2="17.6"/></svg>',
11287
+ /** iOS-style tray and arrow. Kept for hosts targeting an iOS-heavy audience. */
11288
+ upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>',
11289
+ native: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>',
11290
+ copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
11291
+ embed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
11292
+ x: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.9 2H22l-7.1 8.1L23 22h-6.6l-5.2-6.8L5.3 22H2.2l7.6-8.7L1.7 2h6.8l4.7 6.2L18.9 2Zm-1.1 18h1.7L7.3 3.8H5.5L17.8 20Z"/></svg>',
11293
+ facebook: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 12a10 10 0 1 0-11.6 9.9v-7H7.9V12h2.5V9.8c0-2.5 1.5-3.9 3.8-3.9 1.1 0 2.2.2 2.2.2v2.5h-1.3c-1.2 0-1.6.8-1.6 1.6V12h2.8l-.4 2.9h-2.3v7A10 10 0 0 0 22 12Z"/></svg>',
11294
+ linkedin: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.98 3.5A2.5 2.5 0 1 1 0 3.5a2.5 2.5 0 0 1 4.98 0ZM.2 8.3h4.6V24H.2V8.3Zm7.6 0h4.4v2.1h.1a4.8 4.8 0 0 1 4.3-2.4c4.6 0 5.4 3 5.4 6.9V24h-4.6v-7.6c0-1.8 0-4.1-2.5-4.1s-2.9 2-2.9 4V24H7.8V8.3Z"/></svg>',
11295
+ whatsapp: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 0 0-8.5 15.2L2 22l4.9-1.4A10 10 0 1 0 12 2Zm5.1 14.1c-.2.6-1.2 1.2-1.7 1.2-.5.1-1 .1-1.6-.1-.4-.1-.9-.3-1.5-.6a11 11 0 0 1-4.2-3.8c-.3-.5-.7-1.2-.7-2 0-.8.4-1.2.6-1.4.2-.2.4-.3.6-.3h.4c.1 0 .3 0 .5.4l.7 1.6c0 .1.1.3 0 .4l-.3.4-.2.3c-.1.1-.2.2 0 .5.2.3.7 1.1 1.4 1.8.9.8 1.6 1 1.9 1.2.2.1.4.1.5-.1l.6-.7c.2-.2.3-.2.5-.1l1.6.8c.2.1.4.2.4.3.1.1.1.5-.1 1Z"/></svg>',
11296
+ telegram: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21.9 4.3 18.6 20c-.2 1.1-.9 1.4-1.8.9l-4.9-3.6-2.4 2.3c-.3.3-.5.5-1 .5l.4-5 9.1-8.2c.4-.4-.1-.6-.6-.2L6.2 13.1l-4.8-1.5c-1-.3-1-1 .2-1.5l18.9-7.3c.9-.3 1.6.2 1.4 1.5Z"/></svg>',
11297
+ reddit: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 12a2.1 2.1 0 0 0-3.6-1.5 10.4 10.4 0 0 0-5.4-1.7l.9-4.2 3 .6a1.8 1.8 0 1 0 .2-1.2l-3.6-.8a.6.6 0 0 0-.7.5L11.7 8.8a10.4 10.4 0 0 0-5.5 1.7A2.1 2.1 0 1 0 3.6 14a4 4 0 0 0 0 .6c0 3.1 3.7 5.6 8.4 5.6s8.4-2.5 8.4-5.6a4 4 0 0 0 0-.6A2.1 2.1 0 0 0 22 12ZM7.9 13.5a1.5 1.5 0 1 1 1.5 1.5 1.5 1.5 0 0 1-1.5-1.5Zm8.3 4.1a5.6 5.6 0 0 1-4.2 1.3 5.6 5.6 0 0 1-4.2-1.3.5.5 0 0 1 .7-.7 4.7 4.7 0 0 0 3.5 1 4.7 4.7 0 0 0 3.5-1 .5.5 0 1 1 .7.7Zm-.6-2.6a1.5 1.5 0 1 1 1.5-1.5 1.5 1.5 0 0 1-1.5 1.5Z"/></svg>',
11298
+ email: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/></svg>'
11299
+ };
11300
+ var encode = encodeURIComponent;
11301
+ var BUILTIN_TARGETS = {
11302
+ native: { id: "native", label: "Share", icon: icons.native },
11303
+ copy: { id: "copy", label: "Copy link", icon: icons.copy },
11304
+ embed: { id: "embed", label: "Embed", icon: icons.embed },
11305
+ x: {
11306
+ id: "x",
11307
+ label: "X",
11308
+ icon: icons.x,
11309
+ href: (c) => `https://x.com/intent/tweet?url=${encode(c.url)}&text=${encode(c.title)}`
11310
+ },
11311
+ facebook: {
11312
+ id: "facebook",
11313
+ label: "Facebook",
11314
+ icon: icons.facebook,
11315
+ href: (c) => `https://www.facebook.com/sharer/sharer.php?u=${encode(c.url)}`
11316
+ },
11317
+ linkedin: {
11318
+ id: "linkedin",
11319
+ label: "LinkedIn",
11320
+ icon: icons.linkedin,
11321
+ href: (c) => `https://www.linkedin.com/sharing/share-offsite/?url=${encode(c.url)}`
11322
+ },
11323
+ whatsapp: {
11324
+ id: "whatsapp",
11325
+ label: "WhatsApp",
11326
+ icon: icons.whatsapp,
11327
+ href: (c) => `https://api.whatsapp.com/send?text=${encode(`${c.title} ${c.url}`)}`
11328
+ },
11329
+ telegram: {
11330
+ id: "telegram",
11331
+ label: "Telegram",
11332
+ icon: icons.telegram,
11333
+ href: (c) => `https://t.me/share/url?url=${encode(c.url)}&text=${encode(c.title)}`
11334
+ },
11335
+ reddit: {
11336
+ id: "reddit",
11337
+ label: "Reddit",
11338
+ icon: icons.reddit,
11339
+ href: (c) => `https://www.reddit.com/submit?url=${encode(c.url)}&title=${encode(c.title)}`
11340
+ },
11341
+ email: {
11342
+ id: "email",
11343
+ label: "Email",
11344
+ icon: icons.email,
11345
+ href: (c) => `mailto:?subject=${encode(c.title)}&body=${encode(c.url)}`
11346
+ }
11347
+ };
11348
+ var DEFAULT_TARGETS = ["native", "copy", "embed"];
11349
+ function resolveTargets(configured, onUnknown) {
11350
+ const resolved = [];
11351
+ for (const entry of configured) {
11352
+ if (typeof entry !== "string") {
11353
+ resolved.push({ ...entry, icon: entry.icon ?? icons[entry.id] });
11354
+ continue;
11355
+ }
11356
+ const builtin = BUILTIN_TARGETS[entry];
11357
+ if (builtin) {
11358
+ resolved.push(builtin);
11359
+ } else {
11360
+ onUnknown?.(entry);
11361
+ }
11362
+ }
11363
+ return resolved;
11364
+ }
11365
+ function canUseNativeShare() {
11366
+ return typeof navigator !== "undefined" && typeof navigator.share === "function";
11367
+ }
11368
+ async function nativeShare(context) {
11369
+ try {
11370
+ await navigator.share({ title: context.title, url: context.url });
11371
+ return true;
11372
+ } catch (error) {
11373
+ if (error instanceof Error && error.name === "AbortError") {
11374
+ return false;
11375
+ }
11376
+ throw error;
11377
+ }
11378
+ }
11379
+ async function copyText(text) {
11380
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
11381
+ try {
11382
+ await navigator.clipboard.writeText(text);
11383
+ return true;
11384
+ } catch {
11385
+ }
11386
+ }
11387
+ if (typeof document === "undefined") {
11388
+ return false;
11389
+ }
11390
+ const textarea = document.createElement("textarea");
11391
+ textarea.value = text;
11392
+ textarea.setAttribute("readonly", "");
11393
+ textarea.style.position = "fixed";
11394
+ textarea.style.top = "-9999px";
11395
+ textarea.style.opacity = "0";
11396
+ document.body.appendChild(textarea);
11397
+ try {
11398
+ textarea.select();
11399
+ textarea.setSelectionRange(0, textarea.value.length);
11400
+ return document.execCommand("copy");
11401
+ } catch {
11402
+ return false;
11403
+ } finally {
11404
+ textarea.remove();
11405
+ }
11406
+ }
11407
+ function buildEmbedSnippet(context, config) {
11408
+ if (config.embedSnippet) {
11409
+ return config.embedSnippet(context);
11410
+ }
11411
+ if (!config.embedBaseUrl) {
11412
+ return null;
11413
+ }
11414
+ try {
11415
+ const base = typeof window === "undefined" ? void 0 : window.location.href;
11416
+ const embedUrl = new URL(config.embedBaseUrl, base);
11417
+ if (context.currentTime > 0 && !context.isLive) {
11418
+ embedUrl.searchParams.set("startTime", String(Math.floor(context.currentTime)));
11419
+ }
11420
+ embedUrl.searchParams.set("shareUrl", context.url);
11421
+ return `<iframe src="${embedUrl.toString()}" width="640" height="360" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen loading="lazy"></iframe>`;
11422
+ } catch {
11423
+ return null;
11424
+ }
11425
+ }
11426
+ var TOAST_MS = 1800;
11427
+ var ShareSheet = class {
11428
+ constructor(api, callbacks) {
11429
+ this.api = api;
11430
+ this.callbacks = callbacks;
11431
+ this.backdrop = null;
11432
+ this.sheet = null;
11433
+ this.toast = null;
11434
+ this.toastTimer = null;
11435
+ this.previouslyFocused = null;
11436
+ this.open = false;
11437
+ this.keydownHandler = (event) => {
11438
+ if (event.key === "Escape") {
11439
+ event.stopPropagation();
11440
+ this.close();
11441
+ return;
11442
+ }
11443
+ if (event.key === "Tab") {
11444
+ this.trapFocus(event);
11445
+ }
11446
+ };
11447
+ }
11448
+ /** Whether the sheet is currently showing. */
11449
+ isOpen() {
11450
+ return this.open;
11451
+ }
11452
+ /**
11453
+ * Show the sheet for a set of targets.
11454
+ */
11455
+ show(targets) {
11456
+ if (this.open) {
11457
+ return;
11458
+ }
11459
+ this.previouslyFocused = document.activeElement;
11460
+ this.open = true;
11461
+ this.backdrop = document.createElement("div");
11462
+ this.backdrop.className = "sp-share-backdrop";
11463
+ this.backdrop.addEventListener("click", () => this.close());
11464
+ this.sheet = document.createElement("div");
11465
+ this.sheet.className = "sp-share-sheet";
11466
+ this.sheet.setAttribute("role", "dialog");
11467
+ this.sheet.setAttribute("aria-modal", "true");
11468
+ this.sheet.setAttribute("aria-label", "Share");
11469
+ const grip = document.createElement("div");
11470
+ grip.className = "sp-share-grip";
11471
+ this.sheet.appendChild(grip);
11472
+ const heading = document.createElement("p");
11473
+ heading.className = "sp-share-title";
11474
+ heading.textContent = "Share";
11475
+ this.sheet.appendChild(heading);
11476
+ const list = document.createElement("ul");
11477
+ list.className = "sp-share-targets";
11478
+ for (const target of targets) {
11479
+ list.appendChild(this.renderTarget(target));
11480
+ }
11481
+ this.sheet.appendChild(list);
11482
+ this.api.container.appendChild(this.backdrop);
11483
+ this.api.container.appendChild(this.sheet);
11484
+ requestAnimationFrame(() => {
11485
+ this.backdrop?.classList.add("sp-share-backdrop--open");
11486
+ this.sheet?.classList.add("sp-share-sheet--open");
11487
+ });
11488
+ document.addEventListener("keydown", this.keydownHandler, true);
11489
+ const first = this.sheet.querySelector(".sp-share-target");
11490
+ first?.focus();
11491
+ }
11492
+ /** Hide the sheet and restore focus. */
11493
+ close() {
11494
+ if (!this.open) {
11495
+ return;
11496
+ }
11497
+ this.open = false;
11498
+ document.removeEventListener("keydown", this.keydownHandler, true);
11499
+ this.backdrop?.classList.remove("sp-share-backdrop--open");
11500
+ this.sheet?.classList.remove("sp-share-sheet--open");
11501
+ const backdrop = this.backdrop;
11502
+ const sheet = this.sheet;
11503
+ this.backdrop = null;
11504
+ this.sheet = null;
11505
+ setTimeout(() => {
11506
+ backdrop?.remove();
11507
+ sheet?.remove();
11508
+ }, 220);
11509
+ this.previouslyFocused?.focus();
11510
+ this.previouslyFocused = null;
11511
+ this.callbacks.onClose();
11512
+ }
11513
+ /**
11514
+ * Show a transient confirmation, e.g. after copying.
11515
+ */
11516
+ showToast(message) {
11517
+ if (this.toastTimer) {
11518
+ clearTimeout(this.toastTimer);
11519
+ this.toastTimer = null;
11520
+ }
11521
+ if (!this.toast) {
11522
+ this.toast = document.createElement("div");
11523
+ this.toast.className = "sp-share-toast";
11524
+ this.toast.setAttribute("role", "status");
11525
+ this.toast.setAttribute("aria-live", "polite");
11526
+ this.api.container.appendChild(this.toast);
11527
+ }
11528
+ this.toast.textContent = message;
11529
+ requestAnimationFrame(() => this.toast?.classList.add("sp-share-toast--visible"));
11530
+ this.toastTimer = setTimeout(() => {
11531
+ this.toast?.classList.remove("sp-share-toast--visible");
11532
+ this.toastTimer = null;
11533
+ }, TOAST_MS);
11534
+ }
11535
+ /**
11536
+ * Last-resort clipboard fallback: show the text for manual copying.
11537
+ */
11538
+ showManualCopy(text) {
11539
+ if (!this.sheet) {
11540
+ return;
11541
+ }
11542
+ const row = document.createElement("div");
11543
+ row.className = "sp-share-fallback";
11544
+ const input = document.createElement("input");
11545
+ input.type = "text";
11546
+ input.readOnly = true;
11547
+ input.value = text;
11548
+ input.setAttribute("aria-label", "Link to copy");
11549
+ row.appendChild(input);
11550
+ this.sheet.appendChild(row);
11551
+ input.focus();
11552
+ input.select();
11553
+ }
11554
+ /** Remove everything and detach listeners. */
11555
+ destroy() {
11556
+ document.removeEventListener("keydown", this.keydownHandler, true);
11557
+ if (this.toastTimer) {
11558
+ clearTimeout(this.toastTimer);
11559
+ this.toastTimer = null;
11560
+ }
11561
+ this.backdrop?.remove();
11562
+ this.sheet?.remove();
11563
+ this.toast?.remove();
11564
+ this.backdrop = null;
11565
+ this.sheet = null;
11566
+ this.toast = null;
11567
+ this.open = false;
11568
+ this.previouslyFocused = null;
11569
+ }
11570
+ /** Build one target button. */
11571
+ renderTarget(target) {
11572
+ const item = document.createElement("li");
11573
+ const button = document.createElement("button");
11574
+ button.type = "button";
11575
+ button.className = `sp-share-target sp-share-target--${target.id}`;
11576
+ button.setAttribute("aria-label", target.label);
11577
+ const icon = document.createElement("span");
11578
+ icon.className = "sp-share-icon";
11579
+ icon.setAttribute("aria-hidden", "true");
11580
+ if (target.icon) {
11581
+ icon.innerHTML = target.icon;
11582
+ }
11583
+ const label = document.createElement("span");
11584
+ label.className = "sp-share-label";
11585
+ label.textContent = target.label;
11586
+ button.appendChild(icon);
11587
+ button.appendChild(label);
11588
+ button.addEventListener("click", () => this.callbacks.onSelect(target));
11589
+ item.appendChild(button);
11590
+ return item;
11591
+ }
11592
+ /** Keep Tab inside the dialog while it is open. */
11593
+ trapFocus(event) {
11594
+ if (!this.sheet) {
11595
+ return;
11596
+ }
11597
+ const focusable = this.sheet.querySelectorAll('button, input, [tabindex]:not([tabindex="-1"])');
11598
+ if (focusable.length === 0) {
11599
+ return;
11600
+ }
11601
+ const first = focusable[0];
11602
+ const last = focusable[focusable.length - 1];
11603
+ const active = document.activeElement;
11604
+ if (event.shiftKey && active === first) {
11605
+ event.preventDefault();
11606
+ last?.focus();
11607
+ } else if (!event.shiftKey && active === last) {
11608
+ event.preventDefault();
11609
+ first?.focus();
11610
+ }
11611
+ }
11612
+ };
11613
+ var ALLOWED_ELEMENTS = /* @__PURE__ */ new Set([
11614
+ "svg",
11615
+ "g",
11616
+ "path",
11617
+ "circle",
11618
+ "ellipse",
11619
+ "line",
11620
+ "polyline",
11621
+ "polygon",
11622
+ "rect",
11623
+ "defs",
11624
+ "lineargradient",
11625
+ "radialgradient",
11626
+ "stop",
11627
+ "clippath",
11628
+ "mask",
11629
+ "use",
11630
+ "symbol",
11631
+ "title",
11632
+ "desc"
11633
+ ]);
11634
+ function sanitizeIcon(markup) {
11635
+ if (typeof markup !== "string" || markup.trim() === "") {
11636
+ return null;
11637
+ }
11638
+ if (typeof DOMParser === "undefined") {
11639
+ return null;
11640
+ }
11641
+ const doc = new DOMParser().parseFromString(markup, "text/html");
11642
+ const svg = doc.body.querySelector("svg");
11643
+ if (!svg) {
11644
+ return null;
11645
+ }
11646
+ for (const element of Array.from(svg.querySelectorAll("*"))) {
11647
+ if (!ALLOWED_ELEMENTS.has(element.nodeName.toLowerCase())) {
11648
+ element.remove();
11649
+ continue;
11650
+ }
11651
+ stripDangerousAttributes(element);
11652
+ }
11653
+ stripDangerousAttributes(svg);
11654
+ return svg.outerHTML;
11655
+ }
11656
+ function stripDangerousAttributes(element) {
11657
+ for (const attribute of Array.from(element.attributes)) {
11658
+ const name = attribute.name.toLowerCase();
11659
+ if (name.startsWith("on")) {
11660
+ element.removeAttribute(attribute.name);
11661
+ continue;
11662
+ }
11663
+ if (name === "href" || name === "xlink:href" || name === "src") {
11664
+ const value = attribute.value.replace(/\s+/g, "").toLowerCase();
11665
+ if (value.startsWith("javascript:") || value.startsWith("data:text/html")) {
11666
+ element.removeAttribute(attribute.name);
11667
+ }
11668
+ }
11669
+ }
11670
+ }
11671
+ var ShareButton = class {
11672
+ constructor(_api, onActivate, options = {}) {
11673
+ this.onActivate = onActivate;
11674
+ this.clickHandler = () => {
11675
+ this.onActivate();
11676
+ };
11677
+ const label = options.label ?? "Share";
11678
+ this.el = document.createElement("button");
11679
+ this.el.type = "button";
11680
+ this.el.className = "sp-share sp-control";
11681
+ this.el.setAttribute("aria-label", label);
11682
+ this.el.setAttribute("title", label);
11683
+ this.el.setAttribute("aria-haspopup", "dialog");
11684
+ const icon = options.icon ? sanitizeIcon(options.icon) : null;
11685
+ this.el.innerHTML = icon ?? icons.share ?? "";
11686
+ this.el.addEventListener("click", this.clickHandler);
11687
+ }
11688
+ render() {
11689
+ return this.el;
11690
+ }
11691
+ update() {
11692
+ }
11693
+ destroy() {
11694
+ this.el.removeEventListener("click", this.clickHandler);
11695
+ this.el.remove();
11696
+ }
11697
+ };
11698
+ var styles = `
11699
+ .sp-share-backdrop {
11700
+ position: absolute;
11701
+ inset: 0;
11702
+ background: rgba(0, 0, 0, 0.55);
11703
+ opacity: 0;
11704
+ transition: opacity 180ms ease;
11705
+ z-index: 30;
11706
+ }
11707
+ .sp-share-backdrop--open { opacity: 1; }
11708
+
11709
+ .sp-share-sheet {
11710
+ position: absolute;
11711
+ left: 0;
11712
+ right: 0;
11713
+ bottom: 0;
11714
+ z-index: 31;
11715
+ background: #1c1c1e;
11716
+ color: #fff;
11717
+ border-radius: 14px 14px 0 0;
11718
+ padding: 8px 12px calc(12px + env(safe-area-inset-bottom, 0px));
11719
+ box-shadow: 0 -6px 28px rgba(0, 0, 0, 0.45);
11720
+ transform: translateY(100%);
11721
+ transition: transform 220ms cubic-bezier(0.32, 0.72, 0, 1);
11722
+ max-height: 80%;
11723
+ overflow-y: auto;
11724
+ -webkit-overflow-scrolling: touch;
11725
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11726
+ }
11727
+ .sp-share-sheet--open { transform: translateY(0); }
11728
+
11729
+ /* Grab handle - the affordance that says "this drags/dismisses" on a phone. */
11730
+ .sp-share-grip {
11731
+ width: 36px;
11732
+ height: 4px;
11733
+ border-radius: 2px;
11734
+ background: rgba(255, 255, 255, 0.3);
11735
+ margin: 4px auto 10px;
11736
+ }
11737
+
11738
+ .sp-share-title {
11739
+ font-size: 13px;
11740
+ font-weight: 600;
11741
+ letter-spacing: 0.02em;
11742
+ text-transform: uppercase;
11743
+ color: rgba(255, 255, 255, 0.55);
11744
+ padding: 0 8px 8px;
11745
+ margin: 0;
11746
+ }
11747
+
11748
+ .sp-share-targets {
11749
+ display: grid;
11750
+ grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
11751
+ gap: 4px;
11752
+ list-style: none;
11753
+ margin: 0;
11754
+ padding: 0;
11755
+ }
11756
+
11757
+ .sp-share-target {
11758
+ display: flex;
11759
+ flex-direction: column;
11760
+ align-items: center;
11761
+ justify-content: center;
11762
+ gap: 8px;
11763
+ /* 44px is the WCAG 2.5.5 floor; 72px is a comfortable thumb target. */
11764
+ min-height: 72px;
11765
+ min-width: 44px;
11766
+ padding: 10px 6px;
11767
+ border: 0;
11768
+ border-radius: 10px;
11769
+ background: transparent;
11770
+ color: inherit;
11771
+ font: inherit;
11772
+ font-size: 12px;
11773
+ cursor: pointer;
11774
+ -webkit-tap-highlight-color: transparent;
11775
+ transition: background-color 120ms ease, transform 120ms ease;
11776
+ }
11777
+ .sp-share-target:active { transform: scale(0.94); background: rgba(255, 255, 255, 0.14); }
11778
+ .sp-share-target:focus-visible { outline: 2px solid #fff; outline-offset: -2px; }
11779
+ @media (hover: hover) {
11780
+ .sp-share-target:hover { background: rgba(255, 255, 255, 0.1); }
11781
+ }
11782
+
11783
+ .sp-share-target svg {
11784
+ width: 24px;
11785
+ height: 24px;
11786
+ display: block;
11787
+ }
11788
+
11789
+ .sp-share-icon {
11790
+ display: flex;
11791
+ align-items: center;
11792
+ justify-content: center;
11793
+ width: 44px;
11794
+ height: 44px;
11795
+ border-radius: 50%;
11796
+ background: rgba(255, 255, 255, 0.12);
11797
+ }
11798
+
11799
+ .sp-share-label {
11800
+ line-height: 1.2;
11801
+ text-align: center;
11802
+ max-width: 100%;
11803
+ overflow: hidden;
11804
+ text-overflow: ellipsis;
11805
+ white-space: nowrap;
11806
+ }
11807
+
11808
+ /* Copy confirmation, announced politely to assistive tech. */
11809
+ .sp-share-toast {
11810
+ position: absolute;
11811
+ left: 50%;
11812
+ bottom: 16px;
11813
+ transform: translateX(-50%) translateY(8px);
11814
+ z-index: 32;
11815
+ background: rgba(28, 28, 30, 0.95);
11816
+ color: #fff;
11817
+ font-size: 13px;
11818
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11819
+ padding: 10px 16px;
11820
+ border-radius: 20px;
11821
+ opacity: 0;
11822
+ pointer-events: none;
11823
+ transition: opacity 160ms ease, transform 160ms ease;
11824
+ }
11825
+ .sp-share-toast--visible { opacity: 1; transform: translateX(-50%) translateY(0); }
11826
+
11827
+ /* Fallback when the clipboard is unavailable: show the URL to copy by hand. */
11828
+ .sp-share-fallback {
11829
+ display: flex;
11830
+ gap: 8px;
11831
+ padding: 8px;
11832
+ }
11833
+ .sp-share-fallback input {
11834
+ flex: 1;
11835
+ min-width: 0;
11836
+ /* 16px keeps iOS Safari from zooming the viewport on focus. */
11837
+ font-size: 16px;
11838
+ padding: 10px 12px;
11839
+ border-radius: 8px;
11840
+ border: 1px solid rgba(255, 255, 255, 0.2);
11841
+ background: rgba(255, 255, 255, 0.08);
11842
+ color: #fff;
11843
+ }
11844
+
11845
+ @media (min-width: 640px) {
11846
+ .sp-share-backdrop { background: rgba(0, 0, 0, 0.25); }
11847
+
11848
+ .sp-share-sheet {
11849
+ left: auto;
11850
+ right: 12px;
11851
+ bottom: 64px;
11852
+ width: 320px;
11853
+ border-radius: 12px;
11854
+ padding: 10px;
11855
+ transform: translateY(8px) scale(0.98);
11856
+ opacity: 0;
11857
+ transition: opacity 140ms ease, transform 140ms ease;
11858
+ max-height: 60%;
11859
+ }
11860
+ .sp-share-sheet--open { transform: translateY(0) scale(1); opacity: 1; }
11861
+
11862
+ .sp-share-grip { display: none; }
11863
+ .sp-share-targets { grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); }
11864
+ }
11865
+
11866
+ @media (prefers-reduced-motion: reduce) {
11867
+ .sp-share-backdrop,
11868
+ .sp-share-sheet,
11869
+ .sp-share-target,
11870
+ .sp-share-toast {
11871
+ transition: none;
11872
+ }
11873
+ }
11874
+ `;
11875
+ var PKG_VERSION$1 = "1.8.1";
11876
+ var STYLE_ID = "sp-share-styles";
11877
+ function createSharePlugin(config = {}) {
11878
+ let api = null;
11879
+ let sheet = null;
11880
+ let styleEl = null;
11881
+ const configuredTargets = config.targets ?? DEFAULT_TARGETS;
11882
+ const buildContext = () => {
11883
+ const currentTime = api?.getState("currentTime") ?? 0;
11884
+ const isLive = Boolean(api?.getState("live"));
11885
+ const baseUrl = resolveBaseUrl(config);
11886
+ return {
11887
+ url: applyTimestamp(baseUrl, currentTime, isLive, config),
11888
+ title: resolveTitle(config),
11889
+ currentTime,
11890
+ isLive
11891
+ };
11892
+ };
11893
+ const reportError = (error) => {
11894
+ const err = error instanceof Error ? error : new Error(String(error));
11895
+ api?.logger.error("Share failed", { error: err });
11896
+ config.onError?.(err);
11897
+ };
11898
+ const reportShared = (targetId, url) => {
11899
+ config.onShare?.(targetId, url);
11900
+ api?.emit("share:completed", { targetId, url });
11901
+ };
11902
+ const availableTargets = (context) => resolveTargets(configuredTargets, (id) => api?.logger.warn(`Unknown share target: ${id}`)).filter(
11903
+ (target) => {
11904
+ if (target.id === "native") {
11905
+ return canUseNativeShare();
11906
+ }
11907
+ if (target.id === "embed") {
11908
+ return buildEmbedSnippet(context, config) !== null;
11909
+ }
11910
+ return true;
11911
+ }
11912
+ );
11913
+ const runTarget = async (target, context) => {
11914
+ if (target.id === "native") {
11915
+ const shared = await nativeShare(context);
11916
+ if (shared) {
11917
+ reportShared(target.id, context.url);
11918
+ }
11919
+ return;
11920
+ }
11921
+ if (target.id === "copy") {
11922
+ const copied = await copyText(context.url);
11923
+ if (copied) {
11924
+ sheet?.showToast("Link copied");
11925
+ reportShared(target.id, context.url);
11926
+ } else {
11927
+ sheet?.showManualCopy(context.url);
11928
+ }
11929
+ return;
11930
+ }
11931
+ if (target.id === "embed") {
11932
+ const snippet = buildEmbedSnippet(context, config);
11933
+ if (!snippet) {
11934
+ return;
11935
+ }
11936
+ const copied = await copyText(snippet);
11937
+ if (copied) {
11938
+ sheet?.showToast("Embed code copied");
11939
+ reportShared(target.id, context.url);
11940
+ } else {
11941
+ sheet?.showManualCopy(snippet);
11942
+ }
11943
+ return;
11944
+ }
11945
+ if (target.href) {
11946
+ window.open(target.href(context), "_blank", "noopener,noreferrer");
11947
+ reportShared(target.id, context.url);
11948
+ }
11949
+ };
11950
+ const openSheet = () => {
11951
+ if (!sheet) {
11952
+ return;
11953
+ }
11954
+ const context = buildContext();
11955
+ const targets = availableTargets(context);
11956
+ if (targets.length === 0) {
11957
+ api?.logger.warn("No share targets available");
11958
+ return;
11959
+ }
11960
+ sheet.show(targets);
11961
+ api?.emit("share:opened", void 0);
11962
+ };
11963
+ const activate = async () => {
11964
+ const hostChoseTargets = config.targets !== void 0;
11965
+ if (!hostChoseTargets && canUseNativeShare()) {
11966
+ const context = buildContext();
11967
+ try {
11968
+ const shared = await nativeShare(context);
11969
+ if (shared) {
11970
+ reportShared("native", context.url);
11971
+ }
11972
+ } catch (error) {
11973
+ reportError(error);
11974
+ }
11975
+ return;
11976
+ }
11977
+ openSheet();
11978
+ };
11979
+ return {
11980
+ id: "share",
11981
+ name: "Share",
11982
+ version: PKG_VERSION$1,
11983
+ type: "feature",
11984
+ description: "Native share sheet, copy link, timestamps and embed codes",
11985
+ init(pluginApi) {
11986
+ api = pluginApi;
11987
+ api.logger.debug("Share plugin initialized");
11988
+ if (!document.getElementById(STYLE_ID)) {
11989
+ styleEl = document.createElement("style");
11990
+ styleEl.id = STYLE_ID;
11991
+ styleEl.textContent = styles;
11992
+ document.head.appendChild(styleEl);
11993
+ }
11994
+ sheet = new ShareSheet(api, {
11995
+ onSelect: (target) => {
11996
+ const context = buildContext();
11997
+ if (target.id !== "copy" && target.id !== "embed") {
11998
+ sheet?.close();
11999
+ }
12000
+ void runTarget(target, context).catch(reportError);
12001
+ },
12002
+ onClose: () => {
12003
+ api?.emit("share:closed", void 0);
12004
+ }
12005
+ });
12006
+ void Promise.resolve().then(() => index).then(({ registerControl: registerControl2 }) => {
12007
+ registerControl2(
12008
+ "share",
12009
+ (controlApi) => new ShareButton(controlApi, () => void activate(), {
12010
+ icon: config.buttonIcon,
12011
+ label: config.buttonLabel
12012
+ })
12013
+ );
12014
+ }).catch(() => {
12015
+ api?.logger.debug("@scarlett-player/ui not present, share control not registered");
12016
+ });
12017
+ api.onDestroy(() => {
12018
+ sheet?.destroy();
12019
+ sheet = null;
12020
+ });
12021
+ },
12022
+ destroy() {
12023
+ sheet?.destroy();
12024
+ sheet = null;
12025
+ styleEl?.remove();
12026
+ styleEl = null;
12027
+ api = null;
12028
+ },
12029
+ async share(targetId) {
12030
+ if (!targetId) {
12031
+ await activate();
12032
+ return;
12033
+ }
12034
+ const context = buildContext();
12035
+ const target = availableTargets(context).find((t) => t.id === targetId);
12036
+ if (!target) {
12037
+ reportError(new Error(`Unknown or unavailable share target: ${targetId}`));
12038
+ return;
12039
+ }
12040
+ try {
12041
+ await runTarget(target, context);
12042
+ } catch (error) {
12043
+ reportError(error);
12044
+ }
12045
+ },
12046
+ getShareUrl() {
12047
+ return buildContext().url;
12048
+ },
12049
+ close() {
12050
+ sheet?.close();
12051
+ }
12052
+ };
12053
+ }
9367
12054
  function getAttr(element, ...names) {
9368
12055
  for (const name of names) {
9369
12056
  const value = element.getAttribute(name);
@@ -9393,6 +12080,14 @@ function parseDataAttributes(element) {
9393
12080
  if (controls !== null) {
9394
12081
  config.controls = controls !== "false";
9395
12082
  }
12083
+ const bigPlayButton = getAttr(element, "data-big-play-button", "big-play-button");
12084
+ if (bigPlayButton !== null) {
12085
+ config.bigPlayButton = bigPlayButton !== "false";
12086
+ }
12087
+ const gestures = getAttr(element, "data-gestures", "gestures");
12088
+ if (gestures !== null) {
12089
+ config.gestures = gestures !== "false";
12090
+ }
9396
12091
  const keyboard = getAttr(element, "data-keyboard", "keyboard");
9397
12092
  if (keyboard !== null) {
9398
12093
  config.keyboard = keyboard !== "false";
@@ -9421,6 +12116,14 @@ function parseDataAttributes(element) {
9421
12116
  if (album) {
9422
12117
  config.album = album;
9423
12118
  }
12119
+ const shareUrl = getAttr(element, "data-share-url", "share-url");
12120
+ if (shareUrl) {
12121
+ config.shareUrl = shareUrl;
12122
+ }
12123
+ const embedBaseUrl = getAttr(element, "data-embed-base-url", "embed-base-url");
12124
+ if (embedBaseUrl) {
12125
+ config.embedBaseUrl = embedBaseUrl;
12126
+ }
9424
12127
  const brandColor = getAttr(element, "data-brand-color", "data-color", "color");
9425
12128
  if (brandColor) {
9426
12129
  config.brandColor = brandColor;
@@ -9523,6 +12226,23 @@ function applyContainerStyles(container, config) {
9523
12226
  container.style.width = container.style.width || "100%";
9524
12227
  }
9525
12228
  }
12229
+ const SHARE_CONTROL_LAYOUT = [
12230
+ "play",
12231
+ "skip-backward",
12232
+ "skip-forward",
12233
+ "volume",
12234
+ "time",
12235
+ "live-indicator",
12236
+ "bandwidth-indicator",
12237
+ "spacer",
12238
+ "share",
12239
+ "settings",
12240
+ "captions",
12241
+ "chromecast",
12242
+ "airplay",
12243
+ "pip",
12244
+ "fullscreen"
12245
+ ];
9526
12246
  async function createEmbedPlayer(container, config, pluginCreators2, availableTypes) {
9527
12247
  const type = config.type || "video";
9528
12248
  if (!availableTypes.includes(type)) {
@@ -9542,6 +12262,9 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
9542
12262
  if (config.primaryColor) theme.primaryColor = config.primaryColor;
9543
12263
  if (config.backgroundColor) theme.backgroundColor = config.backgroundColor;
9544
12264
  const plugins = [pluginCreators2.hls()];
12265
+ if (pluginCreators2.native) {
12266
+ plugins.push(pluginCreators2.native());
12267
+ }
9545
12268
  if (pluginCreators2.playlist && config.playlist?.length) {
9546
12269
  plugins.push(pluginCreators2.playlist({
9547
12270
  items: config.playlist.map((item, index2) => ({
@@ -9568,6 +12291,16 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
9568
12291
  if (pluginCreators2.captions) {
9569
12292
  plugins.push(pluginCreators2.captions(config.captions || {}));
9570
12293
  }
12294
+ if (type === "video" && pluginCreators2.gestures && config.gestures !== false) {
12295
+ plugins.push(pluginCreators2.gestures({}));
12296
+ }
12297
+ const shareEnabled = type === "video" && Boolean(config.shareUrl) && config.controls !== false;
12298
+ if (shareEnabled && pluginCreators2.share) {
12299
+ const shareConfig = { url: config.shareUrl };
12300
+ if (config.title) shareConfig.title = config.title;
12301
+ if (config.embedBaseUrl) shareConfig.embedBaseUrl = config.embedBaseUrl;
12302
+ plugins.push(pluginCreators2.share(shareConfig));
12303
+ }
9571
12304
  if (pluginCreators2.analytics && config.analytics?.beaconUrl) {
9572
12305
  plugins.push(pluginCreators2.analytics({
9573
12306
  beaconUrl: config.analytics.beaconUrl,
@@ -9580,6 +12313,8 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
9580
12313
  const uiConfig = {};
9581
12314
  if (Object.keys(theme).length > 0) uiConfig.theme = theme;
9582
12315
  if (config.hideDelay !== void 0) uiConfig.hideDelay = config.hideDelay;
12316
+ if (config.bigPlayButton !== void 0) uiConfig.bigPlayButton = config.bigPlayButton;
12317
+ if (shareEnabled && pluginCreators2.share) uiConfig.controls = SHARE_CONTROL_LAYOUT;
9583
12318
  plugins.push(pluginCreators2.videoUI(uiConfig));
9584
12319
  } else if ((type === "audio" || type === "audio-mini") && pluginCreators2.audioUI) {
9585
12320
  plugins.push(pluginCreators2.audioUI({
@@ -9685,17 +12420,21 @@ function setupAutoInit(pluginCreators2, availableTypes) {
9685
12420
  }
9686
12421
  }
9687
12422
  }
9688
- const VERSION = "0.5.3";
12423
+ const PKG_VERSION = "1.8.1";
12424
+ const VERSION = PKG_VERSION;
9689
12425
  const AVAILABLE_TYPES = ["video", "audio", "audio-mini"];
9690
12426
  const pluginCreators = {
9691
12427
  hls: createHLSPlugin,
12428
+ native: createNativePlugin,
9692
12429
  videoUI: uiPlugin,
9693
12430
  audioUI: createAudioUIPlugin,
9694
12431
  analytics: createAnalyticsPlugin,
9695
12432
  playlist: createPlaylistPlugin,
9696
12433
  mediaSession: createMediaSessionPlugin,
9697
12434
  watermark: createWatermarkPlugin,
9698
- captions: createCaptionsPlugin
12435
+ captions: createCaptionsPlugin,
12436
+ gestures: createGesturesPlugin,
12437
+ share: createSharePlugin
9699
12438
  };
9700
12439
  const ScarlettPlayerAPI = createScarlettPlayerAPI(
9701
12440
  pluginCreators,