@f8team/reel-core 1.0.4 → 1.1.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/dist/index.js CHANGED
@@ -212,12 +212,58 @@ function createControlsRegistry() {
212
212
  }
213
213
  return { contribute, snapshot, clear };
214
214
  }
215
- function createPluginHost({ commands, controls, store, bus }) {
215
+ function createSourceTransformRegistry() {
216
+ const transforms = /* @__PURE__ */ new Set();
217
+ function register(transform) {
218
+ transforms.add(transform);
219
+ return () => transforms.delete(transform);
220
+ }
221
+ function apply(source) {
222
+ let current = source;
223
+ for (const transform of transforms) {
224
+ current = transform(current);
225
+ }
226
+ return current;
227
+ }
228
+ return { register, apply };
229
+ }
230
+ function createMediaAttachmentRegistry() {
231
+ let current = null;
232
+ const listeners = /* @__PURE__ */ new Set();
233
+ function get() {
234
+ return current;
235
+ }
236
+ function set(attachment) {
237
+ current = attachment;
238
+ for (const listener of listeners) listener(current);
239
+ }
240
+ function subscribe(listener) {
241
+ listeners.add(listener);
242
+ if (current) listener(current);
243
+ return () => listeners.delete(listener);
244
+ }
245
+ return { get, set, subscribe };
246
+ }
247
+ function createPluginHost({
248
+ commands,
249
+ controls,
250
+ store,
251
+ bus,
252
+ sourceTransforms = createSourceTransformRegistry(),
253
+ mediaAttachments = createMediaAttachmentRegistry()
254
+ }) {
216
255
  return {
217
256
  controls: {
218
- contribute: (slot, render) => controls.contribute(slot, render)
257
+ contribute: controls.contribute
219
258
  },
220
259
  commands,
260
+ sources: {
261
+ transform: sourceTransforms.register
262
+ },
263
+ media: {
264
+ get: mediaAttachments.get,
265
+ subscribe: mediaAttachments.subscribe
266
+ },
221
267
  store,
222
268
  emit: (event, payload) => {
223
269
  bus.emit(event, payload);
@@ -226,6 +272,21 @@ function createPluginHost({ commands, controls, store, bus }) {
226
272
  }
227
273
 
228
274
  // src/util/url.ts
275
+ function areSourceDescriptorsEquivalent(previous, next) {
276
+ if (previous === next) return true;
277
+ if (!previous || !next) return false;
278
+ const previousThumbnails = previous.thumbnails;
279
+ const nextThumbnails = next.thumbnails;
280
+ return previous.src === next.src && (previous.type || "auto") === (next.type || "auto") && previous.withCredentials === next.withCredentials && previous.tracks === next.tracks && (previousThumbnails === nextThumbnails || previousThumbnails?.src === nextThumbnails?.src && previousThumbnails?.withCredentials === nextThumbnails?.withCredentials);
281
+ }
282
+ function isSafeMediaUrl(input) {
283
+ if (!input?.trim()) return false;
284
+ try {
285
+ return /^(https?|blob|data):$/.test(new URL(input, "https://reel.invalid").protocol);
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
229
290
  function looksLikeHls(input) {
230
291
  if (!input) return false;
231
292
  const lower = input.toLowerCase();
@@ -854,6 +915,7 @@ var NativeLoader = class {
854
915
  constructor() {
855
916
  this.video = null;
856
917
  this.cleanups = [];
918
+ this.rejectPending = null;
857
919
  }
858
920
  async attach(video, source) {
859
921
  this.detach();
@@ -861,12 +923,14 @@ var NativeLoader = class {
861
923
  return new Promise((resolve, reject) => {
862
924
  const onMeta = () => {
863
925
  cleanupListeners();
926
+ this.rejectPending = null;
864
927
  resolve();
865
928
  };
866
929
  const onError = () => {
867
930
  cleanupListeners();
868
931
  const err = video.error;
869
932
  const message = err ? `MediaError code=${err.code} message=${err.message}` : "Native loader failed";
933
+ this.rejectPending = null;
870
934
  reject(new Error(message));
871
935
  };
872
936
  const cleanupListeners = () => {
@@ -876,17 +940,28 @@ var NativeLoader = class {
876
940
  video.addEventListener("loadedmetadata", onMeta);
877
941
  video.addEventListener("error", onError);
878
942
  this.cleanups.push(cleanupListeners);
943
+ this.rejectPending = reject;
879
944
  video.src = source.src;
880
945
  video.load();
881
946
  });
882
947
  }
883
- detach() {
948
+ abort() {
949
+ const reject = this.rejectPending;
950
+ this.rejectPending = null;
884
951
  while (this.cleanups.length) {
885
952
  try {
886
953
  this.cleanups.pop()?.();
887
954
  } catch {
888
955
  }
889
956
  }
957
+ if (reject) {
958
+ const error = new Error("Native source load aborted");
959
+ error.name = "AbortError";
960
+ reject(error);
961
+ }
962
+ }
963
+ detach() {
964
+ this.abort();
890
965
  if (this.video) {
891
966
  try {
892
967
  this.video.removeAttribute("src");
@@ -1020,7 +1095,9 @@ var YouTubeLoader = class {
1020
1095
  }
1021
1096
  const host = document.createElement("div");
1022
1097
  host.setAttribute("data-reel-yt-host", "");
1023
- host.style.cssText = "position:absolute;inset:0;width:100%;height:100%;overflow:hidden;background:#000;";
1098
+ host.style.cssText = "position:absolute;inset:0;overflow:hidden;background:#000";
1099
+ const mount = document.createElement("div");
1100
+ host.appendChild(mount);
1024
1101
  parent.appendChild(host);
1025
1102
  this.host = host;
1026
1103
  video.setAttribute(YT_HIDDEN_ATTR, "");
@@ -1033,7 +1110,7 @@ var YouTubeLoader = class {
1033
1110
  run();
1034
1111
  };
1035
1112
  try {
1036
- this.yt = new YT.Player(host, {
1113
+ this.yt = new YT.Player(mount, {
1037
1114
  videoId: id,
1038
1115
  // 100% so the iframe fills the host div rather than using default px dimensions.
1039
1116
  width: "100%",
@@ -1049,7 +1126,7 @@ var YouTubeLoader = class {
1049
1126
  onReady: () => {
1050
1127
  const iframe = host.querySelector("iframe");
1051
1128
  if (iframe) {
1052
- iframe.style.cssText = "width:100%;height:100%;border:none;display:block;";
1129
+ iframe.style.cssText = "width:100%;height:100%;border:none;display:block";
1053
1130
  }
1054
1131
  try {
1055
1132
  this.duration = this.yt?.getDuration() ?? 0;
@@ -1381,21 +1458,6 @@ function createStore(initial, options = {}) {
1381
1458
  }
1382
1459
 
1383
1460
  // src/createPlayer.ts
1384
- var NATIVE_EVENTS = [
1385
- "loadedmetadata",
1386
- "durationchange",
1387
- "timeupdate",
1388
- "play",
1389
- "pause",
1390
- "ended",
1391
- "error",
1392
- "volumechange",
1393
- "ratechange",
1394
- "seeking",
1395
- "seeked",
1396
- "waiting",
1397
- "playing"
1398
- ];
1399
1461
  function makeInitialState(options) {
1400
1462
  return {
1401
1463
  status: "idle",
@@ -1428,13 +1490,42 @@ function createPlayer(options = {}, internal = {}) {
1428
1490
  const bus = createEventBus();
1429
1491
  const commands = createCommandRegistry();
1430
1492
  const controls = createControlsRegistry();
1493
+ const sourceTransforms = createSourceTransformRegistry();
1494
+ const mediaAttachments = createMediaAttachmentRegistry();
1431
1495
  let status = "idle";
1432
1496
  let video = null;
1433
1497
  let activeLoader = null;
1434
1498
  let pendingAttachAbort = null;
1499
+ let pendingSourceLoad = null;
1500
+ let latestSourceLoadPromise = null;
1435
1501
  let disposed = false;
1436
- let initialOptionsApplied = false;
1437
1502
  let pendingPlay = false;
1503
+ function createPendingSourceLoad() {
1504
+ let resolve;
1505
+ let reject;
1506
+ const promise = new Promise((onResolve, onReject) => {
1507
+ resolve = onResolve;
1508
+ reject = onReject;
1509
+ });
1510
+ void promise.catch(() => void 0);
1511
+ latestSourceLoadPromise = promise;
1512
+ return { promise, resolve, reject };
1513
+ }
1514
+ function resolvePendingSourceLoad() {
1515
+ const load = pendingSourceLoad;
1516
+ if (!load) return;
1517
+ pendingSourceLoad = null;
1518
+ load.resolve();
1519
+ }
1520
+ function rejectPendingSourceLoad(reason) {
1521
+ const load = pendingSourceLoad;
1522
+ if (!load) return;
1523
+ pendingSourceLoad = null;
1524
+ load.reject(reason);
1525
+ }
1526
+ function createAbortError() {
1527
+ return Object.assign(new Error("Aborted"), { name: "AbortError" });
1528
+ }
1438
1529
  const YT_PLAYER_STATE_PLAYING = 1;
1439
1530
  function getYouTubePlayer() {
1440
1531
  return activeLoader?._instance?.() ?? null;
@@ -1457,11 +1548,12 @@ function createPlayer(options = {}, internal = {}) {
1457
1548
  bus.emit("qualitychange", { quality, auto });
1458
1549
  }
1459
1550
  commands.add("hls:setQuality", (level) => {
1460
- if (level === null) {
1461
- bus.emit("qualitypreference", { qualityHeight: null });
1462
- } else if (level !== null && typeof level === "object" && typeof level.height === "number") {
1463
- bus.emit("qualitypreference", { qualityHeight: level.height });
1551
+ if (level !== null && (typeof level !== "object" || typeof level.height !== "number")) {
1552
+ return;
1464
1553
  }
1554
+ bus.emit("qualitypreference", {
1555
+ qualityHeight: level === null ? null : level.height
1556
+ });
1465
1557
  const loader = activeLoader;
1466
1558
  if (typeof loader?.setQuality === "function") {
1467
1559
  loader.setQuality(level === null ? "auto" : level);
@@ -1522,7 +1614,9 @@ function createPlayer(options = {}, internal = {}) {
1522
1614
  commands,
1523
1615
  controls,
1524
1616
  store: { getState: store.getState, subscribe: store.subscribe },
1525
- bus
1617
+ bus,
1618
+ sourceTransforms,
1619
+ mediaAttachments
1526
1620
  });
1527
1621
  const plugins = createPluginBus({ player: playerRef, host });
1528
1622
  function dispatch(event) {
@@ -1534,6 +1628,10 @@ function createPlayer(options = {}, internal = {}) {
1534
1628
  for (const effect of result.effects) applyEffect(effect);
1535
1629
  if (prev !== "ready" && status === "ready") {
1536
1630
  bus.emit("ready", { duration: store.getState().duration });
1631
+ resolvePendingSourceLoad();
1632
+ }
1633
+ if (event.type === "loadFailed") {
1634
+ rejectPendingSourceLoad(event.error);
1537
1635
  }
1538
1636
  }
1539
1637
  function applyEffect(effect) {
@@ -1577,15 +1675,29 @@ function createPlayer(options = {}, internal = {}) {
1577
1675
  activeQuality: null
1578
1676
  });
1579
1677
  bus.emit("qualitychange", { quality: null, auto: true });
1678
+ if (!isSafeMediaUrl(source.src) || source.tracks?.some((track) => !isSafeMediaUrl(track.src)) || source.thumbnails && !isSafeMediaUrl(source.thumbnails.src)) {
1679
+ if (video) {
1680
+ pendingSourceLoad = createPendingSourceLoad();
1681
+ }
1682
+ const error = createPlayerError({
1683
+ code: "unsupported",
1684
+ message: "Invalid URL"
1685
+ });
1686
+ dispatch({ type: "loadFailed", error });
1687
+ return;
1688
+ }
1580
1689
  if (!video) return;
1690
+ const sourceLoad = createPendingSourceLoad();
1691
+ pendingSourceLoad = sourceLoad;
1581
1692
  const provider = registry.resolve(source);
1582
1693
  if (!provider) {
1694
+ const error = createPlayerError({
1695
+ code: "unsupported",
1696
+ message: `No source provider for ${source.src}`
1697
+ });
1583
1698
  dispatch({
1584
1699
  type: "loadFailed",
1585
- error: createPlayerError({
1586
- code: "unsupported",
1587
- message: `No source provider for ${source.src}`
1588
- })
1700
+ error
1589
1701
  });
1590
1702
  return;
1591
1703
  }
@@ -1596,8 +1708,13 @@ function createPlayer(options = {}, internal = {}) {
1596
1708
  aborted = true;
1597
1709
  try {
1598
1710
  loader.abort?.();
1599
- } catch (err) {
1600
- console.error("[@f8team/reel-core] loader.abort() threw:", err);
1711
+ } catch (error) {
1712
+ console.error(error);
1713
+ }
1714
+ if (pendingSourceLoad === sourceLoad) {
1715
+ rejectPendingSourceLoad(createAbortError());
1716
+ } else {
1717
+ sourceLoad.reject(createAbortError());
1601
1718
  }
1602
1719
  };
1603
1720
  loader.attach(video, source).then(() => {
@@ -1699,8 +1816,6 @@ function createPlayer(options = {}, internal = {}) {
1699
1816
  target.addEventListener(name, handler);
1700
1817
  nativeListeners.push(() => target.removeEventListener(name, handler));
1701
1818
  }
1702
- for (const _ of NATIVE_EVENTS) {
1703
- }
1704
1819
  on2("loadedmetadata", () => {
1705
1820
  store.setState({
1706
1821
  duration: Number.isFinite(target.duration) ? target.duration : 0,
@@ -1771,17 +1886,24 @@ function createPlayer(options = {}, internal = {}) {
1771
1886
  on2("playing", () => {
1772
1887
  bus.emit("buffering", { isBuffering: false });
1773
1888
  });
1774
- if (typeof document !== "undefined") {
1775
- const onFullscreenChange = () => {
1776
- const isFullscreen = !!document.fullscreenElement;
1777
- store.setState({ fullscreen: isFullscreen });
1778
- bus.emit("fullscreenchange", { fullscreen: isFullscreen });
1779
- };
1780
- document.addEventListener("fullscreenchange", onFullscreenChange);
1781
- nativeListeners.push(
1782
- () => document.removeEventListener("fullscreenchange", onFullscreenChange)
1783
- );
1784
- }
1889
+ on2("enterpictureinpicture", () => {
1890
+ store.setState({ pip: true });
1891
+ bus.emit("pipchange", { pip: true });
1892
+ });
1893
+ on2("leavepictureinpicture", () => {
1894
+ store.setState({ pip: false });
1895
+ bus.emit("pipchange", { pip: false });
1896
+ });
1897
+ const ownerDocument = target.ownerDocument;
1898
+ const onFullscreenChange = () => {
1899
+ const isFullscreen = !!ownerDocument.fullscreenElement?.contains(target);
1900
+ store.setState({ fullscreen: isFullscreen });
1901
+ bus.emit("fullscreenchange", { fullscreen: isFullscreen });
1902
+ };
1903
+ ownerDocument.addEventListener("fullscreenchange", onFullscreenChange);
1904
+ nativeListeners.push(
1905
+ () => ownerDocument.removeEventListener("fullscreenchange", onFullscreenChange)
1906
+ );
1785
1907
  }
1786
1908
  function unbindNativeEvents() {
1787
1909
  while (nativeListeners.length) {
@@ -1791,24 +1913,18 @@ function createPlayer(options = {}, internal = {}) {
1791
1913
  }
1792
1914
  }
1793
1915
  }
1794
- function applyInitialOptions() {
1795
- if (initialOptionsApplied || !video) return;
1796
- if (typeof options.volume === "number") video.volume = options.volume;
1797
- if (typeof options.muted === "boolean") video.muted = options.muted;
1798
- if (typeof options.playbackRate === "number") video.playbackRate = options.playbackRate;
1916
+ function applyAttachOptions() {
1917
+ if (!video) return;
1918
+ const state = store.getState();
1919
+ video.volume = state.volume;
1920
+ video.muted = state.muted;
1921
+ video.playbackRate = state.playbackRate;
1799
1922
  if (options.poster) video.poster = options.poster;
1800
1923
  if (options.preload) video.preload = options.preload;
1801
- if (options.playsInline ?? true) {
1802
- video.playsInline = true;
1803
- video.setAttribute("webkit-playsinline", "");
1804
- }
1805
- if ("crossOrigin" in options) {
1806
- if (options.crossOrigin) video.crossOrigin = options.crossOrigin;
1807
- } else {
1808
- video.crossOrigin = "anonymous";
1809
- }
1810
- if (options.loop) video.loop = true;
1811
- initialOptionsApplied = true;
1924
+ video.playsInline = options.playsInline ?? true;
1925
+ video.toggleAttribute("webkit-playsinline", video.playsInline);
1926
+ video.crossOrigin = "crossOrigin" in options ? options.crossOrigin ?? null : "anonymous";
1927
+ video.loop = !!options.loop;
1812
1928
  }
1813
1929
  const attachDisposers = [];
1814
1930
  function flushAttachDisposers() {
@@ -1832,12 +1948,21 @@ function createPlayer(options = {}, internal = {}) {
1832
1948
  }
1833
1949
  async function attach(el) {
1834
1950
  if (disposed) throw new Error("[@f8team/reel-core] cannot attach: player disposed");
1835
- if (video === el) return;
1951
+ if (video === el) {
1952
+ await latestSourceLoadPromise;
1953
+ return;
1954
+ }
1836
1955
  if (video) detach();
1837
1956
  video = el;
1957
+ mediaAttachments.set({
1958
+ video: el,
1959
+ container: el.closest("[data-reel]") ?? el.parentElement ?? el
1960
+ });
1838
1961
  bindNativeEvents(el);
1839
- applyInitialOptions();
1840
- if (options.source) dispatch({ type: "setSource", source: options.source });
1962
+ applyAttachOptions();
1963
+ const currentSource = store.getState().source;
1964
+ if (currentSource) setSource(currentSource);
1965
+ const sourceLoadPromise = latestSourceLoadPromise;
1841
1966
  const wantStartTime = typeof options.startTime === "number" && options.startTime > 0;
1842
1967
  const wantAutoplay = !!options.autoplay && options.autoplay !== "off" && !!options.source;
1843
1968
  if (status === "ready") {
@@ -1859,6 +1984,7 @@ function createPlayer(options = {}, internal = {}) {
1859
1984
  attachDisposers.push(off2);
1860
1985
  }
1861
1986
  }
1987
+ await sourceLoadPromise;
1862
1988
  }
1863
1989
  function detach() {
1864
1990
  pendingAttachAbort?.();
@@ -1872,6 +1998,7 @@ function createPlayer(options = {}, internal = {}) {
1872
1998
  }
1873
1999
  activeLoader = null;
1874
2000
  }
2001
+ mediaAttachments.set(null);
1875
2002
  video = null;
1876
2003
  }
1877
2004
  function dispose() {
@@ -1886,7 +2013,8 @@ function createPlayer(options = {}, internal = {}) {
1886
2013
  }
1887
2014
  function setSource(source) {
1888
2015
  pendingPlay = false;
1889
- dispatch({ type: "setSource", source });
2016
+ latestSourceLoadPromise = null;
2017
+ dispatch({ type: "setSource", source: source ? sourceTransforms.apply(source) : null });
1890
2018
  }
1891
2019
  function getSource() {
1892
2020
  return store.getState().source;
@@ -1894,7 +2022,7 @@ function createPlayer(options = {}, internal = {}) {
1894
2022
  function retry() {
1895
2023
  const current = store.getState().source;
1896
2024
  if (!current) return false;
1897
- dispatch({ type: "setSource", source: current });
2025
+ setSource(current);
1898
2026
  return true;
1899
2027
  }
1900
2028
  async function play() {
@@ -2264,6 +2392,27 @@ function createFocusTrap(container) {
2264
2392
  return { activate, deactivate };
2265
2393
  }
2266
2394
 
2395
+ // src/a11y/controlsFocus.ts
2396
+ var OPEN_MENU_SELECTOR = "[data-reel-menu-open]";
2397
+ function activeElementInside(container) {
2398
+ const active = (container.ownerDocument ?? document).activeElement;
2399
+ if (!(active instanceof HTMLElement)) return null;
2400
+ return container.contains(active) ? active : null;
2401
+ }
2402
+ function blurFocusInside(container) {
2403
+ activeElementInside(container)?.blur();
2404
+ }
2405
+ function shouldKeepControlsVisible(container) {
2406
+ if (container.querySelector(OPEN_MENU_SELECTOR) !== null) return true;
2407
+ const active = activeElementInside(container);
2408
+ if (!active) return false;
2409
+ try {
2410
+ return active.matches(":focus-visible");
2411
+ } catch {
2412
+ return false;
2413
+ }
2414
+ }
2415
+
2267
2416
  // src/util/time.ts
2268
2417
  var TIME_RE = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:\.(\d+))?$/;
2269
2418
  function parseTime(input) {
@@ -2297,6 +2446,6 @@ function toSeconds(input) {
2297
2446
  return parseTime(input);
2298
2447
  }
2299
2448
 
2300
- export { DEFAULT_TOKENS, THEME_PRESETS, TOKEN_TO_CSS_VAR, announce, applyTheme, createFocusTrap, createPlayer, definePlugin, detectSourceType, formatTime, looksLikeHls, looksLikeProgressive, looksLikeYouTube, parseTime, resolveTokens, toSeconds };
2449
+ export { DEFAULT_TOKENS, THEME_PRESETS, TOKEN_TO_CSS_VAR, announce, applyTheme, areSourceDescriptorsEquivalent, blurFocusInside, createFocusTrap, createPlayer, definePlugin, detectSourceType, formatTime, isSafeMediaUrl, looksLikeHls, looksLikeProgressive, looksLikeYouTube, parseTime, resolveTokens, shouldKeepControlsVisible, toSeconds };
2301
2450
  //# sourceMappingURL=index.js.map
2302
2451
  //# sourceMappingURL=index.js.map