@scarlett-player/embed 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -5
- package/dist/create-embed.d.ts +67 -0
- package/dist/create-embed.d.ts.map +1 -0
- package/dist/{hls2.js → embed.audio.index.js} +1 -1
- package/dist/embed.audio.index.js.map +1 -0
- package/dist/embed.audio.js +820 -52
- package/dist/embed.audio.js.map +1 -1
- package/dist/embed.audio.umd.cjs +1 -1
- package/dist/embed.audio.umd.cjs.map +1 -1
- package/dist/embed.js +2240 -215
- package/dist/embed.js.map +1 -1
- package/dist/embed.umd.cjs +1 -1
- package/dist/embed.umd.cjs.map +1 -1
- package/dist/embed.video.js +2221 -202
- package/dist/embed.video.js.map +1 -1
- package/dist/embed.video.umd.cjs +1 -1
- package/dist/embed.video.umd.cjs.map +1 -1
- package/dist/hls.light.js +21849 -0
- package/dist/hls.light.js.map +1 -0
- package/dist/index-audio.d.ts +21 -0
- package/dist/index-audio.d.ts.map +1 -0
- package/dist/index-video.d.ts +19 -0
- package/dist/index-video.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/parser.d.ts +15 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/types.d.ts +156 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.d.ts.map +1 -0
- package/iframe.html +35 -13
- package/package.json +19 -17
- package/dist/hls2.js.map +0 -1
package/dist/embed.js
CHANGED
|
@@ -149,6 +149,7 @@ class StateManager {
|
|
|
149
149
|
this.signals = /* @__PURE__ */ new Map();
|
|
150
150
|
this.changeSubscribers = /* @__PURE__ */ new Set();
|
|
151
151
|
this.definedDefaults = /* @__PURE__ */ new Map();
|
|
152
|
+
this.destroyed = false;
|
|
152
153
|
this.initializeSignals(initialState);
|
|
153
154
|
}
|
|
154
155
|
/**
|
|
@@ -209,6 +210,7 @@ class StateManager {
|
|
|
209
210
|
*
|
|
210
211
|
* @param key - State property key
|
|
211
212
|
* @returns Signal for the property
|
|
213
|
+
* @throws If the manager has been destroyed, or the key was never registered
|
|
212
214
|
*
|
|
213
215
|
* @example
|
|
214
216
|
* ```ts
|
|
@@ -218,6 +220,9 @@ class StateManager {
|
|
|
218
220
|
* ```
|
|
219
221
|
*/
|
|
220
222
|
get(key) {
|
|
223
|
+
if (this.destroyed) {
|
|
224
|
+
throw new Error(`[StateManager] Manager is destroyed (reading '${key}')`);
|
|
225
|
+
}
|
|
221
226
|
const stateSignal = this.signals.get(key);
|
|
222
227
|
if (!stateSignal) {
|
|
223
228
|
throw new Error(`[StateManager] Unknown state key: ${key}`);
|
|
@@ -229,6 +234,7 @@ class StateManager {
|
|
|
229
234
|
*
|
|
230
235
|
* @param key - State property key
|
|
231
236
|
* @returns Current value
|
|
237
|
+
* @throws If the manager has been destroyed, or the key was never registered
|
|
232
238
|
*
|
|
233
239
|
* @example
|
|
234
240
|
* ```ts
|
|
@@ -396,6 +402,11 @@ class StateManager {
|
|
|
396
402
|
/**
|
|
397
403
|
* Destroy the state manager and cleanup all signals.
|
|
398
404
|
*
|
|
405
|
+
* After this, every read or write ({@link get}, {@link getValue},
|
|
406
|
+
* {@link set}) throws a destroyed-specific error. Returning last-known
|
|
407
|
+
* values instead was considered and rejected: it silently masks the
|
|
408
|
+
* lifecycle bugs this throw exposes.
|
|
409
|
+
*
|
|
399
410
|
* @example
|
|
400
411
|
* ```ts
|
|
401
412
|
* state.destroy();
|
|
@@ -405,6 +416,7 @@ class StateManager {
|
|
|
405
416
|
this.signals.forEach((stateSignal) => stateSignal.destroy());
|
|
406
417
|
this.signals.clear();
|
|
407
418
|
this.changeSubscribers.clear();
|
|
419
|
+
this.destroyed = true;
|
|
408
420
|
}
|
|
409
421
|
}
|
|
410
422
|
const DEFAULT_OPTIONS = {
|
|
@@ -1552,6 +1564,49 @@ class PluginManager {
|
|
|
1552
1564
|
}
|
|
1553
1565
|
}
|
|
1554
1566
|
}
|
|
1567
|
+
function videoIn(container) {
|
|
1568
|
+
return container.querySelector("video");
|
|
1569
|
+
}
|
|
1570
|
+
function isFullscreen(container) {
|
|
1571
|
+
const doc = document;
|
|
1572
|
+
const active = doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
|
|
1573
|
+
if (active && container.contains(active)) {
|
|
1574
|
+
return true;
|
|
1575
|
+
}
|
|
1576
|
+
return !!videoIn(container)?.webkitDisplayingFullscreen;
|
|
1577
|
+
}
|
|
1578
|
+
async function enterFullscreen(container) {
|
|
1579
|
+
const el = container;
|
|
1580
|
+
if (el.requestFullscreen) {
|
|
1581
|
+
await el.requestFullscreen();
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
if (el.webkitRequestFullscreen) {
|
|
1585
|
+
await el.webkitRequestFullscreen();
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
const video = videoIn(container);
|
|
1589
|
+
if (video?.webkitEnterFullscreen) {
|
|
1590
|
+
video.webkitEnterFullscreen();
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
throw new Error("Fullscreen is not supported");
|
|
1594
|
+
}
|
|
1595
|
+
async function exitFullscreen(container) {
|
|
1596
|
+
const video = videoIn(container);
|
|
1597
|
+
if (video?.webkitDisplayingFullscreen) {
|
|
1598
|
+
video.webkitExitFullscreen?.();
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const doc = document;
|
|
1602
|
+
if (doc.exitFullscreen) {
|
|
1603
|
+
await doc.exitFullscreen();
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (doc.webkitExitFullscreen) {
|
|
1607
|
+
await doc.webkitExitFullscreen();
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1555
1610
|
class ScarlettPlayer {
|
|
1556
1611
|
/**
|
|
1557
1612
|
* Create a new ScarlettPlayer.
|
|
@@ -1564,6 +1619,11 @@ class ScarlettPlayer {
|
|
|
1564
1619
|
this.seekingWhilePlaying = false;
|
|
1565
1620
|
this.seekResumeTimeout = null;
|
|
1566
1621
|
this.loadGeneration = 0;
|
|
1622
|
+
this.listenersWired = false;
|
|
1623
|
+
this.readyEmitted = false;
|
|
1624
|
+
this.fullscreenAnnounced = false;
|
|
1625
|
+
this.unwireFullscreen = null;
|
|
1626
|
+
this.initializing = null;
|
|
1567
1627
|
if (typeof options.container === "string") {
|
|
1568
1628
|
const el = document.querySelector(options.container);
|
|
1569
1629
|
if (!el || !(el instanceof HTMLElement)) {
|
|
@@ -1604,31 +1664,79 @@ class ScarlettPlayer {
|
|
|
1604
1664
|
this.eventBus.on("media:error", ({ error }) => {
|
|
1605
1665
|
this.errorHandler.record(error, { channel: "media:error" });
|
|
1606
1666
|
});
|
|
1667
|
+
this.wireFullscreenListeners();
|
|
1607
1668
|
if (options.plugins) {
|
|
1608
1669
|
for (const plugin of options.plugins) {
|
|
1609
1670
|
this.pluginManager.register(plugin);
|
|
1610
1671
|
}
|
|
1611
1672
|
}
|
|
1612
|
-
this.logger.info("ScarlettPlayer
|
|
1673
|
+
this.logger.info("ScarlettPlayer constructed", {
|
|
1613
1674
|
autoplay: options.autoplay,
|
|
1614
1675
|
plugins: options.plugins?.length ?? 0
|
|
1615
1676
|
});
|
|
1616
|
-
this.eventBus.emit("player:ready", void 0);
|
|
1617
1677
|
}
|
|
1618
1678
|
/**
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1679
|
+
* Initialise every registered non-provider plugin and wire the player's
|
|
1680
|
+
* own lifecycle listeners. Idempotent, and safe to call re-entrantly.
|
|
1681
|
+
*
|
|
1682
|
+
* This exists because `new ScarlettPlayer(...)` followed by `load()` used
|
|
1683
|
+
* to leave the player with a provider and nothing else: the READMEs and 12
|
|
1684
|
+
* plugin `@example` blocks show exactly that shape, and every one of them
|
|
1685
|
+
* produced a dead UI (no controls, no overlay, no playlist). `load()` now
|
|
1686
|
+
* calls this first, so the trap cannot be reached.
|
|
1687
|
+
*
|
|
1688
|
+
* The `media:load-request` and `error:retry` listeners live here rather
|
|
1689
|
+
* than in the constructor because they are part of initialisation, not
|
|
1690
|
+
* construction: without them the playlist plugin cannot load a track and
|
|
1691
|
+
* the error overlay's "Try Again" button does nothing.
|
|
1692
|
+
*
|
|
1693
|
+
* Provider plugins are excluded: they are initialised lazily, per source,
|
|
1694
|
+
* by `load()` once `selectProvider()` has picked one.
|
|
1695
|
+
*
|
|
1696
|
+
* @returns Promise resolving when the pass (or the in-flight one) completes
|
|
1621
1697
|
*/
|
|
1622
|
-
|
|
1623
|
-
this.
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1698
|
+
ensureInitialized() {
|
|
1699
|
+
if (this.initializing) return this.initializing;
|
|
1700
|
+
this.initializing = this.runInitialization().finally(() => {
|
|
1701
|
+
this.initializing = null;
|
|
1702
|
+
});
|
|
1703
|
+
return this.initializing;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* One initialisation pass. Never call directly; go through
|
|
1707
|
+
* `ensureInitialized()`, which owns the re-entrancy guard.
|
|
1708
|
+
*
|
|
1709
|
+
* @returns Promise resolving when the pass completes
|
|
1710
|
+
*/
|
|
1711
|
+
async runInitialization() {
|
|
1712
|
+
for (const id of this.pluginManager.getPluginIds()) {
|
|
1713
|
+
if (this.destroyed) return;
|
|
1714
|
+
const plugin = this.pluginManager.getPlugin(id);
|
|
1715
|
+
if (!plugin || plugin.type === "provider") continue;
|
|
1716
|
+
if (this.pluginManager.getPluginState(id) !== "registered") continue;
|
|
1717
|
+
await this.pluginManager.initPlugin(id);
|
|
1628
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;
|
|
1629
1736
|
this.eventBus.on("media:load-request", async ({ src, autoplay }) => {
|
|
1630
1737
|
if (this.stateManager.getValue("chromecastActive")) return;
|
|
1631
1738
|
await this.load(src);
|
|
1739
|
+
if (this.destroyed) return;
|
|
1632
1740
|
if (autoplay !== false) {
|
|
1633
1741
|
await this.play();
|
|
1634
1742
|
}
|
|
@@ -1637,23 +1745,50 @@ class ScarlettPlayer {
|
|
|
1637
1745
|
const was_live = this.stateManager.getValue("live");
|
|
1638
1746
|
const resume_at = this.stateManager.getValue("currentTime");
|
|
1639
1747
|
await this.load(src);
|
|
1748
|
+
if (this.destroyed) return;
|
|
1640
1749
|
if (this.stateManager.getValue("error")) return;
|
|
1641
1750
|
if (was_live) {
|
|
1642
1751
|
this.seekToLive();
|
|
1643
1752
|
} else if (resume_at > 0) {
|
|
1644
1753
|
this.seek(resume_at);
|
|
1645
1754
|
}
|
|
1755
|
+
if (this.destroyed) return;
|
|
1646
1756
|
await this.play();
|
|
1647
1757
|
});
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Initialize the player asynchronously.
|
|
1761
|
+
*
|
|
1762
|
+
* Initialises non-provider plugins, wires the lifecycle listeners and loads
|
|
1763
|
+
* `initialSrc` when one was given. Idempotent: calling it twice, or calling
|
|
1764
|
+
* it after a `load()` has already initialised the player, wires nothing a
|
|
1765
|
+
* second time and re-emits nothing.
|
|
1766
|
+
*
|
|
1767
|
+
* @returns Promise resolving when initialisation (and any initial load) is done
|
|
1768
|
+
*/
|
|
1769
|
+
async init() {
|
|
1770
|
+
this.checkDestroyed();
|
|
1771
|
+
await this.ensureInitialized();
|
|
1648
1772
|
if (this.initialSrc) {
|
|
1649
1773
|
await this.load(this.initialSrc);
|
|
1650
1774
|
}
|
|
1651
|
-
return Promise.resolve();
|
|
1652
1775
|
}
|
|
1653
1776
|
/**
|
|
1654
1777
|
* Load a media source.
|
|
1655
1778
|
*
|
|
1656
|
-
*
|
|
1779
|
+
* Initialises the player if that has not happened yet (see
|
|
1780
|
+
* `ensureInitialized()`), then selects the provider plugin for the source
|
|
1781
|
+
* and loads it. The auto-initialisation is what makes the widely copied
|
|
1782
|
+
* `new ScarlettPlayer(...)` plus `load()` shape work: before it, that shape
|
|
1783
|
+
* produced a player with a provider and no UI, no error overlay and no
|
|
1784
|
+
* working playlist.
|
|
1785
|
+
*
|
|
1786
|
+
* Resets playback state, and deliberately does NOT touch `poster`. The
|
|
1787
|
+
* poster is metadata owned by whoever set it (the consumer through
|
|
1788
|
+
* `PlayerOptions.poster` or `setPoster()`, or the playlist plugin on a track
|
|
1789
|
+
* change), not playback state, and it is written BEFORE the load that goes
|
|
1790
|
+
* with it: clearing it here would blank the image over exactly the gap it
|
|
1791
|
+
* exists to cover, while the next source loads.
|
|
1657
1792
|
*
|
|
1658
1793
|
* @param source - Media source URL
|
|
1659
1794
|
* @returns Promise that resolves when source is loaded
|
|
@@ -1685,6 +1820,7 @@ class ScarlettPlayer {
|
|
|
1685
1820
|
await this.pluginManager.destroyPlugin(previousProviderId);
|
|
1686
1821
|
this._currentProvider = null;
|
|
1687
1822
|
}
|
|
1823
|
+
await this.ensureInitialized();
|
|
1688
1824
|
if (generation !== this.loadGeneration) {
|
|
1689
1825
|
this.logger.info("Load superseded by newer load call", { source });
|
|
1690
1826
|
return;
|
|
@@ -1882,6 +2018,31 @@ class ScarlettPlayer {
|
|
|
1882
2018
|
this.stateManager.set("autoplay", autoplay);
|
|
1883
2019
|
this.logger.debug("Autoplay set", { autoplay });
|
|
1884
2020
|
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Set the poster image shown until the first frame renders.
|
|
2023
|
+
*
|
|
2024
|
+
* Writes the `poster` state key; the provider plugins subscribe to it and
|
|
2025
|
+
* mirror it onto the media element, so this takes effect on a player that
|
|
2026
|
+
* is already running. Before this method existed the poster could only be
|
|
2027
|
+
* chosen at construction, which left a playlist showing the previous
|
|
2028
|
+
* track's art (and a Vue `poster` prop change doing nothing at all).
|
|
2029
|
+
*
|
|
2030
|
+
* An empty string clears the poster, which is how a consumer takes the
|
|
2031
|
+
* image away rather than replacing it.
|
|
2032
|
+
*
|
|
2033
|
+
* @param url - Poster image URL, or '' to clear it
|
|
2034
|
+
*
|
|
2035
|
+
* @example
|
|
2036
|
+
* ```ts
|
|
2037
|
+
* player.setPoster('https://example.com/art.jpg');
|
|
2038
|
+
* player.setPoster(''); // back to the bare video surface
|
|
2039
|
+
* ```
|
|
2040
|
+
*/
|
|
2041
|
+
setPoster(url) {
|
|
2042
|
+
this.checkDestroyed();
|
|
2043
|
+
this.stateManager.set("poster", url);
|
|
2044
|
+
this.logger.debug("Poster set", { poster: url });
|
|
2045
|
+
}
|
|
1885
2046
|
/**
|
|
1886
2047
|
* Subscribe to an event.
|
|
1887
2048
|
*
|
|
@@ -1912,9 +2073,13 @@ class ScarlettPlayer {
|
|
|
1912
2073
|
*
|
|
1913
2074
|
* @example
|
|
1914
2075
|
* ```ts
|
|
2076
|
+
* // player:ready fires once, at the end of the first initialisation, so a
|
|
2077
|
+
* // one-shot listener has to be attached before init() or load() runs.
|
|
2078
|
+
* const player = new ScarlettPlayer({ container });
|
|
1915
2079
|
* player.once('player:ready', () => {
|
|
1916
2080
|
* console.log('Player ready!');
|
|
1917
2081
|
* });
|
|
2082
|
+
* await player.init();
|
|
1918
2083
|
* ```
|
|
1919
2084
|
*/
|
|
1920
2085
|
once(event, handler) {
|
|
@@ -2018,36 +2183,83 @@ class ScarlettPlayer {
|
|
|
2018
2183
|
return -1;
|
|
2019
2184
|
}
|
|
2020
2185
|
// ===== Fullscreen Methods =====
|
|
2186
|
+
/**
|
|
2187
|
+
* Listen for fullscreen changes the player did not initiate.
|
|
2188
|
+
*
|
|
2189
|
+
* Nothing used to: the `fullscreen` state key was written only by this
|
|
2190
|
+
* class's own `requestFullscreen()` and `exitFullscreen()`. Everything else
|
|
2191
|
+
* left it lying. Entering fullscreen through the UI button or the `f`
|
|
2192
|
+
* shortcut never flipped the icon to "Exit fullscreen", `player.fullscreen`
|
|
2193
|
+
* stayed false and `fullscreen:change` never fired; and after a programmatic
|
|
2194
|
+
* `requestFullscreen()` an Escape exit left the state stuck at true.
|
|
2195
|
+
*
|
|
2196
|
+
* `webkitbeginfullscreen` and `webkitendfullscreen` are the iPhone's native
|
|
2197
|
+
* player announcing itself. They are dispatched on the video element, they do
|
|
2198
|
+
* not bubble, and the element does not exist yet when this runs (a provider
|
|
2199
|
+
* plugin creates it, per source), so they are bound to the container in the
|
|
2200
|
+
* CAPTURE phase, which is the one phase that sees a non-bubbling event on a
|
|
2201
|
+
* descendant.
|
|
2202
|
+
*/
|
|
2203
|
+
wireFullscreenListeners() {
|
|
2204
|
+
const onChange = () => {
|
|
2205
|
+
this.fullscreenAnnounced = true;
|
|
2206
|
+
this.setFullscreenState(isFullscreen(this.container));
|
|
2207
|
+
};
|
|
2208
|
+
document.addEventListener("fullscreenchange", onChange);
|
|
2209
|
+
document.addEventListener("webkitfullscreenchange", onChange);
|
|
2210
|
+
this.container.addEventListener("webkitbeginfullscreen", onChange, true);
|
|
2211
|
+
this.container.addEventListener("webkitendfullscreen", onChange, true);
|
|
2212
|
+
this.unwireFullscreen = () => {
|
|
2213
|
+
document.removeEventListener("fullscreenchange", onChange);
|
|
2214
|
+
document.removeEventListener("webkitfullscreenchange", onChange);
|
|
2215
|
+
this.container.removeEventListener("webkitbeginfullscreen", onChange, true);
|
|
2216
|
+
this.container.removeEventListener("webkitendfullscreen", onChange, true);
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Record a fullscreen transition, once.
|
|
2221
|
+
*
|
|
2222
|
+
* @param next - The state the browser is now in
|
|
2223
|
+
*/
|
|
2224
|
+
setFullscreenState(next) {
|
|
2225
|
+
if (this.stateManager.getValue("fullscreen") === next) {
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
this.stateManager.set("fullscreen", next);
|
|
2229
|
+
this.eventBus.emit("fullscreen:change", { fullscreen: next });
|
|
2230
|
+
}
|
|
2021
2231
|
/**
|
|
2022
2232
|
* Request fullscreen mode.
|
|
2233
|
+
*
|
|
2234
|
+
* @returns Promise resolving once the browser has accepted or refused
|
|
2023
2235
|
*/
|
|
2024
2236
|
async requestFullscreen() {
|
|
2025
2237
|
this.checkDestroyed();
|
|
2238
|
+
this.fullscreenAnnounced = false;
|
|
2026
2239
|
try {
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2240
|
+
await enterFullscreen(this.container);
|
|
2241
|
+
if (!this.fullscreenAnnounced) {
|
|
2242
|
+
this.stateManager.set("fullscreen", true);
|
|
2243
|
+
this.eventBus.emit("fullscreen:change", { fullscreen: true });
|
|
2031
2244
|
}
|
|
2032
|
-
this.stateManager.set("fullscreen", true);
|
|
2033
|
-
this.eventBus.emit("fullscreen:change", { fullscreen: true });
|
|
2034
2245
|
} catch (error) {
|
|
2035
2246
|
this.logger.error("Fullscreen request failed", { error });
|
|
2036
2247
|
}
|
|
2037
2248
|
}
|
|
2038
2249
|
/**
|
|
2039
2250
|
* Exit fullscreen mode.
|
|
2251
|
+
*
|
|
2252
|
+
* @returns Promise resolving once the browser has accepted or refused
|
|
2040
2253
|
*/
|
|
2041
2254
|
async exitFullscreen() {
|
|
2042
2255
|
this.checkDestroyed();
|
|
2256
|
+
this.fullscreenAnnounced = false;
|
|
2043
2257
|
try {
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2258
|
+
await exitFullscreen(this.container);
|
|
2259
|
+
if (!this.fullscreenAnnounced) {
|
|
2260
|
+
this.stateManager.set("fullscreen", false);
|
|
2261
|
+
this.eventBus.emit("fullscreen:change", { fullscreen: false });
|
|
2048
2262
|
}
|
|
2049
|
-
this.stateManager.set("fullscreen", false);
|
|
2050
|
-
this.eventBus.emit("fullscreen:change", { fullscreen: false });
|
|
2051
2263
|
} catch (error) {
|
|
2052
2264
|
this.logger.error("Exit fullscreen failed", { error });
|
|
2053
2265
|
}
|
|
@@ -2140,10 +2352,13 @@ class ScarlettPlayer {
|
|
|
2140
2352
|
return;
|
|
2141
2353
|
}
|
|
2142
2354
|
this.logger.info("Destroying player");
|
|
2355
|
+
this.loadGeneration++;
|
|
2143
2356
|
if (this.seekResumeTimeout !== null) {
|
|
2144
2357
|
clearTimeout(this.seekResumeTimeout);
|
|
2145
2358
|
this.seekResumeTimeout = null;
|
|
2146
2359
|
}
|
|
2360
|
+
this.unwireFullscreen?.();
|
|
2361
|
+
this.unwireFullscreen = null;
|
|
2147
2362
|
this.eventBus.emit("player:destroy", void 0);
|
|
2148
2363
|
this.pluginManager.destroyAll();
|
|
2149
2364
|
this.eventBus.destroy();
|
|
@@ -2224,6 +2439,16 @@ class ScarlettPlayer {
|
|
|
2224
2439
|
get autoplay() {
|
|
2225
2440
|
return this.stateManager.getValue("autoplay");
|
|
2226
2441
|
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Get the current poster URL ('' when there is none).
|
|
2444
|
+
*
|
|
2445
|
+
* Reads state rather than the media element: the element only exists once a
|
|
2446
|
+
* provider has been initialised, and for an audio source it never carries
|
|
2447
|
+
* the attribute at all.
|
|
2448
|
+
*/
|
|
2449
|
+
get poster() {
|
|
2450
|
+
return this.stateManager.getValue("poster");
|
|
2451
|
+
}
|
|
2227
2452
|
/**
|
|
2228
2453
|
* Check if player is destroyed.
|
|
2229
2454
|
* @private
|
|
@@ -2287,6 +2512,15 @@ var __export = (target, all) => {
|
|
|
2287
2512
|
for (var name in all)
|
|
2288
2513
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
2289
2514
|
};
|
|
2515
|
+
function sanitizeUrl(url) {
|
|
2516
|
+
if (!url) return void 0;
|
|
2517
|
+
try {
|
|
2518
|
+
const parsed = new URL(url);
|
|
2519
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
2520
|
+
} catch {
|
|
2521
|
+
return void 0;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2290
2524
|
function formatLevel(level) {
|
|
2291
2525
|
if (level.name) {
|
|
2292
2526
|
return level.name;
|
|
@@ -2486,8 +2720,14 @@ function setupVideoEventHandlers(video, api) {
|
|
|
2486
2720
|
video.addEventListener(event, handler);
|
|
2487
2721
|
handlers.push({ event, handler });
|
|
2488
2722
|
};
|
|
2723
|
+
const syncEndedFromElement = () => {
|
|
2724
|
+
if (video.ended || !api.getState("ended")) return;
|
|
2725
|
+
api.setState("ended", false);
|
|
2726
|
+
api.setState("playbackState", video.paused ? "paused" : "playing");
|
|
2727
|
+
};
|
|
2489
2728
|
addHandler("play", () => {
|
|
2490
2729
|
api.setState("paused", false);
|
|
2730
|
+
syncEndedFromElement();
|
|
2491
2731
|
});
|
|
2492
2732
|
addHandler("playing", () => {
|
|
2493
2733
|
api.setState("playing", true);
|
|
@@ -2495,6 +2735,7 @@ function setupVideoEventHandlers(video, api) {
|
|
|
2495
2735
|
api.setState("waiting", false);
|
|
2496
2736
|
api.setState("buffering", false);
|
|
2497
2737
|
api.setState("playbackState", "playing");
|
|
2738
|
+
syncEndedFromElement();
|
|
2498
2739
|
});
|
|
2499
2740
|
addHandler("pause", () => {
|
|
2500
2741
|
api.setState("playing", false);
|
|
@@ -2549,6 +2790,7 @@ function setupVideoEventHandlers(video, api) {
|
|
|
2549
2790
|
});
|
|
2550
2791
|
addHandler("seeking", () => {
|
|
2551
2792
|
api.setState("seeking", true);
|
|
2793
|
+
syncEndedFromElement();
|
|
2552
2794
|
});
|
|
2553
2795
|
addHandler("seeked", () => {
|
|
2554
2796
|
api.setState("seeking", false);
|
|
@@ -2653,6 +2895,7 @@ function createValidatingPlaylistLoader(Hls) {
|
|
|
2653
2895
|
}
|
|
2654
2896
|
};
|
|
2655
2897
|
}
|
|
2898
|
+
var PKG_VERSION$a = "1.7.1";
|
|
2656
2899
|
var DEFAULT_CONFIG$4 = {
|
|
2657
2900
|
debug: false,
|
|
2658
2901
|
autoStartLoad: true,
|
|
@@ -2708,6 +2951,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
2708
2951
|
let reconnectWindowStart = 0;
|
|
2709
2952
|
let reconnectResumePosition = 0;
|
|
2710
2953
|
let onlineListener = null;
|
|
2954
|
+
let reconnectTriggerError = null;
|
|
2955
|
+
let reconnectExhausted = false;
|
|
2956
|
+
const applyPoster = () => {
|
|
2957
|
+
if (!video) return;
|
|
2958
|
+
video.poster = api?.getState("poster") || "";
|
|
2959
|
+
};
|
|
2711
2960
|
const getOrCreateVideo = () => {
|
|
2712
2961
|
if (video) return video;
|
|
2713
2962
|
const existing = api?.container.querySelector("video");
|
|
@@ -2720,10 +2969,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
2720
2969
|
video.preload = "metadata";
|
|
2721
2970
|
video.controls = false;
|
|
2722
2971
|
video.playsInline = true;
|
|
2723
|
-
|
|
2724
|
-
if (poster) {
|
|
2725
|
-
video.poster = poster;
|
|
2726
|
-
}
|
|
2972
|
+
applyPoster();
|
|
2727
2973
|
api?.container.appendChild(video);
|
|
2728
2974
|
return video;
|
|
2729
2975
|
};
|
|
@@ -2816,6 +3062,22 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
2816
3062
|
return ErrorCode.PLAYBACK_FAILED;
|
|
2817
3063
|
}
|
|
2818
3064
|
};
|
|
3065
|
+
const buildErrorDetail = (error, retriesExhausted) => {
|
|
3066
|
+
const attempts = error.type === "network" ? networkRetryCount : error.type === "media" ? mediaRetryCount : 0;
|
|
3067
|
+
const detail = {
|
|
3068
|
+
type: error.type,
|
|
3069
|
+
retriesExhausted,
|
|
3070
|
+
attempts
|
|
3071
|
+
};
|
|
3072
|
+
if (typeof error.response?.code === "number" && error.response.code > 0) {
|
|
3073
|
+
detail.httpStatus = error.response.code;
|
|
3074
|
+
}
|
|
3075
|
+
const url = sanitizeUrl(error.url);
|
|
3076
|
+
if (url) {
|
|
3077
|
+
detail.url = url;
|
|
3078
|
+
}
|
|
3079
|
+
return detail;
|
|
3080
|
+
};
|
|
2819
3081
|
const emitFatalError = (error, retriesExhausted) => {
|
|
2820
3082
|
const message = retriesExhausted ? `HLS error: ${error.details} (max retries exceeded)` : `HLS error: ${error.details}`;
|
|
2821
3083
|
api?.logger.error(message, { type: error.type, details: error.details });
|
|
@@ -2825,7 +3087,8 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
2825
3087
|
code: mapFatalErrorCode(error),
|
|
2826
3088
|
message,
|
|
2827
3089
|
fatal: true,
|
|
2828
|
-
timestamp: Date.now()
|
|
3090
|
+
timestamp: Date.now(),
|
|
3091
|
+
detail: buildErrorDetail(error, retriesExhausted)
|
|
2829
3092
|
});
|
|
2830
3093
|
maybeScheduleReconnect(error);
|
|
2831
3094
|
};
|
|
@@ -2902,6 +3165,62 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
2902
3165
|
}
|
|
2903
3166
|
return false;
|
|
2904
3167
|
};
|
|
3168
|
+
const handleNativeFatalError = (error, resumePosition) => {
|
|
3169
|
+
const is_network = error.type === "network";
|
|
3170
|
+
const max_retries = is_network ? mergedConfig.maxNetworkRetries ?? 3 : mergedConfig.maxMediaRetries ?? 2;
|
|
3171
|
+
const used = is_network ? networkRetryCount : mediaRetryCount;
|
|
3172
|
+
if (!currentSrc || used >= max_retries) {
|
|
3173
|
+
emitFatalError(error, used >= max_retries);
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
const resume_position = resumePosition ?? video?.currentTime ?? 0;
|
|
3177
|
+
if (is_network) {
|
|
3178
|
+
networkRetryCount++;
|
|
3179
|
+
} else {
|
|
3180
|
+
mediaRetryCount++;
|
|
3181
|
+
}
|
|
3182
|
+
const attempt = used + 1;
|
|
3183
|
+
const delay = getRetryDelay(attempt - 1);
|
|
3184
|
+
api?.logger.info(
|
|
3185
|
+
`Attempting native ${error.type} error recovery (attempt ${attempt}/${max_retries}) in ${delay}ms`
|
|
3186
|
+
);
|
|
3187
|
+
api?.emit(is_network ? "error:network" : "error:media", {
|
|
3188
|
+
error: new Error(error.details)
|
|
3189
|
+
});
|
|
3190
|
+
if (retryTimeout) {
|
|
3191
|
+
clearTimeout(retryTimeout);
|
|
3192
|
+
}
|
|
3193
|
+
const retry_session = loadSession;
|
|
3194
|
+
retryTimeout = setTimeout(() => {
|
|
3195
|
+
if (retry_session !== loadSession) return;
|
|
3196
|
+
void recoverNative(error, resume_position);
|
|
3197
|
+
}, delay);
|
|
3198
|
+
};
|
|
3199
|
+
const recoverNative = async (error, resumePosition) => {
|
|
3200
|
+
if (!currentSrc) return;
|
|
3201
|
+
const session = ++loadSession;
|
|
3202
|
+
const saved_src = currentSrc;
|
|
3203
|
+
const was_live = api?.getState("live") ?? false;
|
|
3204
|
+
try {
|
|
3205
|
+
teardownPipeline(new Error("HLS load cancelled: native error recovery"));
|
|
3206
|
+
api?.setState("playbackState", "loading");
|
|
3207
|
+
await loadNative(saved_src);
|
|
3208
|
+
if (session !== loadSession) return;
|
|
3209
|
+
if (!was_live && video && resumePosition > 0) {
|
|
3210
|
+
video.currentTime = resumePosition;
|
|
3211
|
+
}
|
|
3212
|
+
api?.setState("playbackState", "ready");
|
|
3213
|
+
api?.setState("buffering", false);
|
|
3214
|
+
try {
|
|
3215
|
+
await video?.play();
|
|
3216
|
+
} catch {
|
|
3217
|
+
}
|
|
3218
|
+
} catch {
|
|
3219
|
+
if (session !== loadSession) return;
|
|
3220
|
+
api?.logger.warn("Native error recovery attempt failed");
|
|
3221
|
+
handleNativeFatalError(error, resumePosition);
|
|
3222
|
+
}
|
|
3223
|
+
};
|
|
2905
3224
|
const loadNative = async (src) => {
|
|
2906
3225
|
const session = loadSession;
|
|
2907
3226
|
const videoEl = getOrCreateVideo();
|
|
@@ -2943,12 +3262,24 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
2943
3262
|
const media_error = videoEl.error;
|
|
2944
3263
|
const hls_error = {
|
|
2945
3264
|
type: media_error?.code === MediaError.MEDIA_ERR_NETWORK ? "network" : "media",
|
|
2946
|
-
details: media_error?.message || "Native HLS playback error"
|
|
3265
|
+
details: media_error?.message || "Native HLS playback error",
|
|
3266
|
+
fatal: true
|
|
2947
3267
|
};
|
|
2948
|
-
|
|
3268
|
+
handleNativeFatalError(hls_error);
|
|
2949
3269
|
};
|
|
2950
3270
|
videoEl.addEventListener("error", onFatalVideoError);
|
|
2951
|
-
const
|
|
3271
|
+
const onPlayingResetBudget = () => {
|
|
3272
|
+
if (networkRetryCount > 0 || mediaRetryCount > 0) {
|
|
3273
|
+
api?.logger.debug("Native playback recovered, resetting retry budgets");
|
|
3274
|
+
networkRetryCount = 0;
|
|
3275
|
+
mediaRetryCount = 0;
|
|
3276
|
+
}
|
|
3277
|
+
};
|
|
3278
|
+
videoEl.addEventListener("playing", onPlayingResetBudget);
|
|
3279
|
+
const removeFatalListener = () => {
|
|
3280
|
+
videoEl.removeEventListener("error", onFatalVideoError);
|
|
3281
|
+
videoEl.removeEventListener("playing", onPlayingResetBudget);
|
|
3282
|
+
};
|
|
2952
3283
|
const previous_cleanup = cleanupVideoEvents;
|
|
2953
3284
|
cleanupVideoEvents = () => {
|
|
2954
3285
|
removeFatalListener();
|
|
@@ -3073,12 +3404,38 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
3073
3404
|
reconnectAttempts = 0;
|
|
3074
3405
|
reconnectWindowStart = 0;
|
|
3075
3406
|
reconnectResumePosition = 0;
|
|
3407
|
+
reconnectTriggerError = null;
|
|
3408
|
+
reconnectExhausted = false;
|
|
3409
|
+
};
|
|
3410
|
+
const emitReconnectExhausted = (elapsedMs, windowMs) => {
|
|
3411
|
+
if (reconnectExhausted) return;
|
|
3412
|
+
reconnectExhausted = true;
|
|
3413
|
+
const attempts = reconnectAttempts;
|
|
3414
|
+
const trigger = reconnectTriggerError;
|
|
3415
|
+
api?.emit("error:reconnect-exhausted", { attempts, elapsedMs, windowMs });
|
|
3416
|
+
api?.setState("playbackState", "error");
|
|
3417
|
+
api?.setState("buffering", false);
|
|
3418
|
+
api?.emit("error", {
|
|
3419
|
+
code: trigger ? mapFatalErrorCode(trigger) : ErrorCode.PLAYBACK_FAILED,
|
|
3420
|
+
message: `HLS auto-reconnect gave up after ${attempts} attempts over ${Math.round(elapsedMs / 1e3)}s`,
|
|
3421
|
+
fatal: true,
|
|
3422
|
+
timestamp: Date.now(),
|
|
3423
|
+
detail: {
|
|
3424
|
+
type: trigger?.type ?? "other",
|
|
3425
|
+
retriesExhausted: true,
|
|
3426
|
+
attempts,
|
|
3427
|
+
reconnectExhausted: true
|
|
3428
|
+
}
|
|
3429
|
+
});
|
|
3076
3430
|
};
|
|
3077
3431
|
const scheduleReconnectAttempt = () => {
|
|
3432
|
+
if (reconnectExhausted) return;
|
|
3078
3433
|
if (reconnectTimer) return;
|
|
3079
3434
|
const window_ms = mergedConfig.reconnectWindowMs ?? 3e5;
|
|
3080
|
-
|
|
3435
|
+
const elapsed_ms = Date.now() - reconnectWindowStart;
|
|
3436
|
+
if (elapsed_ms > window_ms) {
|
|
3081
3437
|
api?.logger.warn(`Auto-reconnect window exhausted after ${reconnectAttempts} attempts`);
|
|
3438
|
+
emitReconnectExhausted(elapsed_ms, window_ms);
|
|
3082
3439
|
return;
|
|
3083
3440
|
}
|
|
3084
3441
|
const base_delay = mergedConfig.reconnectBaseDelayMs ?? 2e3;
|
|
@@ -3086,7 +3443,12 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
3086
3443
|
const backoff = Math.min(base_delay * Math.pow(2, reconnectAttempts), max_delay);
|
|
3087
3444
|
const delay = Math.round(backoff * (0.7 + Math.random() * 0.3));
|
|
3088
3445
|
api?.logger.info(`Scheduling auto-reconnect attempt ${reconnectAttempts + 1} in ${delay}ms`);
|
|
3089
|
-
api?.emit("error:reconnecting", {
|
|
3446
|
+
api?.emit("error:reconnecting", {
|
|
3447
|
+
attempt: reconnectAttempts + 1,
|
|
3448
|
+
delayMs: delay,
|
|
3449
|
+
elapsedMs: elapsed_ms,
|
|
3450
|
+
windowMs: window_ms
|
|
3451
|
+
});
|
|
3090
3452
|
reconnectTimer = setTimeout(() => {
|
|
3091
3453
|
reconnectTimer = null;
|
|
3092
3454
|
void attemptReconnect();
|
|
@@ -3099,6 +3461,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
3099
3461
|
if (reconnectWindowStart === 0) {
|
|
3100
3462
|
reconnectWindowStart = Date.now();
|
|
3101
3463
|
reconnectResumePosition = video?.currentTime ?? 0;
|
|
3464
|
+
reconnectTriggerError = error;
|
|
3102
3465
|
}
|
|
3103
3466
|
scheduleReconnectAttempt();
|
|
3104
3467
|
};
|
|
@@ -3130,7 +3493,10 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
3130
3493
|
}
|
|
3131
3494
|
api.setState("playbackState", "ready");
|
|
3132
3495
|
api.setState("buffering", false);
|
|
3133
|
-
api.emit("error:recovered",
|
|
3496
|
+
api.emit("error:recovered", {
|
|
3497
|
+
attempt: reconnectAttempts,
|
|
3498
|
+
elapsedMs: Date.now() - reconnectWindowStart
|
|
3499
|
+
});
|
|
3134
3500
|
api.logger.info("Auto-reconnect succeeded");
|
|
3135
3501
|
cancelReconnect();
|
|
3136
3502
|
try {
|
|
@@ -3146,7 +3512,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
3146
3512
|
const plugin = {
|
|
3147
3513
|
id: "hls-provider",
|
|
3148
3514
|
name: variant.name,
|
|
3149
|
-
version:
|
|
3515
|
+
version: PKG_VERSION$a,
|
|
3150
3516
|
type: "provider",
|
|
3151
3517
|
description: variant.description,
|
|
3152
3518
|
canPlay(src) {
|
|
@@ -3237,6 +3603,9 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
3237
3603
|
};
|
|
3238
3604
|
window.addEventListener("online", onlineListener);
|
|
3239
3605
|
}
|
|
3606
|
+
const unsubPoster = api.subscribeToState((event) => {
|
|
3607
|
+
if (event.key === "poster") applyPoster();
|
|
3608
|
+
});
|
|
3240
3609
|
api.onDestroy(() => {
|
|
3241
3610
|
unsubPlay();
|
|
3242
3611
|
unsubPause();
|
|
@@ -3245,6 +3614,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
3245
3614
|
unsubMute();
|
|
3246
3615
|
unsubRate();
|
|
3247
3616
|
unsubQuality();
|
|
3617
|
+
unsubPoster();
|
|
3248
3618
|
});
|
|
3249
3619
|
},
|
|
3250
3620
|
async destroy() {
|
|
@@ -3270,6 +3640,7 @@ function createHLSPluginWith(loader, variant, config) {
|
|
|
3270
3640
|
hasPlayedContent = false;
|
|
3271
3641
|
cleanup(new Error("HLS load cancelled: superseded by a new load"));
|
|
3272
3642
|
currentSrc = src;
|
|
3643
|
+
applyPoster();
|
|
3273
3644
|
api.setState("playbackState", "loading");
|
|
3274
3645
|
api.setState("buffering", true);
|
|
3275
3646
|
if (api.getState("airplayActive") && loader.supportsNativeHLS()) {
|
|
@@ -3488,138 +3859,653 @@ function createHLSPlugin(config) {
|
|
|
3488
3859
|
config
|
|
3489
3860
|
);
|
|
3490
3861
|
}
|
|
3491
|
-
var
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
}
|
|
3535
|
-
|
|
3536
|
-
.
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
.
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
}
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
.
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3862
|
+
var PKG_VERSION$9 = "1.7.1";
|
|
3863
|
+
var VIDEO_EXTENSIONS = ["mp4", "webm", "mov", "mkv", "ogv", "m4v"];
|
|
3864
|
+
var AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "weba"];
|
|
3865
|
+
var SUPPORTED_EXTENSIONS = [...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS];
|
|
3866
|
+
var MIME_TYPES = {
|
|
3867
|
+
// Video
|
|
3868
|
+
mp4: "video/mp4",
|
|
3869
|
+
m4v: "video/mp4",
|
|
3870
|
+
webm: "video/webm",
|
|
3871
|
+
mov: "video/quicktime",
|
|
3872
|
+
mkv: "video/x-matroska",
|
|
3873
|
+
ogv: "video/ogg",
|
|
3874
|
+
// Audio
|
|
3875
|
+
mp3: "audio/mpeg",
|
|
3876
|
+
wav: "audio/wav",
|
|
3877
|
+
ogg: "audio/ogg",
|
|
3878
|
+
flac: "audio/flac",
|
|
3879
|
+
aac: "audio/aac",
|
|
3880
|
+
m4a: "audio/mp4",
|
|
3881
|
+
opus: "audio/opus",
|
|
3882
|
+
weba: "audio/webm"
|
|
3883
|
+
};
|
|
3884
|
+
function createNativePlugin(config) {
|
|
3885
|
+
const preload = config?.preload ?? "metadata";
|
|
3886
|
+
const load_timeout_ms = config?.loadTimeoutMs ?? 3e4;
|
|
3887
|
+
let api = null;
|
|
3888
|
+
let video = null;
|
|
3889
|
+
let cleanupEvents = null;
|
|
3890
|
+
let derived_title = null;
|
|
3891
|
+
let is_audio_source = false;
|
|
3892
|
+
const getExtension = (src) => {
|
|
3893
|
+
try {
|
|
3894
|
+
const url = new URL(src, window.location.href);
|
|
3895
|
+
const pathname = url.pathname;
|
|
3896
|
+
const ext = pathname.split(".").pop()?.toLowerCase() ?? "";
|
|
3897
|
+
return ext;
|
|
3898
|
+
} catch {
|
|
3899
|
+
const rawExt = src.split(".").pop()?.toLowerCase() ?? "";
|
|
3900
|
+
return rawExt.split("?")[0] ?? "";
|
|
3901
|
+
}
|
|
3902
|
+
};
|
|
3903
|
+
const getMimeType = (ext) => {
|
|
3904
|
+
return MIME_TYPES[ext] || "video/mp4";
|
|
3905
|
+
};
|
|
3906
|
+
const isAudioExtension = (ext) => {
|
|
3907
|
+
return AUDIO_EXTENSIONS.includes(ext);
|
|
3908
|
+
};
|
|
3909
|
+
const canBrowserPlay = (mimeType) => {
|
|
3910
|
+
const isAudio = mimeType.startsWith("audio/");
|
|
3911
|
+
const testElement = isAudio ? document.createElement("audio") : document.createElement("video");
|
|
3912
|
+
const canPlay = testElement.canPlayType(mimeType);
|
|
3913
|
+
return canPlay === "probably" || canPlay === "maybe";
|
|
3914
|
+
};
|
|
3915
|
+
const applyPoster = () => {
|
|
3916
|
+
if (!video) return;
|
|
3917
|
+
if (is_audio_source) {
|
|
3918
|
+
video.poster = "";
|
|
3919
|
+
return;
|
|
3920
|
+
}
|
|
3921
|
+
video.poster = api?.getState("poster") || "";
|
|
3922
|
+
};
|
|
3923
|
+
const getOrCreateVideo = () => {
|
|
3924
|
+
if (video) return video;
|
|
3925
|
+
const existing = api?.container.querySelector("video");
|
|
3926
|
+
if (existing) {
|
|
3927
|
+
video = existing;
|
|
3928
|
+
return video;
|
|
3929
|
+
}
|
|
3930
|
+
video = document.createElement("video");
|
|
3931
|
+
video.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;display:block;object-fit:contain;background:#000";
|
|
3932
|
+
video.preload = preload;
|
|
3933
|
+
video.controls = false;
|
|
3934
|
+
video.playsInline = true;
|
|
3935
|
+
applyPoster();
|
|
3936
|
+
api?.container.appendChild(video);
|
|
3937
|
+
return video;
|
|
3938
|
+
};
|
|
3939
|
+
const setupEventListeners = (videoEl) => {
|
|
3940
|
+
const handlers = [];
|
|
3941
|
+
const on = (event, handler) => {
|
|
3942
|
+
videoEl.addEventListener(event, handler);
|
|
3943
|
+
handlers.push([event, handler]);
|
|
3944
|
+
};
|
|
3945
|
+
const syncEndedFromElement = () => {
|
|
3946
|
+
if (videoEl.ended || !api?.getState("ended")) return;
|
|
3947
|
+
api?.setState("ended", false);
|
|
3948
|
+
api?.setState("playbackState", videoEl.paused ? "paused" : "playing");
|
|
3949
|
+
};
|
|
3950
|
+
on("play", () => {
|
|
3951
|
+
api?.setState("paused", false);
|
|
3952
|
+
syncEndedFromElement();
|
|
3953
|
+
});
|
|
3954
|
+
on("playing", () => {
|
|
3955
|
+
api?.setState("playing", true);
|
|
3956
|
+
api?.setState("paused", false);
|
|
3957
|
+
api?.setState("playbackState", "playing");
|
|
3958
|
+
api?.emit("playback:play", void 0);
|
|
3959
|
+
syncEndedFromElement();
|
|
3960
|
+
});
|
|
3961
|
+
on("pause", () => {
|
|
3962
|
+
api?.setState("playing", false);
|
|
3963
|
+
api?.setState("paused", true);
|
|
3964
|
+
api?.setState("playbackState", "paused");
|
|
3965
|
+
api?.emit("playback:pause", void 0);
|
|
3966
|
+
});
|
|
3967
|
+
on("ended", () => {
|
|
3968
|
+
api?.setState("playing", false);
|
|
3969
|
+
api?.setState("ended", true);
|
|
3970
|
+
api?.setState("playbackState", "ended");
|
|
3971
|
+
api?.emit("playback:ended", void 0);
|
|
3972
|
+
});
|
|
3973
|
+
on("timeupdate", () => {
|
|
3974
|
+
api?.setState("currentTime", videoEl.currentTime);
|
|
3975
|
+
api?.emit("playback:timeupdate", { currentTime: videoEl.currentTime });
|
|
3976
|
+
});
|
|
3977
|
+
on("durationchange", () => {
|
|
3978
|
+
api?.setState("duration", videoEl.duration || 0);
|
|
3979
|
+
});
|
|
3980
|
+
on("loadedmetadata", () => {
|
|
3981
|
+
api?.setState("duration", videoEl.duration || 0);
|
|
3982
|
+
api?.emit("media:loadedmetadata", { duration: videoEl.duration || 0 });
|
|
3983
|
+
});
|
|
3984
|
+
on("canplay", () => {
|
|
3985
|
+
api?.setState("buffering", false);
|
|
3986
|
+
api?.emit("media:canplay", void 0);
|
|
3987
|
+
});
|
|
3988
|
+
on("canplaythrough", () => {
|
|
3989
|
+
api?.emit("media:canplaythrough", void 0);
|
|
3990
|
+
});
|
|
3991
|
+
on("waiting", () => {
|
|
3992
|
+
api?.setState("buffering", true);
|
|
3993
|
+
api?.emit("media:waiting", void 0);
|
|
3994
|
+
});
|
|
3995
|
+
on("progress", () => {
|
|
3996
|
+
if (videoEl.buffered.length > 0) {
|
|
3997
|
+
const bufferedEnd = videoEl.buffered.end(videoEl.buffered.length - 1);
|
|
3998
|
+
const duration = videoEl.duration || 0;
|
|
3999
|
+
const buffered = duration > 0 ? bufferedEnd / duration : 0;
|
|
4000
|
+
api?.setState("bufferedAmount", buffered);
|
|
4001
|
+
api?.emit("media:progress", { buffered });
|
|
4002
|
+
}
|
|
4003
|
+
});
|
|
4004
|
+
on("seeking", () => {
|
|
4005
|
+
api?.setState("seeking", true);
|
|
4006
|
+
syncEndedFromElement();
|
|
4007
|
+
});
|
|
4008
|
+
on("seeked", () => {
|
|
4009
|
+
api?.setState("seeking", false);
|
|
4010
|
+
api?.emit("playback:seeked", { time: videoEl.currentTime });
|
|
4011
|
+
});
|
|
4012
|
+
on("volumechange", () => {
|
|
4013
|
+
api?.setState("volume", videoEl.volume);
|
|
4014
|
+
api?.setState("muted", videoEl.muted);
|
|
4015
|
+
api?.emit("volume:change", { volume: videoEl.volume, muted: videoEl.muted });
|
|
4016
|
+
});
|
|
4017
|
+
on("ratechange", () => {
|
|
4018
|
+
api?.setState("playbackRate", videoEl.playbackRate);
|
|
4019
|
+
api?.emit("playback:ratechange", { rate: videoEl.playbackRate });
|
|
4020
|
+
});
|
|
4021
|
+
on("stalled", () => {
|
|
4022
|
+
api?.setState("buffering", true);
|
|
4023
|
+
api?.emit("media:stalled", void 0);
|
|
4024
|
+
api?.logger.warn("Media stalled - network may be slow");
|
|
4025
|
+
});
|
|
4026
|
+
on("suspend", () => {
|
|
4027
|
+
api?.emit("media:suspend", void 0);
|
|
4028
|
+
});
|
|
4029
|
+
on("abort", () => {
|
|
4030
|
+
api?.emit("media:abort", void 0);
|
|
4031
|
+
});
|
|
4032
|
+
on("error", () => {
|
|
4033
|
+
const error = videoEl.error;
|
|
4034
|
+
let message = "Unknown video error";
|
|
4035
|
+
if (error) {
|
|
4036
|
+
switch (error.code) {
|
|
4037
|
+
case MediaError.MEDIA_ERR_ABORTED:
|
|
4038
|
+
message = "Playback aborted";
|
|
4039
|
+
break;
|
|
4040
|
+
case MediaError.MEDIA_ERR_NETWORK:
|
|
4041
|
+
message = "Network error";
|
|
4042
|
+
break;
|
|
4043
|
+
case MediaError.MEDIA_ERR_DECODE:
|
|
4044
|
+
message = "Decode error - format may not be supported";
|
|
4045
|
+
break;
|
|
4046
|
+
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
|
4047
|
+
message = "Format not supported";
|
|
4048
|
+
break;
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
api?.logger.error("Video error", { code: error?.code, message });
|
|
4052
|
+
api?.emit("error", {
|
|
4053
|
+
code: ErrorCode.PLAYBACK_FAILED,
|
|
4054
|
+
message,
|
|
4055
|
+
fatal: true,
|
|
4056
|
+
timestamp: Date.now()
|
|
4057
|
+
});
|
|
4058
|
+
});
|
|
4059
|
+
on("enterpictureinpicture", () => {
|
|
4060
|
+
api?.setState("pip", true);
|
|
4061
|
+
api?.logger.debug("PiP: entered (standard)");
|
|
4062
|
+
});
|
|
4063
|
+
on("leavepictureinpicture", () => {
|
|
4064
|
+
api?.setState("pip", false);
|
|
4065
|
+
api?.logger.debug("PiP: exited (standard)");
|
|
4066
|
+
if (!videoEl.paused || api?.getState("playing")) {
|
|
4067
|
+
videoEl.play().catch(() => {
|
|
4068
|
+
});
|
|
4069
|
+
}
|
|
4070
|
+
});
|
|
4071
|
+
const webkitVideo = videoEl;
|
|
4072
|
+
if ("webkitPresentationMode" in videoEl) {
|
|
4073
|
+
on("webkitpresentationmodechanged", () => {
|
|
4074
|
+
const mode = webkitVideo.webkitPresentationMode;
|
|
4075
|
+
api?.setState("pip", mode === "picture-in-picture");
|
|
4076
|
+
api?.logger.debug(`PiP: mode changed to ${mode} (webkit)`);
|
|
4077
|
+
if (mode === "inline" && videoEl.paused) {
|
|
4078
|
+
videoEl.play().catch(() => {
|
|
4079
|
+
});
|
|
4080
|
+
}
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
return () => {
|
|
4084
|
+
handlers.forEach(([event, handler]) => {
|
|
4085
|
+
videoEl.removeEventListener(event, handler);
|
|
4086
|
+
});
|
|
4087
|
+
};
|
|
4088
|
+
};
|
|
4089
|
+
const cleanup = () => {
|
|
4090
|
+
cleanupEvents?.();
|
|
4091
|
+
cleanupEvents = null;
|
|
4092
|
+
if (video) {
|
|
4093
|
+
video.pause();
|
|
4094
|
+
video.removeAttribute("src");
|
|
4095
|
+
video.load();
|
|
4096
|
+
}
|
|
4097
|
+
};
|
|
4098
|
+
const plugin = {
|
|
4099
|
+
id: "native-provider",
|
|
4100
|
+
name: "Native Media Provider",
|
|
4101
|
+
version: PKG_VERSION$9,
|
|
4102
|
+
type: "provider",
|
|
4103
|
+
description: "Native HTML5 playback for video (MP4, WebM, MOV) and audio (MP3, WAV, FLAC, AAC)",
|
|
4104
|
+
canPlay(src) {
|
|
4105
|
+
const ext = getExtension(src);
|
|
4106
|
+
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
4107
|
+
return false;
|
|
4108
|
+
}
|
|
4109
|
+
const mimeType = getMimeType(ext);
|
|
4110
|
+
return canBrowserPlay(mimeType);
|
|
4111
|
+
},
|
|
4112
|
+
async init(pluginApi) {
|
|
4113
|
+
api = pluginApi;
|
|
4114
|
+
api.logger.info("Native video plugin initialized");
|
|
4115
|
+
const unsubPlay = api.on("playback:play", async () => {
|
|
4116
|
+
if (!video) return;
|
|
4117
|
+
try {
|
|
4118
|
+
await video.play();
|
|
4119
|
+
} catch (e) {
|
|
4120
|
+
api?.logger.error("Play failed", e);
|
|
4121
|
+
}
|
|
4122
|
+
});
|
|
4123
|
+
const unsubPause = api.on("playback:pause", () => {
|
|
4124
|
+
video?.pause();
|
|
4125
|
+
});
|
|
4126
|
+
const unsubSeek = api.on("playback:seeking", ({ time }) => {
|
|
4127
|
+
if (!video) return;
|
|
4128
|
+
const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
|
|
4129
|
+
video.currentTime = clampedTime;
|
|
4130
|
+
});
|
|
4131
|
+
const unsubVolume = api.on("volume:change", ({ volume, muted }) => {
|
|
4132
|
+
if (video) {
|
|
4133
|
+
video.volume = volume;
|
|
4134
|
+
video.muted = muted;
|
|
4135
|
+
}
|
|
4136
|
+
});
|
|
4137
|
+
const unsubMute = api.on("volume:mute", ({ muted }) => {
|
|
4138
|
+
if (video) video.muted = muted;
|
|
4139
|
+
});
|
|
4140
|
+
const unsubRate = api.on("playback:ratechange", ({ rate }) => {
|
|
4141
|
+
if (video) video.playbackRate = rate;
|
|
4142
|
+
});
|
|
4143
|
+
const unsubPoster = api.subscribeToState((event) => {
|
|
4144
|
+
if (event.key === "poster") applyPoster();
|
|
4145
|
+
});
|
|
4146
|
+
api.onDestroy(() => {
|
|
4147
|
+
unsubPlay();
|
|
4148
|
+
unsubPause();
|
|
4149
|
+
unsubSeek();
|
|
4150
|
+
unsubVolume();
|
|
4151
|
+
unsubMute();
|
|
4152
|
+
unsubRate();
|
|
4153
|
+
unsubPoster();
|
|
4154
|
+
});
|
|
4155
|
+
},
|
|
4156
|
+
async destroy() {
|
|
4157
|
+
api?.logger.info("Native video plugin destroying");
|
|
4158
|
+
cleanup();
|
|
4159
|
+
if (video?.parentNode) {
|
|
4160
|
+
video.parentNode.removeChild(video);
|
|
4161
|
+
}
|
|
4162
|
+
video = null;
|
|
4163
|
+
api = null;
|
|
4164
|
+
derived_title = null;
|
|
4165
|
+
is_audio_source = false;
|
|
4166
|
+
},
|
|
4167
|
+
async loadSource(src) {
|
|
4168
|
+
if (!api) throw new Error("Plugin not initialized");
|
|
4169
|
+
const ext = getExtension(src);
|
|
4170
|
+
const mimeType = getMimeType(ext);
|
|
4171
|
+
const isAudio = isAudioExtension(ext);
|
|
4172
|
+
is_audio_source = isAudio;
|
|
4173
|
+
api.logger.info("Loading native media source", { src, mimeType, isAudio });
|
|
4174
|
+
cleanup();
|
|
4175
|
+
api.setState("playbackState", "loading");
|
|
4176
|
+
api.setState("buffering", true);
|
|
4177
|
+
api.setState("mediaType", isAudio ? "audio" : "video");
|
|
4178
|
+
if (isAudio) {
|
|
4179
|
+
const current_title = api.getState("title");
|
|
4180
|
+
if (!current_title || current_title === derived_title) {
|
|
4181
|
+
try {
|
|
4182
|
+
const url = new URL(src, window.location.href);
|
|
4183
|
+
const filename = url.pathname.split("/").pop() || "Audio";
|
|
4184
|
+
const title = decodeURIComponent(filename.replace(/\.[^.]+$/, "").replace(/[-_]/g, " "));
|
|
4185
|
+
derived_title = title;
|
|
4186
|
+
api.setState("title", title);
|
|
4187
|
+
} catch {
|
|
4188
|
+
derived_title = "Audio";
|
|
4189
|
+
api.setState("title", "Audio");
|
|
4190
|
+
}
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
api.setState("qualities", []);
|
|
4194
|
+
api.setState("currentQuality", null);
|
|
4195
|
+
const videoEl = getOrCreateVideo();
|
|
4196
|
+
videoEl.style.display = isAudio ? "none" : "block";
|
|
4197
|
+
applyPoster();
|
|
4198
|
+
cleanupEvents = setupEventListeners(videoEl);
|
|
4199
|
+
return new Promise((resolve, reject) => {
|
|
4200
|
+
let watchdog = null;
|
|
4201
|
+
const settle = () => {
|
|
4202
|
+
videoEl.removeEventListener("loadedmetadata", onLoaded);
|
|
4203
|
+
videoEl.removeEventListener("error", onError);
|
|
4204
|
+
if (watchdog !== null) {
|
|
4205
|
+
clearTimeout(watchdog);
|
|
4206
|
+
watchdog = null;
|
|
4207
|
+
}
|
|
4208
|
+
};
|
|
4209
|
+
const onLoaded = () => {
|
|
4210
|
+
settle();
|
|
4211
|
+
const muted = api?.getState("muted");
|
|
4212
|
+
const volume = api?.getState("volume");
|
|
4213
|
+
if (muted !== void 0) videoEl.muted = muted;
|
|
4214
|
+
if (volume !== void 0) videoEl.volume = volume;
|
|
4215
|
+
api?.setState("source", { src, type: mimeType });
|
|
4216
|
+
api?.setState("playbackState", "ready");
|
|
4217
|
+
api?.setState("buffering", false);
|
|
4218
|
+
api?.emit("media:loaded", { src, type: mimeType });
|
|
4219
|
+
resolve();
|
|
4220
|
+
};
|
|
4221
|
+
const onError = () => {
|
|
4222
|
+
settle();
|
|
4223
|
+
const error = videoEl.error;
|
|
4224
|
+
reject(new Error(error?.message || "Failed to load video source"));
|
|
4225
|
+
};
|
|
4226
|
+
if (load_timeout_ms > 0) {
|
|
4227
|
+
watchdog = setTimeout(() => {
|
|
4228
|
+
settle();
|
|
4229
|
+
reject(new Error("Video took too long to load (network timeout)"));
|
|
4230
|
+
}, load_timeout_ms);
|
|
4231
|
+
}
|
|
4232
|
+
videoEl.addEventListener("loadedmetadata", onLoaded);
|
|
4233
|
+
videoEl.addEventListener("error", onError);
|
|
4234
|
+
videoEl.src = src;
|
|
4235
|
+
videoEl.load();
|
|
4236
|
+
});
|
|
4237
|
+
}
|
|
4238
|
+
};
|
|
4239
|
+
return plugin;
|
|
4240
|
+
}
|
|
4241
|
+
var 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$2 = `
|
|
4318
|
+
/* ============================================
|
|
4319
|
+
Container & Base
|
|
4320
|
+
============================================ */
|
|
4321
|
+
.sp-container {
|
|
4322
|
+
position: relative;
|
|
4323
|
+
width: 100%;
|
|
4324
|
+
height: 100%;
|
|
4325
|
+
background: #000;
|
|
4326
|
+
overflow: hidden;
|
|
4327
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
.sp-container video {
|
|
4331
|
+
width: 100%;
|
|
4332
|
+
height: 100%;
|
|
4333
|
+
display: block;
|
|
4334
|
+
object-fit: contain;
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
.sp-container:focus {
|
|
4338
|
+
outline: none;
|
|
4339
|
+
}
|
|
4340
|
+
|
|
4341
|
+
/* ============================================
|
|
4342
|
+
Gradient Overlay
|
|
4343
|
+
============================================ */
|
|
4344
|
+
.sp-gradient {
|
|
4345
|
+
position: absolute;
|
|
4346
|
+
bottom: 0;
|
|
4347
|
+
left: 0;
|
|
4348
|
+
right: 0;
|
|
4349
|
+
height: 160px;
|
|
4350
|
+
background: linear-gradient(
|
|
4351
|
+
to top,
|
|
4352
|
+
rgba(0, 0, 0, 0.8) 0%,
|
|
4353
|
+
rgba(0, 0, 0, 0.4) 50%,
|
|
4354
|
+
transparent 100%
|
|
4355
|
+
);
|
|
4356
|
+
pointer-events: none;
|
|
4357
|
+
opacity: 0;
|
|
4358
|
+
transition: opacity 0.25s ease;
|
|
4359
|
+
z-index: 5;
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
.sp-gradient--visible {
|
|
4363
|
+
opacity: 1;
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
/* ============================================
|
|
4367
|
+
Controls Container
|
|
4368
|
+
============================================ */
|
|
4369
|
+
.sp-controls {
|
|
4370
|
+
position: absolute;
|
|
4371
|
+
bottom: 0;
|
|
4372
|
+
left: 0;
|
|
4373
|
+
right: 0;
|
|
4374
|
+
display: flex;
|
|
4375
|
+
align-items: center;
|
|
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));
|
|
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;
|
|
4388
|
+
opacity: 0;
|
|
4389
|
+
transform: translateY(4px);
|
|
4390
|
+
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
4391
|
+
z-index: 10;
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
.sp-controls--visible {
|
|
4395
|
+
opacity: 1;
|
|
4396
|
+
transform: translateY(0);
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
.sp-controls--hidden {
|
|
4400
|
+
opacity: 0;
|
|
4401
|
+
transform: translateY(4px);
|
|
4402
|
+
pointer-events: none;
|
|
4403
|
+
}
|
|
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
|
+
|
|
4430
|
+
/* ============================================
|
|
4431
|
+
Progress Bar (Above Controls)
|
|
4432
|
+
============================================ */
|
|
4433
|
+
.sp-progress-wrapper {
|
|
4434
|
+
position: absolute;
|
|
4435
|
+
bottom: calc(48px + var(--sp-inset-bottom, 0px));
|
|
4436
|
+
left: 12px;
|
|
4437
|
+
right: 12px;
|
|
4438
|
+
height: 20px;
|
|
4439
|
+
display: flex;
|
|
4440
|
+
align-items: center;
|
|
4441
|
+
cursor: pointer;
|
|
4442
|
+
z-index: 10;
|
|
4443
|
+
opacity: 0;
|
|
4444
|
+
transition: opacity 0.25s ease;
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
.sp-progress-wrapper--visible {
|
|
4448
|
+
opacity: 1;
|
|
4449
|
+
}
|
|
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
|
+
|
|
4476
|
+
.sp-progress {
|
|
4477
|
+
position: relative;
|
|
4478
|
+
width: 100%;
|
|
4479
|
+
height: 3px;
|
|
4480
|
+
background: rgba(255, 255, 255, 0.3);
|
|
4481
|
+
border-radius: 1.5px;
|
|
4482
|
+
transition: height 0.15s ease;
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
@media (hover: hover) {
|
|
4486
|
+
.sp-progress-wrapper:hover .sp-progress {
|
|
4487
|
+
height: 5px;
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
.sp-progress--dragging {
|
|
4492
|
+
height: 5px;
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
.sp-progress__track {
|
|
4496
|
+
position: absolute;
|
|
4497
|
+
top: 0;
|
|
4498
|
+
left: 0;
|
|
4499
|
+
right: 0;
|
|
4500
|
+
bottom: 0;
|
|
4501
|
+
border-radius: inherit;
|
|
4502
|
+
overflow: hidden;
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
.sp-progress__buffered {
|
|
4506
|
+
position: absolute;
|
|
4507
|
+
top: 0;
|
|
4508
|
+
left: 0;
|
|
3623
4509
|
height: 100%;
|
|
3624
4510
|
background: rgba(255, 255, 255, 0.4);
|
|
3625
4511
|
border-radius: inherit;
|
|
@@ -3794,6 +4680,61 @@ var styles$1 = `
|
|
|
3794
4680
|
min-width: 0;
|
|
3795
4681
|
}
|
|
3796
4682
|
|
|
4683
|
+
/* ============================================
|
|
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.
|
|
4695
|
+
============================================ */
|
|
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
|
+
|
|
3797
4738
|
/* ============================================
|
|
3798
4739
|
Time Display
|
|
3799
4740
|
============================================ */
|
|
@@ -3821,14 +4762,18 @@ var styles$1 = `
|
|
|
3821
4762
|
transition: width 0.2s ease;
|
|
3822
4763
|
}
|
|
3823
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. */
|
|
3824
4769
|
@media (hover: hover) {
|
|
3825
4770
|
.sp-volume:hover .sp-volume__slider-wrap {
|
|
3826
|
-
width:
|
|
4771
|
+
width: var(--sp-volume-slider-width);
|
|
3827
4772
|
}
|
|
3828
4773
|
}
|
|
3829
4774
|
|
|
3830
4775
|
.sp-volume:focus-within .sp-volume__slider-wrap {
|
|
3831
|
-
width:
|
|
4776
|
+
width: var(--sp-volume-slider-width);
|
|
3832
4777
|
}
|
|
3833
4778
|
|
|
3834
4779
|
.sp-volume__slider {
|
|
@@ -3929,6 +4874,14 @@ var styles$1 = `
|
|
|
3929
4874
|
position: absolute;
|
|
3930
4875
|
bottom: calc(100% + 8px);
|
|
3931
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;
|
|
3932
4885
|
background: rgba(20, 20, 20, 0.95);
|
|
3933
4886
|
backdrop-filter: blur(8px);
|
|
3934
4887
|
-webkit-backdrop-filter: blur(8px);
|
|
@@ -3997,6 +4950,29 @@ var styles$1 = `
|
|
|
3997
4950
|
position: absolute;
|
|
3998
4951
|
bottom: calc(100% + 8px);
|
|
3999
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;
|
|
4000
4976
|
background: rgba(20, 20, 20, 0.95);
|
|
4001
4977
|
backdrop-filter: blur(8px);
|
|
4002
4978
|
-webkit-backdrop-filter: blur(8px);
|
|
@@ -4008,7 +4984,6 @@ var styles$1 = `
|
|
|
4008
4984
|
transform: translateY(8px);
|
|
4009
4985
|
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
|
4010
4986
|
z-index: 20;
|
|
4011
|
-
overflow: hidden;
|
|
4012
4987
|
}
|
|
4013
4988
|
|
|
4014
4989
|
.sp-settings-panel--open {
|
|
@@ -4155,6 +5130,70 @@ var styles$1 = `
|
|
|
4155
5130
|
opacity: 0.4;
|
|
4156
5131
|
}
|
|
4157
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
|
+
|
|
4158
5197
|
/* ============================================
|
|
4159
5198
|
Error Overlay
|
|
4160
5199
|
============================================ */
|
|
@@ -4329,17 +5368,24 @@ var styles$1 = `
|
|
|
4329
5368
|
.sp-control,
|
|
4330
5369
|
.sp-volume__slider-wrap,
|
|
4331
5370
|
.sp-quality-menu,
|
|
5371
|
+
.sp-overflow-tray,
|
|
4332
5372
|
.sp-settings-panel,
|
|
4333
5373
|
.sp-settings-panel__row,
|
|
4334
5374
|
.sp-settings-panel__item,
|
|
4335
5375
|
.sp-settings-panel__header,
|
|
4336
5376
|
.sp-buffering,
|
|
5377
|
+
.sp-big-play,
|
|
4337
5378
|
.sp-error-overlay,
|
|
4338
5379
|
.sp-error-overlay__retry,
|
|
4339
5380
|
.sp-error-overlay__dismiss {
|
|
4340
5381
|
transition: none;
|
|
4341
5382
|
}
|
|
4342
5383
|
|
|
5384
|
+
.sp-big-play:hover,
|
|
5385
|
+
.sp-big-play:active {
|
|
5386
|
+
transform: translate(-50%, -50%);
|
|
5387
|
+
}
|
|
5388
|
+
|
|
4343
5389
|
.sp-live__dot,
|
|
4344
5390
|
.sp-spin {
|
|
4345
5391
|
animation: none;
|
|
@@ -4376,6 +5422,8 @@ var icons = {
|
|
|
4376
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>`,
|
|
4377
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>`,
|
|
4378
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>`,
|
|
4379
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>`,
|
|
4380
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>`,
|
|
4381
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>`,
|
|
@@ -4492,6 +5540,102 @@ var PlayButton = class {
|
|
|
4492
5540
|
this.el.remove();
|
|
4493
5541
|
}
|
|
4494
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.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.replay : icons.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
|
+
};
|
|
4495
5639
|
var ThumbnailPreview = class {
|
|
4496
5640
|
constructor() {
|
|
4497
5641
|
this.config = null;
|
|
@@ -5422,16 +6566,22 @@ var FullscreenButton = class {
|
|
|
5422
6566
|
setAttr(this.el, "aria-label", "Fullscreen");
|
|
5423
6567
|
}
|
|
5424
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
|
+
*/
|
|
5425
6578
|
async toggle() {
|
|
5426
6579
|
const container = this.api.container;
|
|
5427
|
-
const video = getVideo(container);
|
|
5428
6580
|
try {
|
|
5429
|
-
if (
|
|
5430
|
-
await
|
|
5431
|
-
} else
|
|
5432
|
-
await container
|
|
5433
|
-
} else if (video?.webkitEnterFullscreen) {
|
|
5434
|
-
video.webkitEnterFullscreen();
|
|
6581
|
+
if (isFullscreen(container)) {
|
|
6582
|
+
await exitFullscreen(container);
|
|
6583
|
+
} else {
|
|
6584
|
+
await enterFullscreen(container);
|
|
5435
6585
|
}
|
|
5436
6586
|
} catch {
|
|
5437
6587
|
}
|
|
@@ -6109,21 +7259,168 @@ var BandwidthIndicator = class {
|
|
|
6109
7259
|
return this.el;
|
|
6110
7260
|
}
|
|
6111
7261
|
update() {
|
|
6112
|
-
const bandwidth = this.api.getState("bandwidth");
|
|
6113
|
-
const qualities = this.api.getState("qualities");
|
|
6114
|
-
if (!bandwidth || !qualities || qualities.length === 0) {
|
|
6115
|
-
this.el.style.display = "none";
|
|
6116
|
-
return;
|
|
7262
|
+
const bandwidth = this.api.getState("bandwidth");
|
|
7263
|
+
const qualities = this.api.getState("qualities");
|
|
7264
|
+
if (!bandwidth || !qualities || qualities.length === 0) {
|
|
7265
|
+
this.el.style.display = "none";
|
|
7266
|
+
return;
|
|
7267
|
+
}
|
|
7268
|
+
const highestBitrate = Math.max(...qualities.map((q) => q.bitrate));
|
|
7269
|
+
if (highestBitrate > 0 && bandwidth < highestBitrate) {
|
|
7270
|
+
this.el.style.display = "";
|
|
7271
|
+
} else {
|
|
7272
|
+
this.el.style.display = "none";
|
|
7273
|
+
}
|
|
7274
|
+
}
|
|
7275
|
+
destroy() {
|
|
7276
|
+
this.el.remove();
|
|
7277
|
+
}
|
|
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.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);
|
|
6117
7355
|
}
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
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();
|
|
6123
7403
|
}
|
|
6124
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
|
+
*/
|
|
6125
7418
|
destroy() {
|
|
7419
|
+
document.removeEventListener("click", this.closeHandler);
|
|
7420
|
+
document.removeEventListener("keydown", this.keyHandler);
|
|
7421
|
+
this.btn.removeEventListener("click", this.toggleHandler);
|
|
6126
7422
|
this.el.remove();
|
|
7423
|
+
this.api.logger.debug("Overflow tray destroyed");
|
|
6127
7424
|
}
|
|
6128
7425
|
};
|
|
6129
7426
|
var registry = /* @__PURE__ */ new Map();
|
|
@@ -6143,6 +7440,7 @@ function onControlRegistered(listener) {
|
|
|
6143
7440
|
listeners.delete(listener);
|
|
6144
7441
|
};
|
|
6145
7442
|
}
|
|
7443
|
+
var PKG_VERSION$8 = "1.7.1";
|
|
6146
7444
|
var DEFAULT_LAYOUT = [
|
|
6147
7445
|
"play",
|
|
6148
7446
|
"skip-backward",
|
|
@@ -6160,6 +7458,14 @@ var DEFAULT_LAYOUT = [
|
|
|
6160
7458
|
"fullscreen"
|
|
6161
7459
|
];
|
|
6162
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;
|
|
6163
7469
|
function uiPlugin(config = {}) {
|
|
6164
7470
|
let api;
|
|
6165
7471
|
let controlBar = null;
|
|
@@ -6167,6 +7473,7 @@ function uiPlugin(config = {}) {
|
|
|
6167
7473
|
let progressBar = null;
|
|
6168
7474
|
let bufferingIndicator = null;
|
|
6169
7475
|
let errorOverlay = null;
|
|
7476
|
+
let bigPlayButton = null;
|
|
6170
7477
|
let styleEl = null;
|
|
6171
7478
|
let controls = [];
|
|
6172
7479
|
let hideTimeout = null;
|
|
@@ -6177,8 +7484,22 @@ function uiPlugin(config = {}) {
|
|
|
6177
7484
|
let recoveredUnsubscribe = null;
|
|
6178
7485
|
let controlsVisible = true;
|
|
6179
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;
|
|
6180
7496
|
const layout = config.controls || DEFAULT_LAYOUT;
|
|
6181
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
|
+
}
|
|
6182
7503
|
const createControl = (slot) => {
|
|
6183
7504
|
switch (slot) {
|
|
6184
7505
|
case "play":
|
|
@@ -6232,13 +7553,179 @@ function uiPlugin(config = {}) {
|
|
|
6232
7553
|
if (!controlBar) {
|
|
6233
7554
|
return;
|
|
6234
7555
|
}
|
|
7556
|
+
const rules = new Map(
|
|
7557
|
+
resolveFitItems(layout, config.priority).map((template) => [template.id, template])
|
|
7558
|
+
);
|
|
6235
7559
|
for (const slot of layout) {
|
|
6236
7560
|
const control = createControl(slot);
|
|
6237
|
-
if (control) {
|
|
6238
|
-
|
|
6239
|
-
|
|
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);
|
|
6240
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;
|
|
7652
|
+
}
|
|
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;
|
|
6241
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
|
+
);
|
|
6242
7729
|
};
|
|
6243
7730
|
const rebuildControlBar = () => {
|
|
6244
7731
|
if (!controlBar) {
|
|
@@ -6246,7 +7733,12 @@ function uiPlugin(config = {}) {
|
|
|
6246
7733
|
}
|
|
6247
7734
|
controls.forEach((c) => c.destroy());
|
|
6248
7735
|
controls = [];
|
|
7736
|
+
entries = [];
|
|
7737
|
+
timeEntry = null;
|
|
7738
|
+
tray = null;
|
|
6249
7739
|
controlBar.replaceChildren();
|
|
7740
|
+
lastFitSignature = null;
|
|
7741
|
+
fitPending = true;
|
|
6250
7742
|
populateControlBar();
|
|
6251
7743
|
updateControls();
|
|
6252
7744
|
};
|
|
@@ -6260,6 +7752,8 @@ function uiPlugin(config = {}) {
|
|
|
6260
7752
|
const showSpinner = waiting || seeking && !api?.getState("paused") || isLoading;
|
|
6261
7753
|
bufferingIndicator?.classList.toggle("sp-buffering--visible", !!showSpinner);
|
|
6262
7754
|
errorOverlay?.update();
|
|
7755
|
+
bigPlayButton?.update();
|
|
7756
|
+
maybeFit();
|
|
6263
7757
|
};
|
|
6264
7758
|
const scheduleUpdate = () => {
|
|
6265
7759
|
if (rafHandle !== null) return;
|
|
@@ -6338,11 +7832,11 @@ function uiPlugin(config = {}) {
|
|
|
6338
7832
|
break;
|
|
6339
7833
|
case "f":
|
|
6340
7834
|
e.preventDefault();
|
|
6341
|
-
if (
|
|
6342
|
-
|
|
7835
|
+
if (isFullscreen(api.container)) {
|
|
7836
|
+
exitFullscreen(api.container).catch(() => {
|
|
6343
7837
|
});
|
|
6344
7838
|
} else {
|
|
6345
|
-
api.container
|
|
7839
|
+
enterFullscreen(api.container).catch(() => {
|
|
6346
7840
|
});
|
|
6347
7841
|
}
|
|
6348
7842
|
break;
|
|
@@ -6380,11 +7874,11 @@ function uiPlugin(config = {}) {
|
|
|
6380
7874
|
id: "ui-controls",
|
|
6381
7875
|
name: "UI Controls",
|
|
6382
7876
|
type: "ui",
|
|
6383
|
-
version:
|
|
7877
|
+
version: PKG_VERSION$8,
|
|
6384
7878
|
async init(pluginApi) {
|
|
6385
7879
|
api = pluginApi;
|
|
6386
7880
|
styleEl = document.createElement("style");
|
|
6387
|
-
styleEl.textContent = styles$
|
|
7881
|
+
styleEl.textContent = styles$2;
|
|
6388
7882
|
document.head.appendChild(styleEl);
|
|
6389
7883
|
if (config.theme) {
|
|
6390
7884
|
this.setTheme(config.theme);
|
|
@@ -6421,6 +7915,10 @@ function uiPlugin(config = {}) {
|
|
|
6421
7915
|
recoveredUnsubscribe = api.on("error:recovered", () => {
|
|
6422
7916
|
errorOverlay?.hide();
|
|
6423
7917
|
});
|
|
7918
|
+
if (showBigPlayButton) {
|
|
7919
|
+
bigPlayButton = new BigPlayButton(api, () => errorOverlay?.isVisible() ?? false);
|
|
7920
|
+
container.appendChild(bigPlayButton.render());
|
|
7921
|
+
}
|
|
6424
7922
|
progressBar = new ProgressBar(api);
|
|
6425
7923
|
container.appendChild(progressBar.render());
|
|
6426
7924
|
if (!isPlaying) {
|
|
@@ -6432,6 +7930,14 @@ function uiPlugin(config = {}) {
|
|
|
6432
7930
|
controlBar.setAttribute("aria-label", "Video controls");
|
|
6433
7931
|
populateControlBar();
|
|
6434
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
|
+
}
|
|
6435
7941
|
controlRegistryUnsubscribe = onControlRegistered((id) => {
|
|
6436
7942
|
if (!layout.includes(id)) {
|
|
6437
7943
|
return;
|
|
@@ -6448,7 +7954,6 @@ function uiPlugin(config = {}) {
|
|
|
6448
7954
|
container.addEventListener("click", handleInteraction);
|
|
6449
7955
|
document.addEventListener("keydown", handleKeyDown);
|
|
6450
7956
|
stateUnsubscribe = api.subscribeToState(scheduleUpdate);
|
|
6451
|
-
document.addEventListener("fullscreenchange", scheduleUpdate);
|
|
6452
7957
|
updateControls();
|
|
6453
7958
|
if (!container.hasAttribute("tabindex")) {
|
|
6454
7959
|
container.setAttribute("tabindex", "0");
|
|
@@ -6469,6 +7974,9 @@ function uiPlugin(config = {}) {
|
|
|
6469
7974
|
cancelAnimationFrame(rafHandle);
|
|
6470
7975
|
rafHandle = null;
|
|
6471
7976
|
}
|
|
7977
|
+
resizeObserver?.disconnect();
|
|
7978
|
+
resizeObserver = null;
|
|
7979
|
+
api?.container?.style.removeProperty("--sp-menu-max-height");
|
|
6472
7980
|
stateUnsubscribe?.();
|
|
6473
7981
|
stateUnsubscribe = null;
|
|
6474
7982
|
errorUnsubscribe?.();
|
|
@@ -6487,15 +7995,19 @@ function uiPlugin(config = {}) {
|
|
|
6487
7995
|
api.container.removeEventListener("click", handleInteraction);
|
|
6488
7996
|
}
|
|
6489
7997
|
document.removeEventListener("keydown", handleKeyDown);
|
|
6490
|
-
document.removeEventListener("fullscreenchange", scheduleUpdate);
|
|
6491
7998
|
controlRegistryUnsubscribe?.();
|
|
6492
7999
|
controlRegistryUnsubscribe = null;
|
|
6493
8000
|
controls.forEach((c) => c.destroy());
|
|
6494
8001
|
controls = [];
|
|
8002
|
+
entries = [];
|
|
8003
|
+
timeEntry = null;
|
|
8004
|
+
tray = null;
|
|
6495
8005
|
progressBar?.destroy();
|
|
6496
8006
|
progressBar = null;
|
|
6497
8007
|
errorOverlay?.destroy();
|
|
6498
8008
|
errorOverlay = null;
|
|
8009
|
+
bigPlayButton?.destroy();
|
|
8010
|
+
bigPlayButton = null;
|
|
6499
8011
|
controlBar?.remove();
|
|
6500
8012
|
controlBar = null;
|
|
6501
8013
|
gradient?.remove();
|
|
@@ -6543,14 +8055,19 @@ function uiPlugin(config = {}) {
|
|
|
6543
8055
|
}
|
|
6544
8056
|
const index = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
6545
8057
|
__proto__: null,
|
|
8058
|
+
DEFAULT_PRIORITY,
|
|
8059
|
+
assertFitLayout,
|
|
6546
8060
|
formatLiveTime,
|
|
6547
8061
|
formatTime: formatTime$1,
|
|
6548
8062
|
getControlFactory,
|
|
6549
8063
|
icons,
|
|
8064
|
+
planFit,
|
|
6550
8065
|
registerControl,
|
|
6551
|
-
|
|
8066
|
+
resolveFitItems,
|
|
8067
|
+
styles: styles$2,
|
|
6552
8068
|
uiPlugin
|
|
6553
8069
|
}, Symbol.toStringTag, { value: "Module" }));
|
|
8070
|
+
var PKG_VERSION$7 = "1.7.1";
|
|
6554
8071
|
var DEFAULT_THEME = {
|
|
6555
8072
|
primary: "#6366f1",
|
|
6556
8073
|
background: "#18181b",
|
|
@@ -7177,7 +8694,7 @@ function createAudioUIPlugin(config) {
|
|
|
7177
8694
|
const plugin = {
|
|
7178
8695
|
id: "audio-ui",
|
|
7179
8696
|
name: "Audio UI",
|
|
7180
|
-
version:
|
|
8697
|
+
version: PKG_VERSION$7,
|
|
7181
8698
|
type: "ui",
|
|
7182
8699
|
description: "Compact audio player interface",
|
|
7183
8700
|
async init(pluginApi) {
|
|
@@ -7482,7 +8999,8 @@ function safeStringify(data) {
|
|
|
7482
8999
|
return "{}";
|
|
7483
9000
|
}
|
|
7484
9001
|
}
|
|
7485
|
-
var
|
|
9002
|
+
var PKG_VERSION$6 = "1.7.1";
|
|
9003
|
+
var PLUGIN_VERSION = PKG_VERSION$6;
|
|
7486
9004
|
var PLUGIN_NAME = "scarlett-player";
|
|
7487
9005
|
var DEFAULT_CONFIG$2 = {
|
|
7488
9006
|
heartbeatInterval: 1e4,
|
|
@@ -8020,8 +9538,8 @@ var PlaylistPanel = class {
|
|
|
8020
9538
|
});
|
|
8021
9539
|
}
|
|
8022
9540
|
};
|
|
8023
|
-
var STYLE_ID = "sp-playlist-styles";
|
|
8024
|
-
var styles = `
|
|
9541
|
+
var STYLE_ID$1 = "sp-playlist-styles";
|
|
9542
|
+
var styles$1 = `
|
|
8025
9543
|
.sp-playlist-skip[disabled] {
|
|
8026
9544
|
opacity: 0.4;
|
|
8027
9545
|
cursor: default;
|
|
@@ -8121,15 +9639,16 @@ var styles = `
|
|
|
8121
9639
|
}
|
|
8122
9640
|
`;
|
|
8123
9641
|
function injectStyles() {
|
|
8124
|
-
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
|
|
9642
|
+
if (typeof document === "undefined" || document.getElementById(STYLE_ID$1)) {
|
|
8125
9643
|
return null;
|
|
8126
9644
|
}
|
|
8127
9645
|
const el = document.createElement("style");
|
|
8128
|
-
el.id = STYLE_ID;
|
|
8129
|
-
el.textContent = styles;
|
|
9646
|
+
el.id = STYLE_ID$1;
|
|
9647
|
+
el.textContent = styles$1;
|
|
8130
9648
|
document.head.appendChild(el);
|
|
8131
9649
|
return el;
|
|
8132
9650
|
}
|
|
9651
|
+
var PKG_VERSION$5 = "1.7.1";
|
|
8133
9652
|
var DEFAULT_CONFIG$1 = {
|
|
8134
9653
|
autoAdvance: true,
|
|
8135
9654
|
preloadNext: true,
|
|
@@ -8278,9 +9797,7 @@ function createPlaylistPlugin(config) {
|
|
|
8278
9797
|
currentIndex = index2;
|
|
8279
9798
|
api?.logger.info("Track changed", { index: index2, title: track.title, src: track.src });
|
|
8280
9799
|
api?.setState("title", track.title || "");
|
|
8281
|
-
|
|
8282
|
-
api?.setState("poster", track.artwork);
|
|
8283
|
-
}
|
|
9800
|
+
api?.setState("poster", track.artwork || "");
|
|
8284
9801
|
api?.setState("mediaType", track.type || "audio");
|
|
8285
9802
|
emitChange();
|
|
8286
9803
|
if (mergedConfig.autoLoad !== false && track.src) {
|
|
@@ -8290,7 +9807,7 @@ function createPlaylistPlugin(config) {
|
|
|
8290
9807
|
const plugin = {
|
|
8291
9808
|
id: "playlist",
|
|
8292
9809
|
name: "Playlist",
|
|
8293
|
-
version:
|
|
9810
|
+
version: PKG_VERSION$5,
|
|
8294
9811
|
type: "feature",
|
|
8295
9812
|
description: "Playlist management with shuffle, repeat, and gapless playback",
|
|
8296
9813
|
async init(pluginApi) {
|
|
@@ -8546,6 +10063,7 @@ function createPlaylistPlugin(config) {
|
|
|
8546
10063
|
};
|
|
8547
10064
|
return plugin;
|
|
8548
10065
|
}
|
|
10066
|
+
var PKG_VERSION$4 = "1.7.1";
|
|
8549
10067
|
var DEFAULT_CONFIG = {
|
|
8550
10068
|
enablePlayPause: true,
|
|
8551
10069
|
enableSeek: true,
|
|
@@ -8693,7 +10211,7 @@ function createMediaSessionPlugin(config) {
|
|
|
8693
10211
|
const plugin = {
|
|
8694
10212
|
id: "media-session",
|
|
8695
10213
|
name: "Media Session",
|
|
8696
|
-
version:
|
|
10214
|
+
version: PKG_VERSION$4,
|
|
8697
10215
|
type: "feature",
|
|
8698
10216
|
description: "Media Session API integration for system-level media controls",
|
|
8699
10217
|
async init(pluginApi) {
|
|
@@ -8804,6 +10322,7 @@ function createMediaSessionPlugin(config) {
|
|
|
8804
10322
|
};
|
|
8805
10323
|
return plugin;
|
|
8806
10324
|
}
|
|
10325
|
+
var PKG_VERSION$3 = "1.7.1";
|
|
8807
10326
|
var POSITIONS = ["top-left", "top-right", "bottom-left", "bottom-right", "center"];
|
|
8808
10327
|
function getPositionStyles(padding, bottomPadding) {
|
|
8809
10328
|
return {
|
|
@@ -8913,7 +10432,7 @@ function createWatermarkPlugin(config = {}) {
|
|
|
8913
10432
|
return {
|
|
8914
10433
|
id: "watermark",
|
|
8915
10434
|
name: "Watermark",
|
|
8916
|
-
version:
|
|
10435
|
+
version: PKG_VERSION$3,
|
|
8917
10436
|
type: "feature",
|
|
8918
10437
|
description: "Anti-piracy watermark overlay with text/image support and dynamic repositioning",
|
|
8919
10438
|
init(pluginApi) {
|
|
@@ -8998,6 +10517,7 @@ function createWatermarkPlugin(config = {}) {
|
|
|
8998
10517
|
}
|
|
8999
10518
|
};
|
|
9000
10519
|
}
|
|
10520
|
+
var PKG_VERSION$2 = "1.7.1";
|
|
9001
10521
|
var HLS_SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated";
|
|
9002
10522
|
var HLS_INSTANCE_RETRY_MS = 500;
|
|
9003
10523
|
function createCaptionsPlugin(config = {}) {
|
|
@@ -9168,7 +10688,7 @@ function createCaptionsPlugin(config = {}) {
|
|
|
9168
10688
|
return {
|
|
9169
10689
|
id: "captions",
|
|
9170
10690
|
name: "Captions",
|
|
9171
|
-
version:
|
|
10691
|
+
version: PKG_VERSION$2,
|
|
9172
10692
|
type: "feature",
|
|
9173
10693
|
description: "WebVTT subtitles and closed captions with HLS extraction",
|
|
9174
10694
|
init(pluginApi) {
|
|
@@ -9217,6 +10737,493 @@ function createCaptionsPlugin(config = {}) {
|
|
|
9217
10737
|
}
|
|
9218
10738
|
};
|
|
9219
10739
|
}
|
|
10740
|
+
var ZONE_HYSTERESIS = 0.05;
|
|
10741
|
+
var DEFAULT_RECOGNIZER_OPTIONS = {
|
|
10742
|
+
doubleTapWindowMs: 275,
|
|
10743
|
+
accumulationWindowMs: 650,
|
|
10744
|
+
leftZone: 0.33,
|
|
10745
|
+
rightZone: 0.33,
|
|
10746
|
+
slopPx: 10
|
|
10747
|
+
};
|
|
10748
|
+
function zoneFor(fraction, options) {
|
|
10749
|
+
if (fraction <= options.leftZone) return "left";
|
|
10750
|
+
if (fraction >= 1 - options.rightZone) return "right";
|
|
10751
|
+
return "middle";
|
|
10752
|
+
}
|
|
10753
|
+
function createRecognizer(options = {}) {
|
|
10754
|
+
const config = { ...DEFAULT_RECOGNIZER_OPTIONS, ...options };
|
|
10755
|
+
let pending = null;
|
|
10756
|
+
let lastTapZone = null;
|
|
10757
|
+
let lastTapAt = 0;
|
|
10758
|
+
let accumulatingZone = null;
|
|
10759
|
+
let accumulatedCount = 0;
|
|
10760
|
+
let lastAccumulateAt = 0;
|
|
10761
|
+
let activePointers = 0;
|
|
10762
|
+
const resetSequence = () => {
|
|
10763
|
+
lastTapZone = null;
|
|
10764
|
+
lastTapAt = 0;
|
|
10765
|
+
accumulatingZone = null;
|
|
10766
|
+
accumulatedCount = 0;
|
|
10767
|
+
lastAccumulateAt = 0;
|
|
10768
|
+
};
|
|
10769
|
+
const resolveZone = (fraction) => {
|
|
10770
|
+
const raw = zoneFor(fraction, config);
|
|
10771
|
+
const active = accumulatingZone ?? lastTapZone;
|
|
10772
|
+
if (!active || raw === active) return raw;
|
|
10773
|
+
if (active === "left" && fraction <= config.leftZone + ZONE_HYSTERESIS) return "left";
|
|
10774
|
+
if (active === "right" && fraction >= 1 - config.rightZone - ZONE_HYSTERESIS) return "right";
|
|
10775
|
+
return raw;
|
|
10776
|
+
};
|
|
10777
|
+
const expire = (now) => {
|
|
10778
|
+
if (accumulatingZone && now - lastAccumulateAt > config.accumulationWindowMs) {
|
|
10779
|
+
resetSequence();
|
|
10780
|
+
} else if (!accumulatingZone && lastTapZone && now - lastTapAt > config.doubleTapWindowMs) {
|
|
10781
|
+
lastTapZone = null;
|
|
10782
|
+
lastTapAt = 0;
|
|
10783
|
+
}
|
|
10784
|
+
return [];
|
|
10785
|
+
};
|
|
10786
|
+
return {
|
|
10787
|
+
handle(record) {
|
|
10788
|
+
const events = [];
|
|
10789
|
+
switch (record.type) {
|
|
10790
|
+
case "down": {
|
|
10791
|
+
activePointers += 1;
|
|
10792
|
+
if (activePointers > 1) {
|
|
10793
|
+
if (pending) pending.invalid = true;
|
|
10794
|
+
if (accumulatingZone || lastTapZone) {
|
|
10795
|
+
resetSequence();
|
|
10796
|
+
events.push({ type: "cancel" });
|
|
10797
|
+
}
|
|
10798
|
+
return events;
|
|
10799
|
+
}
|
|
10800
|
+
expire(record.timeStamp);
|
|
10801
|
+
pending = {
|
|
10802
|
+
pointerId: record.pointerId,
|
|
10803
|
+
x: record.x,
|
|
10804
|
+
y: record.y,
|
|
10805
|
+
fraction: record.fraction,
|
|
10806
|
+
timeStamp: record.timeStamp,
|
|
10807
|
+
invalid: false
|
|
10808
|
+
};
|
|
10809
|
+
return events;
|
|
10810
|
+
}
|
|
10811
|
+
case "move": {
|
|
10812
|
+
if (!pending || pending.pointerId !== record.pointerId || pending.invalid) {
|
|
10813
|
+
return events;
|
|
10814
|
+
}
|
|
10815
|
+
const dx = record.x - pending.x;
|
|
10816
|
+
const dy = record.y - pending.y;
|
|
10817
|
+
if (Math.hypot(dx, dy) > config.slopPx) {
|
|
10818
|
+
pending.invalid = true;
|
|
10819
|
+
if (accumulatingZone || lastTapZone) {
|
|
10820
|
+
resetSequence();
|
|
10821
|
+
events.push({ type: "cancel" });
|
|
10822
|
+
}
|
|
10823
|
+
}
|
|
10824
|
+
return events;
|
|
10825
|
+
}
|
|
10826
|
+
case "up": {
|
|
10827
|
+
activePointers = Math.max(0, activePointers - 1);
|
|
10828
|
+
const candidate = pending;
|
|
10829
|
+
pending = null;
|
|
10830
|
+
if (!candidate || candidate.pointerId !== record.pointerId || candidate.invalid) {
|
|
10831
|
+
return events;
|
|
10832
|
+
}
|
|
10833
|
+
expire(record.timeStamp);
|
|
10834
|
+
const zone = resolveZone(record.fraction);
|
|
10835
|
+
if (accumulatingZone) {
|
|
10836
|
+
if (zone === accumulatingZone) {
|
|
10837
|
+
accumulatedCount += 1;
|
|
10838
|
+
lastAccumulateAt = record.timeStamp;
|
|
10839
|
+
events.push({ type: "accumulate", zone, count: accumulatedCount });
|
|
10840
|
+
} else {
|
|
10841
|
+
resetSequence();
|
|
10842
|
+
events.push({ type: "cancel" });
|
|
10843
|
+
}
|
|
10844
|
+
return events;
|
|
10845
|
+
}
|
|
10846
|
+
if (lastTapZone && zone === lastTapZone && record.timeStamp - lastTapAt <= config.doubleTapWindowMs) {
|
|
10847
|
+
accumulatingZone = zone;
|
|
10848
|
+
accumulatedCount = 1;
|
|
10849
|
+
lastAccumulateAt = record.timeStamp;
|
|
10850
|
+
lastTapZone = null;
|
|
10851
|
+
lastTapAt = 0;
|
|
10852
|
+
events.push({ type: "double-tap", zone, count: 1 });
|
|
10853
|
+
return events;
|
|
10854
|
+
}
|
|
10855
|
+
lastTapZone = zone;
|
|
10856
|
+
lastTapAt = record.timeStamp;
|
|
10857
|
+
events.push({ type: "tap", zone });
|
|
10858
|
+
return events;
|
|
10859
|
+
}
|
|
10860
|
+
case "cancel": {
|
|
10861
|
+
activePointers = Math.max(0, activePointers - 1);
|
|
10862
|
+
pending = null;
|
|
10863
|
+
if (accumulatingZone || lastTapZone) {
|
|
10864
|
+
resetSequence();
|
|
10865
|
+
events.push({ type: "cancel" });
|
|
10866
|
+
}
|
|
10867
|
+
return events;
|
|
10868
|
+
}
|
|
10869
|
+
default:
|
|
10870
|
+
return events;
|
|
10871
|
+
}
|
|
10872
|
+
},
|
|
10873
|
+
tick(now) {
|
|
10874
|
+
return expire(now);
|
|
10875
|
+
},
|
|
10876
|
+
reset() {
|
|
10877
|
+
pending = null;
|
|
10878
|
+
activePointers = 0;
|
|
10879
|
+
resetSequence();
|
|
10880
|
+
},
|
|
10881
|
+
isAccumulating() {
|
|
10882
|
+
return accumulatingZone !== null;
|
|
10883
|
+
}
|
|
10884
|
+
};
|
|
10885
|
+
}
|
|
10886
|
+
var STYLE_ID = "sp-gestures-styles";
|
|
10887
|
+
var styles = `
|
|
10888
|
+
.sp-gestures {
|
|
10889
|
+
position: absolute;
|
|
10890
|
+
top: 0;
|
|
10891
|
+
left: 0;
|
|
10892
|
+
right: 0;
|
|
10893
|
+
/* The bottom strip belongs to the progress bar and control bar. */
|
|
10894
|
+
bottom: 64px;
|
|
10895
|
+
z-index: 6;
|
|
10896
|
+
touch-action: manipulation;
|
|
10897
|
+
user-select: none;
|
|
10898
|
+
-webkit-user-select: none;
|
|
10899
|
+
-webkit-touch-callout: none;
|
|
10900
|
+
}
|
|
10901
|
+
|
|
10902
|
+
.sp-gestures__zone {
|
|
10903
|
+
position: absolute;
|
|
10904
|
+
top: 0;
|
|
10905
|
+
bottom: 0;
|
|
10906
|
+
display: flex;
|
|
10907
|
+
align-items: center;
|
|
10908
|
+
justify-content: center;
|
|
10909
|
+
pointer-events: none;
|
|
10910
|
+
opacity: 0;
|
|
10911
|
+
color: #fff;
|
|
10912
|
+
transition: opacity 0.25s ease;
|
|
10913
|
+
}
|
|
10914
|
+
|
|
10915
|
+
.sp-gestures__zone--left {
|
|
10916
|
+
left: 0;
|
|
10917
|
+
border-radius: 0 50% 50% 0;
|
|
10918
|
+
}
|
|
10919
|
+
|
|
10920
|
+
.sp-gestures__zone--right {
|
|
10921
|
+
right: 0;
|
|
10922
|
+
border-radius: 50% 0 0 50%;
|
|
10923
|
+
}
|
|
10924
|
+
|
|
10925
|
+
.sp-gestures__zone--active {
|
|
10926
|
+
opacity: 1;
|
|
10927
|
+
background: rgba(255, 255, 255, 0.12);
|
|
10928
|
+
}
|
|
10929
|
+
|
|
10930
|
+
.sp-gestures__label {
|
|
10931
|
+
font-size: 13px;
|
|
10932
|
+
font-weight: 600;
|
|
10933
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
|
10934
|
+
}
|
|
10935
|
+
|
|
10936
|
+
.sp-gestures__live {
|
|
10937
|
+
position: absolute;
|
|
10938
|
+
width: 1px;
|
|
10939
|
+
height: 1px;
|
|
10940
|
+
margin: -1px;
|
|
10941
|
+
padding: 0;
|
|
10942
|
+
overflow: hidden;
|
|
10943
|
+
clip: rect(0 0 0 0);
|
|
10944
|
+
white-space: nowrap;
|
|
10945
|
+
border: 0;
|
|
10946
|
+
}
|
|
10947
|
+
|
|
10948
|
+
@media (prefers-reduced-motion: reduce) {
|
|
10949
|
+
.sp-gestures__zone {
|
|
10950
|
+
transition: none;
|
|
10951
|
+
}
|
|
10952
|
+
}
|
|
10953
|
+
`;
|
|
10954
|
+
var GestureOverlay = class {
|
|
10955
|
+
constructor(container, options) {
|
|
10956
|
+
this.container = container;
|
|
10957
|
+
this.options = options;
|
|
10958
|
+
this.styleEl = null;
|
|
10959
|
+
this.hideTimer = null;
|
|
10960
|
+
this.pointerHandler = (event) => {
|
|
10961
|
+
if (event.pointerType !== "touch") return;
|
|
10962
|
+
const rect = this.el.getBoundingClientRect();
|
|
10963
|
+
const width = rect.width || 1;
|
|
10964
|
+
this.options.onPointer({
|
|
10965
|
+
type: event.type === "pointerdown" ? "down" : event.type === "pointermove" ? "move" : event.type === "pointerup" ? "up" : "cancel",
|
|
10966
|
+
x: event.clientX,
|
|
10967
|
+
y: event.clientY,
|
|
10968
|
+
fraction: Math.max(0, Math.min(1, (event.clientX - rect.left) / width)),
|
|
10969
|
+
pointerId: event.pointerId,
|
|
10970
|
+
timeStamp: event.timeStamp
|
|
10971
|
+
});
|
|
10972
|
+
};
|
|
10973
|
+
this.injectStyles();
|
|
10974
|
+
this.el = document.createElement("div");
|
|
10975
|
+
this.el.className = "sp-gestures";
|
|
10976
|
+
const left = this.createZone("left");
|
|
10977
|
+
const right = this.createZone("right");
|
|
10978
|
+
this.zones = { left: left.zone, right: right.zone };
|
|
10979
|
+
this.labels = { left: left.label, right: right.label };
|
|
10980
|
+
this.live = document.createElement("div");
|
|
10981
|
+
this.live.className = "sp-gestures__live";
|
|
10982
|
+
this.live.setAttribute("aria-live", "polite");
|
|
10983
|
+
this.live.setAttribute("role", "status");
|
|
10984
|
+
this.el.appendChild(left.zone);
|
|
10985
|
+
this.el.appendChild(right.zone);
|
|
10986
|
+
this.el.appendChild(this.live);
|
|
10987
|
+
this.el.addEventListener("pointerdown", this.pointerHandler);
|
|
10988
|
+
this.el.addEventListener("pointermove", this.pointerHandler);
|
|
10989
|
+
this.el.addEventListener("pointerup", this.pointerHandler);
|
|
10990
|
+
this.el.addEventListener("pointercancel", this.pointerHandler);
|
|
10991
|
+
container.appendChild(this.el);
|
|
10992
|
+
}
|
|
10993
|
+
/** Size the zones to match the recognizer's split. */
|
|
10994
|
+
setZoneWidths(left, right) {
|
|
10995
|
+
this.zones.left.style.width = `${left * 100}%`;
|
|
10996
|
+
this.zones.right.style.width = `${right * 100}%`;
|
|
10997
|
+
}
|
|
10998
|
+
/**
|
|
10999
|
+
* Show the cumulative seek for a zone.
|
|
11000
|
+
*
|
|
11001
|
+
* @param zone - Which side was tapped
|
|
11002
|
+
* @param seconds - Total seconds this sequence has moved
|
|
11003
|
+
*/
|
|
11004
|
+
showSeek(zone, seconds) {
|
|
11005
|
+
if (zone === "middle") return;
|
|
11006
|
+
const direction = zone === "right" ? "forward" : "back";
|
|
11007
|
+
this.live.textContent = `${seconds} seconds ${direction}`;
|
|
11008
|
+
if (!this.options.feedback) return;
|
|
11009
|
+
const target = this.zones[zone];
|
|
11010
|
+
this.labels[zone].textContent = `${seconds} seconds`;
|
|
11011
|
+
target.classList.add("sp-gestures__zone--active");
|
|
11012
|
+
if (this.hideTimer) clearTimeout(this.hideTimer);
|
|
11013
|
+
this.hideTimer = setTimeout(() => {
|
|
11014
|
+
this.zones.left.classList.remove("sp-gestures__zone--active");
|
|
11015
|
+
this.zones.right.classList.remove("sp-gestures__zone--active");
|
|
11016
|
+
this.hideTimer = null;
|
|
11017
|
+
}, 600);
|
|
11018
|
+
}
|
|
11019
|
+
/** Announce that a forward seek was refused because the viewer is at the live edge. */
|
|
11020
|
+
announceLiveEdge() {
|
|
11021
|
+
this.live.textContent = "Already at the live edge";
|
|
11022
|
+
}
|
|
11023
|
+
destroy() {
|
|
11024
|
+
if (this.hideTimer) {
|
|
11025
|
+
clearTimeout(this.hideTimer);
|
|
11026
|
+
this.hideTimer = null;
|
|
11027
|
+
}
|
|
11028
|
+
this.el.removeEventListener("pointerdown", this.pointerHandler);
|
|
11029
|
+
this.el.removeEventListener("pointermove", this.pointerHandler);
|
|
11030
|
+
this.el.removeEventListener("pointerup", this.pointerHandler);
|
|
11031
|
+
this.el.removeEventListener("pointercancel", this.pointerHandler);
|
|
11032
|
+
this.el.remove();
|
|
11033
|
+
this.styleEl?.remove();
|
|
11034
|
+
this.styleEl = null;
|
|
11035
|
+
}
|
|
11036
|
+
/** Exposed for tests and for hosts that want to inspect the surface. */
|
|
11037
|
+
getElement() {
|
|
11038
|
+
return this.el;
|
|
11039
|
+
}
|
|
11040
|
+
createZone(side) {
|
|
11041
|
+
const zone = document.createElement("div");
|
|
11042
|
+
zone.className = `sp-gestures__zone sp-gestures__zone--${side}`;
|
|
11043
|
+
zone.setAttribute("aria-hidden", "true");
|
|
11044
|
+
const label = document.createElement("span");
|
|
11045
|
+
label.className = "sp-gestures__label";
|
|
11046
|
+
zone.appendChild(label);
|
|
11047
|
+
return { zone, label };
|
|
11048
|
+
}
|
|
11049
|
+
injectStyles() {
|
|
11050
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
11051
|
+
this.styleEl = document.createElement("style");
|
|
11052
|
+
this.styleEl.id = STYLE_ID;
|
|
11053
|
+
this.styleEl.textContent = styles;
|
|
11054
|
+
document.head.appendChild(this.styleEl);
|
|
11055
|
+
}
|
|
11056
|
+
};
|
|
11057
|
+
var PKG_VERSION$1 = "1.7.1";
|
|
11058
|
+
function hasCoarsePointer() {
|
|
11059
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
11060
|
+
return false;
|
|
11061
|
+
}
|
|
11062
|
+
try {
|
|
11063
|
+
return window.matchMedia("(any-pointer: coarse)").matches;
|
|
11064
|
+
} catch {
|
|
11065
|
+
return false;
|
|
11066
|
+
}
|
|
11067
|
+
}
|
|
11068
|
+
function createGesturesPlugin(config = {}) {
|
|
11069
|
+
let api = null;
|
|
11070
|
+
let overlay = null;
|
|
11071
|
+
let recognizer = null;
|
|
11072
|
+
let active = false;
|
|
11073
|
+
let runSeconds = 0;
|
|
11074
|
+
let pendingHide = null;
|
|
11075
|
+
const seekSeconds = config.seekSeconds ?? 10;
|
|
11076
|
+
const doubleTapWindowMs = config.doubleTapWindowMs ?? DEFAULT_RECOGNIZER_OPTIONS.doubleTapWindowMs;
|
|
11077
|
+
const leftZone = config.zones?.left ?? DEFAULT_RECOGNIZER_OPTIONS.leftZone;
|
|
11078
|
+
const rightZone = config.zones?.right ?? DEFAULT_RECOGNIZER_OPTIONS.rightZone;
|
|
11079
|
+
const feedback = config.feedback !== false;
|
|
11080
|
+
const haptics = config.haptics !== false;
|
|
11081
|
+
const tapToToggleControls = config.tapToToggleControls !== false;
|
|
11082
|
+
const canSeek = () => {
|
|
11083
|
+
if (!api) return false;
|
|
11084
|
+
if (api.getState("chromecastActive") || api.getState("airplayActive")) return false;
|
|
11085
|
+
const live = api.getState("live");
|
|
11086
|
+
const seekableRange = api.getState("seekableRange");
|
|
11087
|
+
if (live && !seekableRange) return false;
|
|
11088
|
+
const duration = api.getState("duration");
|
|
11089
|
+
if (!live && (!duration || !Number.isFinite(duration))) return false;
|
|
11090
|
+
return true;
|
|
11091
|
+
};
|
|
11092
|
+
const applySeek = (zone) => {
|
|
11093
|
+
if (!api || zone === "middle") return false;
|
|
11094
|
+
const video = api.container.querySelector("video");
|
|
11095
|
+
if (!video) return false;
|
|
11096
|
+
const delta = zone === "right" ? seekSeconds : -seekSeconds;
|
|
11097
|
+
const live = api.getState("live");
|
|
11098
|
+
const seekableRange = api.getState("seekableRange");
|
|
11099
|
+
const current = video.currentTime;
|
|
11100
|
+
let target;
|
|
11101
|
+
if (live && seekableRange) {
|
|
11102
|
+
target = Math.max(seekableRange.start, Math.min(seekableRange.end, current + delta));
|
|
11103
|
+
if (zone === "right" && target <= current) {
|
|
11104
|
+
overlay?.announceLiveEdge();
|
|
11105
|
+
return false;
|
|
11106
|
+
}
|
|
11107
|
+
} else {
|
|
11108
|
+
const duration = video.duration;
|
|
11109
|
+
const max = Number.isFinite(duration) && duration > 0 ? duration - 0.25 : current;
|
|
11110
|
+
target = Math.max(0, Math.min(max, current + delta));
|
|
11111
|
+
}
|
|
11112
|
+
video.currentTime = target;
|
|
11113
|
+
api.emit("playback:seeking", { time: target });
|
|
11114
|
+
if (haptics && typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
|
|
11115
|
+
navigator.vibrate(10);
|
|
11116
|
+
}
|
|
11117
|
+
return true;
|
|
11118
|
+
};
|
|
11119
|
+
const ui = () => api?.getPlugin("ui-controls") ?? null;
|
|
11120
|
+
const clearPendingHide = () => {
|
|
11121
|
+
if (pendingHide) {
|
|
11122
|
+
clearTimeout(pendingHide);
|
|
11123
|
+
pendingHide = null;
|
|
11124
|
+
}
|
|
11125
|
+
};
|
|
11126
|
+
const handleTap = () => {
|
|
11127
|
+
if (!tapToToggleControls || !api) return;
|
|
11128
|
+
const controls = ui();
|
|
11129
|
+
if (!controls) return;
|
|
11130
|
+
const visible = Boolean(api.getState("controlsVisible"));
|
|
11131
|
+
const paused = Boolean(api.getState("paused"));
|
|
11132
|
+
if (!visible) {
|
|
11133
|
+
controls.show();
|
|
11134
|
+
return;
|
|
11135
|
+
}
|
|
11136
|
+
if (paused) return;
|
|
11137
|
+
clearPendingHide();
|
|
11138
|
+
pendingHide = setTimeout(() => {
|
|
11139
|
+
pendingHide = null;
|
|
11140
|
+
controls.hide();
|
|
11141
|
+
}, doubleTapWindowMs);
|
|
11142
|
+
};
|
|
11143
|
+
const handleSeekStep = (zone) => {
|
|
11144
|
+
clearPendingHide();
|
|
11145
|
+
if (!canSeek()) return;
|
|
11146
|
+
const moved = applySeek(zone);
|
|
11147
|
+
if (!moved) return;
|
|
11148
|
+
runSeconds += seekSeconds;
|
|
11149
|
+
overlay?.showSeek(zone, runSeconds);
|
|
11150
|
+
api?.emit("gesture:seek", {
|
|
11151
|
+
direction: zone === "right" ? "forward" : "backward",
|
|
11152
|
+
seconds: seekSeconds,
|
|
11153
|
+
cumulative: runSeconds
|
|
11154
|
+
});
|
|
11155
|
+
};
|
|
11156
|
+
const onPointer = (record) => {
|
|
11157
|
+
if (!recognizer) return;
|
|
11158
|
+
for (const event of recognizer.handle(record)) {
|
|
11159
|
+
switch (event.type) {
|
|
11160
|
+
case "tap":
|
|
11161
|
+
api?.emit("gesture:tap", { zone: event.zone });
|
|
11162
|
+
handleTap();
|
|
11163
|
+
break;
|
|
11164
|
+
case "double-tap":
|
|
11165
|
+
runSeconds = 0;
|
|
11166
|
+
handleSeekStep(event.zone);
|
|
11167
|
+
break;
|
|
11168
|
+
case "accumulate":
|
|
11169
|
+
handleSeekStep(event.zone);
|
|
11170
|
+
break;
|
|
11171
|
+
case "cancel":
|
|
11172
|
+
runSeconds = 0;
|
|
11173
|
+
clearPendingHide();
|
|
11174
|
+
break;
|
|
11175
|
+
}
|
|
11176
|
+
}
|
|
11177
|
+
};
|
|
11178
|
+
return {
|
|
11179
|
+
id: "gestures",
|
|
11180
|
+
name: "Gestures",
|
|
11181
|
+
version: PKG_VERSION$1,
|
|
11182
|
+
type: "feature",
|
|
11183
|
+
init(pluginApi) {
|
|
11184
|
+
api = pluginApi;
|
|
11185
|
+
const enabled = config.enabled ?? "auto";
|
|
11186
|
+
active = enabled === "auto" ? hasCoarsePointer() : Boolean(enabled);
|
|
11187
|
+
if (!active) {
|
|
11188
|
+
api.logger.debug("[gestures] no coarse pointer, gesture surface not installed");
|
|
11189
|
+
return;
|
|
11190
|
+
}
|
|
11191
|
+
if (api.getState("mediaType") === "audio") {
|
|
11192
|
+
active = false;
|
|
11193
|
+
return;
|
|
11194
|
+
}
|
|
11195
|
+
recognizer = createRecognizer({
|
|
11196
|
+
doubleTapWindowMs,
|
|
11197
|
+
accumulationWindowMs: config.accumulationWindowMs,
|
|
11198
|
+
leftZone,
|
|
11199
|
+
rightZone,
|
|
11200
|
+
slopPx: config.slopPx
|
|
11201
|
+
});
|
|
11202
|
+
overlay = new GestureOverlay(api.container, { onPointer, feedback });
|
|
11203
|
+
overlay.setZoneWidths(leftZone, rightZone);
|
|
11204
|
+
api.onDestroy(() => {
|
|
11205
|
+
clearPendingHide();
|
|
11206
|
+
overlay?.destroy();
|
|
11207
|
+
overlay = null;
|
|
11208
|
+
recognizer?.reset();
|
|
11209
|
+
recognizer = null;
|
|
11210
|
+
});
|
|
11211
|
+
},
|
|
11212
|
+
destroy() {
|
|
11213
|
+
clearPendingHide();
|
|
11214
|
+
overlay?.destroy();
|
|
11215
|
+
overlay = null;
|
|
11216
|
+
recognizer?.reset();
|
|
11217
|
+
recognizer = null;
|
|
11218
|
+
active = false;
|
|
11219
|
+
runSeconds = 0;
|
|
11220
|
+
api = null;
|
|
11221
|
+
},
|
|
11222
|
+
ownsTapInteraction() {
|
|
11223
|
+
return active && tapToToggleControls;
|
|
11224
|
+
}
|
|
11225
|
+
};
|
|
11226
|
+
}
|
|
9220
11227
|
function getAttr(element, ...names) {
|
|
9221
11228
|
for (const name of names) {
|
|
9222
11229
|
const value = element.getAttribute(name);
|
|
@@ -9246,6 +11253,14 @@ function parseDataAttributes(element) {
|
|
|
9246
11253
|
if (controls !== null) {
|
|
9247
11254
|
config.controls = controls !== "false";
|
|
9248
11255
|
}
|
|
11256
|
+
const bigPlayButton = getAttr(element, "data-big-play-button", "big-play-button");
|
|
11257
|
+
if (bigPlayButton !== null) {
|
|
11258
|
+
config.bigPlayButton = bigPlayButton !== "false";
|
|
11259
|
+
}
|
|
11260
|
+
const gestures = getAttr(element, "data-gestures", "gestures");
|
|
11261
|
+
if (gestures !== null) {
|
|
11262
|
+
config.gestures = gestures !== "false";
|
|
11263
|
+
}
|
|
9249
11264
|
const keyboard = getAttr(element, "data-keyboard", "keyboard");
|
|
9250
11265
|
if (keyboard !== null) {
|
|
9251
11266
|
config.keyboard = keyboard !== "false";
|
|
@@ -9395,6 +11410,9 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
|
|
|
9395
11410
|
if (config.primaryColor) theme.primaryColor = config.primaryColor;
|
|
9396
11411
|
if (config.backgroundColor) theme.backgroundColor = config.backgroundColor;
|
|
9397
11412
|
const plugins = [pluginCreators2.hls()];
|
|
11413
|
+
if (pluginCreators2.native) {
|
|
11414
|
+
plugins.push(pluginCreators2.native());
|
|
11415
|
+
}
|
|
9398
11416
|
if (pluginCreators2.playlist && config.playlist?.length) {
|
|
9399
11417
|
plugins.push(pluginCreators2.playlist({
|
|
9400
11418
|
items: config.playlist.map((item, index2) => ({
|
|
@@ -9421,6 +11439,9 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
|
|
|
9421
11439
|
if (pluginCreators2.captions) {
|
|
9422
11440
|
plugins.push(pluginCreators2.captions(config.captions || {}));
|
|
9423
11441
|
}
|
|
11442
|
+
if (type === "video" && pluginCreators2.gestures && config.gestures !== false) {
|
|
11443
|
+
plugins.push(pluginCreators2.gestures({}));
|
|
11444
|
+
}
|
|
9424
11445
|
if (pluginCreators2.analytics && config.analytics?.beaconUrl) {
|
|
9425
11446
|
plugins.push(pluginCreators2.analytics({
|
|
9426
11447
|
beaconUrl: config.analytics.beaconUrl,
|
|
@@ -9433,6 +11454,7 @@ async function createEmbedPlayer(container, config, pluginCreators2, availableTy
|
|
|
9433
11454
|
const uiConfig = {};
|
|
9434
11455
|
if (Object.keys(theme).length > 0) uiConfig.theme = theme;
|
|
9435
11456
|
if (config.hideDelay !== void 0) uiConfig.hideDelay = config.hideDelay;
|
|
11457
|
+
if (config.bigPlayButton !== void 0) uiConfig.bigPlayButton = config.bigPlayButton;
|
|
9436
11458
|
plugins.push(pluginCreators2.videoUI(uiConfig));
|
|
9437
11459
|
} else if ((type === "audio" || type === "audio-mini") && pluginCreators2.audioUI) {
|
|
9438
11460
|
plugins.push(pluginCreators2.audioUI({
|
|
@@ -9538,17 +11560,20 @@ function setupAutoInit(pluginCreators2, availableTypes) {
|
|
|
9538
11560
|
}
|
|
9539
11561
|
}
|
|
9540
11562
|
}
|
|
9541
|
-
const
|
|
11563
|
+
const PKG_VERSION = "1.7.1";
|
|
11564
|
+
const VERSION = PKG_VERSION;
|
|
9542
11565
|
const AVAILABLE_TYPES = ["video", "audio", "audio-mini"];
|
|
9543
11566
|
const pluginCreators = {
|
|
9544
11567
|
hls: createHLSPlugin,
|
|
11568
|
+
native: createNativePlugin,
|
|
9545
11569
|
videoUI: uiPlugin,
|
|
9546
11570
|
audioUI: createAudioUIPlugin,
|
|
9547
11571
|
analytics: createAnalyticsPlugin,
|
|
9548
11572
|
playlist: createPlaylistPlugin,
|
|
9549
11573
|
mediaSession: createMediaSessionPlugin,
|
|
9550
11574
|
watermark: createWatermarkPlugin,
|
|
9551
|
-
captions: createCaptionsPlugin
|
|
11575
|
+
captions: createCaptionsPlugin,
|
|
11576
|
+
gestures: createGesturesPlugin
|
|
9552
11577
|
};
|
|
9553
11578
|
const ScarlettPlayerAPI = createScarlettPlayerAPI(
|
|
9554
11579
|
pluginCreators,
|