@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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @f8team/reel-core
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fe0431b: Fix controls that never auto-hide in fullscreen.
8
+
9
+ Entering fullscreen is only possible by hitting the fullscreen control, and the
10
+ pointer focus it leaves behind kept `:focus-within` matching forever, so the
11
+ themes held the controls bar and the mobile transport at full opacity for the
12
+ rest of the session. Reported on Android Chrome and Safari.
13
+
14
+ The idle timer now releases that pointer focus instead of only dropping
15
+ `data-reel-controls-visible`. Keyboard focus and open control menus still keep
16
+ the chrome on screen - the timer re-arms rather than hiding.
17
+
18
+ `@f8team/reel-core` gains `blurFocusInside` and `shouldKeepControlsVisible` so
19
+ the Lit element, the React adapter and app-level players share one auto-hide
20
+ focus contract; `@f8team/reel-react` re-exports both.
21
+
22
+ ## 1.0.5
23
+
24
+ ### Patch Changes
25
+
26
+ - 16eaa0b: Harden source loading, authenticated HLS, multi-player input ownership, adapter lifecycle,
27
+ YouTube controls, responsive touch targets, and plugin cleanup. Add framework-specific preset
28
+ entry points, deterministic browser journeys, and focused regression coverage for production
29
+ consumers. Enforce exact tenant API credential boundaries, preserve progressive upload playback,
30
+ and expand release gates across Chromium, WebKit, coverage, bundle size, and dependency security.
31
+
3
32
  ## 1.0.4
4
33
 
5
34
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -214,12 +214,58 @@ function createControlsRegistry() {
214
214
  }
215
215
  return { contribute, snapshot, clear };
216
216
  }
217
- function createPluginHost({ commands, controls, store, bus }) {
217
+ function createSourceTransformRegistry() {
218
+ const transforms = /* @__PURE__ */ new Set();
219
+ function register(transform) {
220
+ transforms.add(transform);
221
+ return () => transforms.delete(transform);
222
+ }
223
+ function apply(source) {
224
+ let current = source;
225
+ for (const transform of transforms) {
226
+ current = transform(current);
227
+ }
228
+ return current;
229
+ }
230
+ return { register, apply };
231
+ }
232
+ function createMediaAttachmentRegistry() {
233
+ let current = null;
234
+ const listeners = /* @__PURE__ */ new Set();
235
+ function get() {
236
+ return current;
237
+ }
238
+ function set(attachment) {
239
+ current = attachment;
240
+ for (const listener of listeners) listener(current);
241
+ }
242
+ function subscribe(listener) {
243
+ listeners.add(listener);
244
+ if (current) listener(current);
245
+ return () => listeners.delete(listener);
246
+ }
247
+ return { get, set, subscribe };
248
+ }
249
+ function createPluginHost({
250
+ commands,
251
+ controls,
252
+ store,
253
+ bus,
254
+ sourceTransforms = createSourceTransformRegistry(),
255
+ mediaAttachments = createMediaAttachmentRegistry()
256
+ }) {
218
257
  return {
219
258
  controls: {
220
- contribute: (slot, render) => controls.contribute(slot, render)
259
+ contribute: controls.contribute
221
260
  },
222
261
  commands,
262
+ sources: {
263
+ transform: sourceTransforms.register
264
+ },
265
+ media: {
266
+ get: mediaAttachments.get,
267
+ subscribe: mediaAttachments.subscribe
268
+ },
223
269
  store,
224
270
  emit: (event, payload) => {
225
271
  bus.emit(event, payload);
@@ -228,6 +274,21 @@ function createPluginHost({ commands, controls, store, bus }) {
228
274
  }
229
275
 
230
276
  // src/util/url.ts
277
+ function areSourceDescriptorsEquivalent(previous, next) {
278
+ if (previous === next) return true;
279
+ if (!previous || !next) return false;
280
+ const previousThumbnails = previous.thumbnails;
281
+ const nextThumbnails = next.thumbnails;
282
+ 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);
283
+ }
284
+ function isSafeMediaUrl(input) {
285
+ if (!input?.trim()) return false;
286
+ try {
287
+ return /^(https?|blob|data):$/.test(new URL(input, "https://reel.invalid").protocol);
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
231
292
  function looksLikeHls(input) {
232
293
  if (!input) return false;
233
294
  const lower = input.toLowerCase();
@@ -856,6 +917,7 @@ var NativeLoader = class {
856
917
  constructor() {
857
918
  this.video = null;
858
919
  this.cleanups = [];
920
+ this.rejectPending = null;
859
921
  }
860
922
  async attach(video, source) {
861
923
  this.detach();
@@ -863,12 +925,14 @@ var NativeLoader = class {
863
925
  return new Promise((resolve, reject) => {
864
926
  const onMeta = () => {
865
927
  cleanupListeners();
928
+ this.rejectPending = null;
866
929
  resolve();
867
930
  };
868
931
  const onError = () => {
869
932
  cleanupListeners();
870
933
  const err = video.error;
871
934
  const message = err ? `MediaError code=${err.code} message=${err.message}` : "Native loader failed";
935
+ this.rejectPending = null;
872
936
  reject(new Error(message));
873
937
  };
874
938
  const cleanupListeners = () => {
@@ -878,17 +942,28 @@ var NativeLoader = class {
878
942
  video.addEventListener("loadedmetadata", onMeta);
879
943
  video.addEventListener("error", onError);
880
944
  this.cleanups.push(cleanupListeners);
945
+ this.rejectPending = reject;
881
946
  video.src = source.src;
882
947
  video.load();
883
948
  });
884
949
  }
885
- detach() {
950
+ abort() {
951
+ const reject = this.rejectPending;
952
+ this.rejectPending = null;
886
953
  while (this.cleanups.length) {
887
954
  try {
888
955
  this.cleanups.pop()?.();
889
956
  } catch {
890
957
  }
891
958
  }
959
+ if (reject) {
960
+ const error = new Error("Native source load aborted");
961
+ error.name = "AbortError";
962
+ reject(error);
963
+ }
964
+ }
965
+ detach() {
966
+ this.abort();
892
967
  if (this.video) {
893
968
  try {
894
969
  this.video.removeAttribute("src");
@@ -1022,7 +1097,9 @@ var YouTubeLoader = class {
1022
1097
  }
1023
1098
  const host = document.createElement("div");
1024
1099
  host.setAttribute("data-reel-yt-host", "");
1025
- host.style.cssText = "position:absolute;inset:0;width:100%;height:100%;overflow:hidden;background:#000;";
1100
+ host.style.cssText = "position:absolute;inset:0;overflow:hidden;background:#000";
1101
+ const mount = document.createElement("div");
1102
+ host.appendChild(mount);
1026
1103
  parent.appendChild(host);
1027
1104
  this.host = host;
1028
1105
  video.setAttribute(YT_HIDDEN_ATTR, "");
@@ -1035,7 +1112,7 @@ var YouTubeLoader = class {
1035
1112
  run();
1036
1113
  };
1037
1114
  try {
1038
- this.yt = new YT.Player(host, {
1115
+ this.yt = new YT.Player(mount, {
1039
1116
  videoId: id,
1040
1117
  // 100% so the iframe fills the host div rather than using default px dimensions.
1041
1118
  width: "100%",
@@ -1051,7 +1128,7 @@ var YouTubeLoader = class {
1051
1128
  onReady: () => {
1052
1129
  const iframe = host.querySelector("iframe");
1053
1130
  if (iframe) {
1054
- iframe.style.cssText = "width:100%;height:100%;border:none;display:block;";
1131
+ iframe.style.cssText = "width:100%;height:100%;border:none;display:block";
1055
1132
  }
1056
1133
  try {
1057
1134
  this.duration = this.yt?.getDuration() ?? 0;
@@ -1383,21 +1460,6 @@ function createStore(initial, options = {}) {
1383
1460
  }
1384
1461
 
1385
1462
  // src/createPlayer.ts
1386
- var NATIVE_EVENTS = [
1387
- "loadedmetadata",
1388
- "durationchange",
1389
- "timeupdate",
1390
- "play",
1391
- "pause",
1392
- "ended",
1393
- "error",
1394
- "volumechange",
1395
- "ratechange",
1396
- "seeking",
1397
- "seeked",
1398
- "waiting",
1399
- "playing"
1400
- ];
1401
1463
  function makeInitialState(options) {
1402
1464
  return {
1403
1465
  status: "idle",
@@ -1430,13 +1492,42 @@ function createPlayer(options = {}, internal = {}) {
1430
1492
  const bus = createEventBus();
1431
1493
  const commands = createCommandRegistry();
1432
1494
  const controls = createControlsRegistry();
1495
+ const sourceTransforms = createSourceTransformRegistry();
1496
+ const mediaAttachments = createMediaAttachmentRegistry();
1433
1497
  let status = "idle";
1434
1498
  let video = null;
1435
1499
  let activeLoader = null;
1436
1500
  let pendingAttachAbort = null;
1501
+ let pendingSourceLoad = null;
1502
+ let latestSourceLoadPromise = null;
1437
1503
  let disposed = false;
1438
- let initialOptionsApplied = false;
1439
1504
  let pendingPlay = false;
1505
+ function createPendingSourceLoad() {
1506
+ let resolve;
1507
+ let reject;
1508
+ const promise = new Promise((onResolve, onReject) => {
1509
+ resolve = onResolve;
1510
+ reject = onReject;
1511
+ });
1512
+ void promise.catch(() => void 0);
1513
+ latestSourceLoadPromise = promise;
1514
+ return { promise, resolve, reject };
1515
+ }
1516
+ function resolvePendingSourceLoad() {
1517
+ const load = pendingSourceLoad;
1518
+ if (!load) return;
1519
+ pendingSourceLoad = null;
1520
+ load.resolve();
1521
+ }
1522
+ function rejectPendingSourceLoad(reason) {
1523
+ const load = pendingSourceLoad;
1524
+ if (!load) return;
1525
+ pendingSourceLoad = null;
1526
+ load.reject(reason);
1527
+ }
1528
+ function createAbortError() {
1529
+ return Object.assign(new Error("Aborted"), { name: "AbortError" });
1530
+ }
1440
1531
  const YT_PLAYER_STATE_PLAYING = 1;
1441
1532
  function getYouTubePlayer() {
1442
1533
  return activeLoader?._instance?.() ?? null;
@@ -1459,11 +1550,12 @@ function createPlayer(options = {}, internal = {}) {
1459
1550
  bus.emit("qualitychange", { quality, auto });
1460
1551
  }
1461
1552
  commands.add("hls:setQuality", (level) => {
1462
- if (level === null) {
1463
- bus.emit("qualitypreference", { qualityHeight: null });
1464
- } else if (level !== null && typeof level === "object" && typeof level.height === "number") {
1465
- bus.emit("qualitypreference", { qualityHeight: level.height });
1553
+ if (level !== null && (typeof level !== "object" || typeof level.height !== "number")) {
1554
+ return;
1466
1555
  }
1556
+ bus.emit("qualitypreference", {
1557
+ qualityHeight: level === null ? null : level.height
1558
+ });
1467
1559
  const loader = activeLoader;
1468
1560
  if (typeof loader?.setQuality === "function") {
1469
1561
  loader.setQuality(level === null ? "auto" : level);
@@ -1524,7 +1616,9 @@ function createPlayer(options = {}, internal = {}) {
1524
1616
  commands,
1525
1617
  controls,
1526
1618
  store: { getState: store.getState, subscribe: store.subscribe },
1527
- bus
1619
+ bus,
1620
+ sourceTransforms,
1621
+ mediaAttachments
1528
1622
  });
1529
1623
  const plugins = createPluginBus({ player: playerRef, host });
1530
1624
  function dispatch(event) {
@@ -1536,6 +1630,10 @@ function createPlayer(options = {}, internal = {}) {
1536
1630
  for (const effect of result.effects) applyEffect(effect);
1537
1631
  if (prev !== "ready" && status === "ready") {
1538
1632
  bus.emit("ready", { duration: store.getState().duration });
1633
+ resolvePendingSourceLoad();
1634
+ }
1635
+ if (event.type === "loadFailed") {
1636
+ rejectPendingSourceLoad(event.error);
1539
1637
  }
1540
1638
  }
1541
1639
  function applyEffect(effect) {
@@ -1579,15 +1677,29 @@ function createPlayer(options = {}, internal = {}) {
1579
1677
  activeQuality: null
1580
1678
  });
1581
1679
  bus.emit("qualitychange", { quality: null, auto: true });
1680
+ if (!isSafeMediaUrl(source.src) || source.tracks?.some((track) => !isSafeMediaUrl(track.src)) || source.thumbnails && !isSafeMediaUrl(source.thumbnails.src)) {
1681
+ if (video) {
1682
+ pendingSourceLoad = createPendingSourceLoad();
1683
+ }
1684
+ const error = createPlayerError({
1685
+ code: "unsupported",
1686
+ message: "Invalid URL"
1687
+ });
1688
+ dispatch({ type: "loadFailed", error });
1689
+ return;
1690
+ }
1582
1691
  if (!video) return;
1692
+ const sourceLoad = createPendingSourceLoad();
1693
+ pendingSourceLoad = sourceLoad;
1583
1694
  const provider = registry.resolve(source);
1584
1695
  if (!provider) {
1696
+ const error = createPlayerError({
1697
+ code: "unsupported",
1698
+ message: `No source provider for ${source.src}`
1699
+ });
1585
1700
  dispatch({
1586
1701
  type: "loadFailed",
1587
- error: createPlayerError({
1588
- code: "unsupported",
1589
- message: `No source provider for ${source.src}`
1590
- })
1702
+ error
1591
1703
  });
1592
1704
  return;
1593
1705
  }
@@ -1598,8 +1710,13 @@ function createPlayer(options = {}, internal = {}) {
1598
1710
  aborted = true;
1599
1711
  try {
1600
1712
  loader.abort?.();
1601
- } catch (err) {
1602
- console.error("[@f8team/reel-core] loader.abort() threw:", err);
1713
+ } catch (error) {
1714
+ console.error(error);
1715
+ }
1716
+ if (pendingSourceLoad === sourceLoad) {
1717
+ rejectPendingSourceLoad(createAbortError());
1718
+ } else {
1719
+ sourceLoad.reject(createAbortError());
1603
1720
  }
1604
1721
  };
1605
1722
  loader.attach(video, source).then(() => {
@@ -1701,8 +1818,6 @@ function createPlayer(options = {}, internal = {}) {
1701
1818
  target.addEventListener(name, handler);
1702
1819
  nativeListeners.push(() => target.removeEventListener(name, handler));
1703
1820
  }
1704
- for (const _ of NATIVE_EVENTS) {
1705
- }
1706
1821
  on2("loadedmetadata", () => {
1707
1822
  store.setState({
1708
1823
  duration: Number.isFinite(target.duration) ? target.duration : 0,
@@ -1773,17 +1888,24 @@ function createPlayer(options = {}, internal = {}) {
1773
1888
  on2("playing", () => {
1774
1889
  bus.emit("buffering", { isBuffering: false });
1775
1890
  });
1776
- if (typeof document !== "undefined") {
1777
- const onFullscreenChange = () => {
1778
- const isFullscreen = !!document.fullscreenElement;
1779
- store.setState({ fullscreen: isFullscreen });
1780
- bus.emit("fullscreenchange", { fullscreen: isFullscreen });
1781
- };
1782
- document.addEventListener("fullscreenchange", onFullscreenChange);
1783
- nativeListeners.push(
1784
- () => document.removeEventListener("fullscreenchange", onFullscreenChange)
1785
- );
1786
- }
1891
+ on2("enterpictureinpicture", () => {
1892
+ store.setState({ pip: true });
1893
+ bus.emit("pipchange", { pip: true });
1894
+ });
1895
+ on2("leavepictureinpicture", () => {
1896
+ store.setState({ pip: false });
1897
+ bus.emit("pipchange", { pip: false });
1898
+ });
1899
+ const ownerDocument = target.ownerDocument;
1900
+ const onFullscreenChange = () => {
1901
+ const isFullscreen = !!ownerDocument.fullscreenElement?.contains(target);
1902
+ store.setState({ fullscreen: isFullscreen });
1903
+ bus.emit("fullscreenchange", { fullscreen: isFullscreen });
1904
+ };
1905
+ ownerDocument.addEventListener("fullscreenchange", onFullscreenChange);
1906
+ nativeListeners.push(
1907
+ () => ownerDocument.removeEventListener("fullscreenchange", onFullscreenChange)
1908
+ );
1787
1909
  }
1788
1910
  function unbindNativeEvents() {
1789
1911
  while (nativeListeners.length) {
@@ -1793,24 +1915,18 @@ function createPlayer(options = {}, internal = {}) {
1793
1915
  }
1794
1916
  }
1795
1917
  }
1796
- function applyInitialOptions() {
1797
- if (initialOptionsApplied || !video) return;
1798
- if (typeof options.volume === "number") video.volume = options.volume;
1799
- if (typeof options.muted === "boolean") video.muted = options.muted;
1800
- if (typeof options.playbackRate === "number") video.playbackRate = options.playbackRate;
1918
+ function applyAttachOptions() {
1919
+ if (!video) return;
1920
+ const state = store.getState();
1921
+ video.volume = state.volume;
1922
+ video.muted = state.muted;
1923
+ video.playbackRate = state.playbackRate;
1801
1924
  if (options.poster) video.poster = options.poster;
1802
1925
  if (options.preload) video.preload = options.preload;
1803
- if (options.playsInline ?? true) {
1804
- video.playsInline = true;
1805
- video.setAttribute("webkit-playsinline", "");
1806
- }
1807
- if ("crossOrigin" in options) {
1808
- if (options.crossOrigin) video.crossOrigin = options.crossOrigin;
1809
- } else {
1810
- video.crossOrigin = "anonymous";
1811
- }
1812
- if (options.loop) video.loop = true;
1813
- initialOptionsApplied = true;
1926
+ video.playsInline = options.playsInline ?? true;
1927
+ video.toggleAttribute("webkit-playsinline", video.playsInline);
1928
+ video.crossOrigin = "crossOrigin" in options ? options.crossOrigin ?? null : "anonymous";
1929
+ video.loop = !!options.loop;
1814
1930
  }
1815
1931
  const attachDisposers = [];
1816
1932
  function flushAttachDisposers() {
@@ -1834,12 +1950,21 @@ function createPlayer(options = {}, internal = {}) {
1834
1950
  }
1835
1951
  async function attach(el) {
1836
1952
  if (disposed) throw new Error("[@f8team/reel-core] cannot attach: player disposed");
1837
- if (video === el) return;
1953
+ if (video === el) {
1954
+ await latestSourceLoadPromise;
1955
+ return;
1956
+ }
1838
1957
  if (video) detach();
1839
1958
  video = el;
1959
+ mediaAttachments.set({
1960
+ video: el,
1961
+ container: el.closest("[data-reel]") ?? el.parentElement ?? el
1962
+ });
1840
1963
  bindNativeEvents(el);
1841
- applyInitialOptions();
1842
- if (options.source) dispatch({ type: "setSource", source: options.source });
1964
+ applyAttachOptions();
1965
+ const currentSource = store.getState().source;
1966
+ if (currentSource) setSource(currentSource);
1967
+ const sourceLoadPromise = latestSourceLoadPromise;
1843
1968
  const wantStartTime = typeof options.startTime === "number" && options.startTime > 0;
1844
1969
  const wantAutoplay = !!options.autoplay && options.autoplay !== "off" && !!options.source;
1845
1970
  if (status === "ready") {
@@ -1861,6 +1986,7 @@ function createPlayer(options = {}, internal = {}) {
1861
1986
  attachDisposers.push(off2);
1862
1987
  }
1863
1988
  }
1989
+ await sourceLoadPromise;
1864
1990
  }
1865
1991
  function detach() {
1866
1992
  pendingAttachAbort?.();
@@ -1874,6 +2000,7 @@ function createPlayer(options = {}, internal = {}) {
1874
2000
  }
1875
2001
  activeLoader = null;
1876
2002
  }
2003
+ mediaAttachments.set(null);
1877
2004
  video = null;
1878
2005
  }
1879
2006
  function dispose() {
@@ -1888,7 +2015,8 @@ function createPlayer(options = {}, internal = {}) {
1888
2015
  }
1889
2016
  function setSource(source) {
1890
2017
  pendingPlay = false;
1891
- dispatch({ type: "setSource", source });
2018
+ latestSourceLoadPromise = null;
2019
+ dispatch({ type: "setSource", source: source ? sourceTransforms.apply(source) : null });
1892
2020
  }
1893
2021
  function getSource() {
1894
2022
  return store.getState().source;
@@ -1896,7 +2024,7 @@ function createPlayer(options = {}, internal = {}) {
1896
2024
  function retry() {
1897
2025
  const current = store.getState().source;
1898
2026
  if (!current) return false;
1899
- dispatch({ type: "setSource", source: current });
2027
+ setSource(current);
1900
2028
  return true;
1901
2029
  }
1902
2030
  async function play() {
@@ -2266,6 +2394,27 @@ function createFocusTrap(container) {
2266
2394
  return { activate, deactivate };
2267
2395
  }
2268
2396
 
2397
+ // src/a11y/controlsFocus.ts
2398
+ var OPEN_MENU_SELECTOR = "[data-reel-menu-open]";
2399
+ function activeElementInside(container) {
2400
+ const active = (container.ownerDocument ?? document).activeElement;
2401
+ if (!(active instanceof HTMLElement)) return null;
2402
+ return container.contains(active) ? active : null;
2403
+ }
2404
+ function blurFocusInside(container) {
2405
+ activeElementInside(container)?.blur();
2406
+ }
2407
+ function shouldKeepControlsVisible(container) {
2408
+ if (container.querySelector(OPEN_MENU_SELECTOR) !== null) return true;
2409
+ const active = activeElementInside(container);
2410
+ if (!active) return false;
2411
+ try {
2412
+ return active.matches(":focus-visible");
2413
+ } catch {
2414
+ return false;
2415
+ }
2416
+ }
2417
+
2269
2418
  // src/util/time.ts
2270
2419
  var TIME_RE = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:\.(\d+))?$/;
2271
2420
  function parseTime(input) {
@@ -2304,16 +2453,20 @@ exports.THEME_PRESETS = THEME_PRESETS;
2304
2453
  exports.TOKEN_TO_CSS_VAR = TOKEN_TO_CSS_VAR;
2305
2454
  exports.announce = announce;
2306
2455
  exports.applyTheme = applyTheme;
2456
+ exports.areSourceDescriptorsEquivalent = areSourceDescriptorsEquivalent;
2457
+ exports.blurFocusInside = blurFocusInside;
2307
2458
  exports.createFocusTrap = createFocusTrap;
2308
2459
  exports.createPlayer = createPlayer;
2309
2460
  exports.definePlugin = definePlugin;
2310
2461
  exports.detectSourceType = detectSourceType;
2311
2462
  exports.formatTime = formatTime;
2463
+ exports.isSafeMediaUrl = isSafeMediaUrl;
2312
2464
  exports.looksLikeHls = looksLikeHls;
2313
2465
  exports.looksLikeProgressive = looksLikeProgressive;
2314
2466
  exports.looksLikeYouTube = looksLikeYouTube;
2315
2467
  exports.parseTime = parseTime;
2316
2468
  exports.resolveTokens = resolveTokens;
2469
+ exports.shouldKeepControlsVisible = shouldKeepControlsVisible;
2317
2470
  exports.toSeconds = toSeconds;
2318
2471
  //# sourceMappingURL=index.cjs.map
2319
2472
  //# sourceMappingURL=index.cjs.map