@xhub-reels/sdk 0.2.12 → 0.2.13

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.cjs CHANGED
@@ -4,6 +4,7 @@ var vanilla = require('zustand/vanilla');
4
4
  var react = require('react');
5
5
  var jsxRuntime = require('react/jsx-runtime');
6
6
  var Hls = require('hls.js');
7
+ var reactDom = require('react-dom');
7
8
 
8
9
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
9
10
 
@@ -106,6 +107,7 @@ var PlayerEngine = class {
106
107
  this.circuitResetTimer = null;
107
108
  this.watchTimeInterval = null;
108
109
  this.lastStatus = "idle" /* IDLE */;
110
+ this._destroyed = false;
109
111
  this.config = { ...DEFAULT_PLAYER_CONFIG, ...config };
110
112
  this.analytics = analytics;
111
113
  this.logger = logger;
@@ -315,8 +317,13 @@ var PlayerEngine = class {
315
317
  this.lastStatus = "idle" /* IDLE */;
316
318
  }
317
319
  destroy() {
320
+ if (this._destroyed) return;
321
+ this._destroyed = true;
318
322
  this.stopWatchTime();
319
- if (this.circuitResetTimer) clearTimeout(this.circuitResetTimer);
323
+ if (this.circuitResetTimer) {
324
+ clearTimeout(this.circuitResetTimer);
325
+ this.circuitResetTimer = null;
326
+ }
320
327
  this.listeners.clear();
321
328
  }
322
329
  // ═══════════════════════════════════════════
@@ -419,6 +426,8 @@ var FeedManager = class {
419
426
  this.inFlightRequests = /* @__PURE__ */ new Map();
420
427
  /** LRU tracking: itemId → lastAccessTime */
421
428
  this.accessOrder = /* @__PURE__ */ new Map();
429
+ /** Idempotency guard for destroy() */
430
+ this._destroyed = false;
422
431
  /** Prefetch cache — instance-scoped (not static) */
423
432
  this.prefetchCache = null;
424
433
  this.config = { ...DEFAULT_FEED_CONFIG, ...config };
@@ -531,7 +540,10 @@ var FeedManager = class {
531
540
  // PUBLIC API — Lifecycle
532
541
  // ═══════════════════════════════════════════
533
542
  destroy() {
543
+ if (this._destroyed) return;
544
+ this._destroyed = true;
534
545
  this.abortController?.abort();
546
+ this.abortController = null;
535
547
  this.inFlightRequests.clear();
536
548
  this.accessOrder.clear();
537
549
  this.prefetchCache = null;
@@ -600,7 +612,13 @@ var FeedManager = class {
600
612
  applyItems(incoming, _nextCursor, append) {
601
613
  const { itemsById, displayOrder } = this.store.getState();
602
614
  const nextById = new Map(itemsById);
615
+ const seenInBatch = /* @__PURE__ */ new Set();
616
+ const deduped = [];
603
617
  for (const item of incoming) {
618
+ if (!seenInBatch.has(item.id)) {
619
+ seenInBatch.add(item.id);
620
+ deduped.push(item);
621
+ }
604
622
  nextById.set(item.id, item);
605
623
  this.accessOrder.set(item.id, Date.now());
606
624
  }
@@ -608,14 +626,14 @@ var FeedManager = class {
608
626
  if (append) {
609
627
  const existingIds = new Set(displayOrder);
610
628
  const newIds = [];
611
- for (const item of incoming) {
629
+ for (const item of deduped) {
612
630
  if (!existingIds.has(item.id)) {
613
631
  newIds.push(item.id);
614
632
  }
615
633
  }
616
634
  nextOrder = [...displayOrder, ...newIds];
617
635
  } else {
618
- nextOrder = incoming.map((item) => item.id);
636
+ nextOrder = deduped.map((item) => item.id);
619
637
  }
620
638
  if (nextById.size > this.config.maxCacheSize) {
621
639
  this.evictLRU(nextById, nextOrder);
@@ -657,6 +675,8 @@ var OptimisticManager = class {
657
675
  this.likeDebounceTimers = /* @__PURE__ */ new Map();
658
676
  /** Pending like direction: contentId → final intended state */
659
677
  this.pendingLikeState = /* @__PURE__ */ new Map();
678
+ /** Idempotency guard for destroy() */
679
+ this._destroyed = false;
660
680
  this.interaction = interaction;
661
681
  this.logger = logger;
662
682
  this.store = vanilla.createStore(createInitialState3);
@@ -747,6 +767,8 @@ var OptimisticManager = class {
747
767
  // PUBLIC API — Lifecycle
748
768
  // ═══════════════════════════════════════════
749
769
  destroy() {
770
+ if (this._destroyed) return;
771
+ this._destroyed = true;
750
772
  for (const timer of this.likeDebounceTimers.values()) {
751
773
  clearTimeout(timer);
752
774
  }
@@ -768,6 +790,7 @@ var DEFAULT_RESOURCE_CONFIG = {
768
790
  var ResourceGovernor = class {
769
791
  constructor(config = {}, videoLoader, network, logger) {
770
792
  this.focusDebounceTimer = null;
793
+ this._destroyed = false;
771
794
  this.config = { ...DEFAULT_RESOURCE_CONFIG, ...config };
772
795
  this.videoLoader = videoLoader;
773
796
  this.network = network;
@@ -798,11 +821,19 @@ var ResourceGovernor = class {
798
821
  this.logger?.debug("[ResourceGovernor] Activated");
799
822
  }
800
823
  deactivate() {
824
+ if (this._destroyed) return;
825
+ if (!this.store.getState().isActive) return;
801
826
  this.networkUnsubscribe?.();
802
- if (this.focusDebounceTimer) clearTimeout(this.focusDebounceTimer);
827
+ this.networkUnsubscribe = void 0;
828
+ if (this.focusDebounceTimer) {
829
+ clearTimeout(this.focusDebounceTimer);
830
+ this.focusDebounceTimer = null;
831
+ }
803
832
  this.store.setState({ isActive: false });
804
833
  }
805
834
  destroy() {
835
+ if (this._destroyed) return;
836
+ this._destroyed = true;
806
837
  this.deactivate();
807
838
  this.videoLoader?.clearAll();
808
839
  }
@@ -926,6 +957,153 @@ var ResourceGovernor = class {
926
957
  );
927
958
  }
928
959
  };
960
+ var DEFAULT_NAVIGATION_CONFIG = {
961
+ phaseTimeoutMs: 1200
962
+ };
963
+ var VALID_NAV_TRANSITIONS = {
964
+ closed: ["opening"],
965
+ // Allow opening → closing so a fast user can dismiss mid-open.
966
+ opening: ["open", "closing", "closed"],
967
+ open: ["closing"],
968
+ // Allow closing → opening so re-opening during the exit animation works.
969
+ closing: ["closed", "opening"]
970
+ };
971
+ function isValidNavTransition(from, to) {
972
+ if (from === to) return false;
973
+ return VALID_NAV_TRANSITIONS[from].includes(to);
974
+ }
975
+ var NavigationManager = class {
976
+ constructor(config = {}, logger) {
977
+ /** Fallback timer guarding against a missing animationend report. */
978
+ this.phaseTimer = null;
979
+ this._destroyed = false;
980
+ this.config = { ...DEFAULT_NAVIGATION_CONFIG, ...config };
981
+ this.logger = logger;
982
+ this.store = vanilla.createStore(() => ({
983
+ phase: "closed",
984
+ openIndex: null
985
+ }));
986
+ }
987
+ // ═══════════════════════════════════════════
988
+ // PUBLIC API — Intent
989
+ // ═══════════════════════════════════════════
990
+ /**
991
+ * Open the reels viewer at `index`. Safe to call from a thumbnail click
992
+ * handler — it transitions into `opening`, at which point ReelsModal mounts
993
+ * <ReelsFeed> hidden and begins prewarming.
994
+ *
995
+ * Calling open() while already open/opening just retargets the index
996
+ * (e.g. user taps a different thumbnail before the animation settles).
997
+ */
998
+ open(index) {
999
+ if (this._destroyed) return;
1000
+ const { phase, openIndex } = this.store.getState();
1001
+ if ((phase === "open" || phase === "opening") && index === openIndex) {
1002
+ return;
1003
+ }
1004
+ if (phase === "open" || phase === "opening") {
1005
+ this.store.setState({ openIndex: index });
1006
+ this.logger?.debug(`[NavigationManager] retarget openIndex=${index}`);
1007
+ return;
1008
+ }
1009
+ if (!this.transition("opening")) return;
1010
+ this.store.setState({ openIndex: index });
1011
+ this.logger?.debug(`[NavigationManager] open(${index}) \u2192 opening`);
1012
+ }
1013
+ /**
1014
+ * Begin closing the viewer. ReelsModal animates out then calls
1015
+ * `setPhase('closed')`. Idempotent if already closing/closed.
1016
+ */
1017
+ close() {
1018
+ if (this._destroyed) return;
1019
+ const { phase } = this.store.getState();
1020
+ if (phase === "closed" || phase === "closing") return;
1021
+ this.transition("closing");
1022
+ this.logger?.debug("[NavigationManager] close() \u2192 closing");
1023
+ }
1024
+ // ═══════════════════════════════════════════
1025
+ // PUBLIC API — Phase reporting (called by ReelsModal)
1026
+ // ═══════════════════════════════════════════
1027
+ /**
1028
+ * Report an animation-driven phase change from the component layer.
1029
+ * Invalid transitions are dropped. When reaching `closed`, openIndex is
1030
+ * cleared so <ReelsFeed> unmounts.
1031
+ */
1032
+ setPhase(next) {
1033
+ if (this._destroyed) return;
1034
+ this.transition(next);
1035
+ }
1036
+ // ═══════════════════════════════════════════
1037
+ // PUBLIC API — Queries
1038
+ // ═══════════════════════════════════════════
1039
+ getPhase() {
1040
+ return this.store.getState().phase;
1041
+ }
1042
+ getOpenIndex() {
1043
+ return this.store.getState().openIndex;
1044
+ }
1045
+ isOpen() {
1046
+ const { phase } = this.store.getState();
1047
+ return phase === "open" || phase === "opening";
1048
+ }
1049
+ /** Whether <ReelsFeed> should be mounted (any non-closed phase). */
1050
+ shouldMount() {
1051
+ return this.store.getState().phase !== "closed";
1052
+ }
1053
+ // ═══════════════════════════════════════════
1054
+ // PUBLIC API — Lifecycle
1055
+ // ═══════════════════════════════════════════
1056
+ destroy() {
1057
+ if (this._destroyed) return;
1058
+ this._destroyed = true;
1059
+ if (this.phaseTimer) {
1060
+ clearTimeout(this.phaseTimer);
1061
+ this.phaseTimer = null;
1062
+ }
1063
+ }
1064
+ // ═══════════════════════════════════════════
1065
+ // PRIVATE
1066
+ // ═══════════════════════════════════════════
1067
+ /**
1068
+ * Apply a guarded phase transition. Returns true if it was applied.
1069
+ * Manages the fallback timer for transient phases (opening/closing).
1070
+ */
1071
+ transition(next) {
1072
+ const { phase } = this.store.getState();
1073
+ if (!isValidNavTransition(phase, next)) {
1074
+ this.logger?.debug(
1075
+ `[NavigationManager] dropped invalid transition ${phase} \u2192 ${next}`
1076
+ );
1077
+ return false;
1078
+ }
1079
+ this.clearPhaseTimer();
1080
+ if (next === "closed") {
1081
+ this.store.setState({ phase: "closed", openIndex: null });
1082
+ return true;
1083
+ }
1084
+ this.store.setState({ phase: next });
1085
+ if (next === "opening" || next === "closing") {
1086
+ const fallbackTarget = next === "opening" ? "open" : "closed";
1087
+ this.phaseTimer = setTimeout(() => {
1088
+ this.phaseTimer = null;
1089
+ const current = this.store.getState().phase;
1090
+ if (current === next) {
1091
+ this.logger?.warn(
1092
+ `[NavigationManager] phase fallback ${next} \u2192 ${fallbackTarget}`
1093
+ );
1094
+ this.transition(fallbackTarget);
1095
+ }
1096
+ }, this.config.phaseTimeoutMs);
1097
+ }
1098
+ return true;
1099
+ }
1100
+ clearPhaseTimer() {
1101
+ if (this.phaseTimer) {
1102
+ clearTimeout(this.phaseTimer);
1103
+ this.phaseTimer = null;
1104
+ }
1105
+ }
1106
+ };
929
1107
  function usePointerGesture(config = {}) {
930
1108
  const {
931
1109
  axis = "y",
@@ -1115,7 +1293,9 @@ function useSnapAnimation(config = {}) {
1115
1293
  }
1116
1294
  );
1117
1295
  anim.addEventListener("finish", () => {
1118
- element.style.transform = `translateY(${toY}px)`;
1296
+ if (element.isConnected) {
1297
+ element.style.transform = `translateY(${toY}px)`;
1298
+ }
1119
1299
  anim.cancel();
1120
1300
  });
1121
1301
  animations.push(anim);
@@ -1143,39 +1323,100 @@ function useSnapAnimation(config = {}) {
1143
1323
  }, [cancelAnimation]);
1144
1324
  return { animateSnap, animateBounceBack, cancelAnimation };
1145
1325
  }
1326
+ var noop = () => {
1327
+ };
1328
+ var noopAsync = () => Promise.resolve();
1329
+ var DEFAULT_LOGGER = {
1330
+ debug: noop,
1331
+ info: noop,
1332
+ warn: noop,
1333
+ error: noop
1334
+ };
1335
+ var DEFAULT_ANALYTICS = {
1336
+ trackView: noop,
1337
+ trackLike: noop,
1338
+ trackShare: noop,
1339
+ trackComment: noop,
1340
+ trackError: noop,
1341
+ trackPlaybackEvent: noop
1342
+ };
1343
+ var DEFAULT_INTERACTION = {
1344
+ like: noopAsync,
1345
+ unlike: noopAsync,
1346
+ follow: noopAsync,
1347
+ unfollow: noopAsync,
1348
+ bookmark: noopAsync,
1349
+ unbookmark: noopAsync,
1350
+ share: noopAsync
1351
+ };
1352
+ var DEFAULT_STORAGE = {
1353
+ get: () => null,
1354
+ set: noop,
1355
+ remove: noop,
1356
+ clear: noop
1357
+ };
1358
+ var DEFAULT_NETWORK = {
1359
+ getNetworkType: () => "unknown",
1360
+ isOnline: () => true,
1361
+ onNetworkChange: () => noop
1362
+ };
1363
+ var DEFAULT_VIDEO_LOADER = {
1364
+ preload: (videoId) => Promise.resolve({ videoId, status: "idle" }),
1365
+ cancel: noop,
1366
+ isPreloaded: () => false,
1367
+ getPreloadStatus: () => "idle",
1368
+ clearAll: noop
1369
+ };
1370
+ var DEFAULT_COMMENT = {
1371
+ fetchComments: () => Promise.resolve({ items: [], nextCursor: null, hasMore: false, total: 0 }),
1372
+ postComment: () => Promise.reject(new Error("Comment adapter not configured")),
1373
+ deleteComment: noopAsync,
1374
+ likeComment: noopAsync,
1375
+ unlikeComment: noopAsync
1376
+ };
1146
1377
  var SDKContext = react.createContext(null);
1147
1378
  function ReelsProvider({
1148
1379
  children,
1149
1380
  adapters,
1150
1381
  initialItems,
1151
- debug = false
1382
+ debug = false,
1383
+ navigationConfig
1152
1384
  }) {
1153
- const logger = adapters.logger;
1154
- const sdkRef = react.useRef(null);
1385
+ const resolvedAdapters = react.useMemo(() => ({
1386
+ dataSource: adapters.dataSource,
1387
+ interaction: adapters.interaction ?? DEFAULT_INTERACTION,
1388
+ storage: adapters.storage ?? DEFAULT_STORAGE,
1389
+ analytics: adapters.analytics ?? DEFAULT_ANALYTICS,
1390
+ logger: adapters.logger ?? DEFAULT_LOGGER,
1391
+ network: adapters.network ?? DEFAULT_NETWORK,
1392
+ videoLoader: adapters.videoLoader ?? DEFAULT_VIDEO_LOADER,
1393
+ comment: adapters.comment ?? DEFAULT_COMMENT
1394
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1395
+ }), [adapters.dataSource]);
1396
+ const logger = resolvedAdapters.logger;
1397
+ const prevInstanceRef = react.useRef(null);
1155
1398
  const value = react.useMemo(() => {
1156
- if (sdkRef.current) {
1157
- sdkRef.current.feedManager.destroy();
1158
- sdkRef.current.playerEngine.destroy();
1159
- sdkRef.current.resourceGovernor.destroy();
1160
- sdkRef.current.optimisticManager.destroy();
1161
- }
1162
1399
  const feedManager = new FeedManager(adapters.dataSource, {}, logger);
1163
1400
  if (initialItems && initialItems.length > 0) {
1164
1401
  feedManager.setInitialItems(initialItems);
1165
1402
  }
1166
1403
  const playerEngine = new PlayerEngine(
1167
1404
  {},
1168
- adapters.analytics,
1405
+ resolvedAdapters.analytics,
1169
1406
  logger
1170
1407
  );
1171
1408
  const resourceGovernor = new ResourceGovernor(
1172
1409
  {},
1173
- adapters.videoLoader,
1174
- adapters.network,
1410
+ resolvedAdapters.videoLoader,
1411
+ resolvedAdapters.network,
1175
1412
  logger
1176
1413
  );
1177
1414
  const optimisticManager = new OptimisticManager(
1178
- adapters.interaction ?? {},
1415
+ resolvedAdapters.interaction,
1416
+ logger
1417
+ );
1418
+ const navigationManager = new NavigationManager(
1419
+ navigationConfig ?? {},
1179
1420
  logger
1180
1421
  );
1181
1422
  const instance = {
@@ -1183,15 +1424,27 @@ function ReelsProvider({
1183
1424
  playerEngine,
1184
1425
  resourceGovernor,
1185
1426
  optimisticManager,
1186
- adapters
1427
+ navigationManager,
1428
+ adapters: resolvedAdapters
1187
1429
  };
1188
- sdkRef.current = instance;
1189
1430
  return instance;
1190
1431
  }, [adapters.dataSource]);
1191
1432
  react.useEffect(() => {
1192
- value.resourceGovernor.activate();
1433
+ const prev = prevInstanceRef.current;
1434
+ if (prev && prev !== value) {
1435
+ prev.feedManager.destroy();
1436
+ prev.playerEngine.destroy();
1437
+ prev.resourceGovernor.destroy();
1438
+ prev.optimisticManager.destroy();
1439
+ prev.navigationManager.destroy();
1440
+ }
1441
+ prevInstanceRef.current = value;
1442
+ }, [value]);
1443
+ react.useEffect(() => {
1444
+ const governor = value.resourceGovernor;
1445
+ governor.activate();
1193
1446
  return () => {
1194
- value.resourceGovernor.deactivate();
1447
+ governor.deactivate();
1195
1448
  };
1196
1449
  }, [value.resourceGovernor]);
1197
1450
  react.useEffect(() => {
@@ -1201,10 +1454,11 @@ function ReelsProvider({
1201
1454
  }, [debug, logger]);
1202
1455
  react.useEffect(() => {
1203
1456
  return () => {
1204
- sdkRef.current?.feedManager.destroy();
1205
- sdkRef.current?.playerEngine.destroy();
1206
- sdkRef.current?.resourceGovernor.destroy();
1207
- sdkRef.current?.optimisticManager.destroy();
1457
+ prevInstanceRef.current?.feedManager.destroy();
1458
+ prevInstanceRef.current?.playerEngine.destroy();
1459
+ prevInstanceRef.current?.resourceGovernor.destroy();
1460
+ prevInstanceRef.current?.optimisticManager.destroy();
1461
+ prevInstanceRef.current?.navigationManager.destroy();
1208
1462
  };
1209
1463
  }, []);
1210
1464
  return /* @__PURE__ */ jsxRuntime.jsx(SDKContext.Provider, { value, children });
@@ -1708,25 +1962,34 @@ var PLAY_AHEAD_MAX_CONCURRENT = 2;
1708
1962
  var PLAY_AHEAD_STAGGER_MS = 80;
1709
1963
  var _playAheadActive = 0;
1710
1964
  var _playAheadQueue = [];
1965
+ var _tokenCounter = 0;
1966
+ var _releasedTokens = /* @__PURE__ */ new Set();
1711
1967
  function acquirePlayAhead() {
1968
+ const token = ++_tokenCounter;
1969
+ const makeRelease = () => {
1970
+ let released = false;
1971
+ return () => {
1972
+ if (released) return;
1973
+ released = true;
1974
+ _releasedTokens.add(token);
1975
+ _playAheadActive = Math.max(0, _playAheadActive - 1);
1976
+ const next = _playAheadQueue.shift();
1977
+ if (next) {
1978
+ next();
1979
+ }
1980
+ };
1981
+ };
1712
1982
  if (_playAheadActive < PLAY_AHEAD_MAX_CONCURRENT) {
1713
1983
  _playAheadActive++;
1714
- return Promise.resolve();
1984
+ return Promise.resolve(makeRelease());
1715
1985
  }
1716
1986
  return new Promise((resolve) => {
1717
1987
  _playAheadQueue.push(() => {
1718
- setTimeout(resolve, PLAY_AHEAD_STAGGER_MS);
1988
+ _playAheadActive++;
1989
+ setTimeout(() => resolve(makeRelease()), PLAY_AHEAD_STAGGER_MS);
1719
1990
  });
1720
1991
  });
1721
1992
  }
1722
- function releasePlayAhead() {
1723
- _playAheadActive = Math.max(0, _playAheadActive - 1);
1724
- const next = _playAheadQueue.shift();
1725
- if (next) {
1726
- _playAheadActive++;
1727
- next();
1728
- }
1729
- }
1730
1993
  function VideoSlot({
1731
1994
  item,
1732
1995
  index,
@@ -1849,6 +2112,42 @@ function VideoSlotInner({
1849
2112
  }
1850
2113
  }, [mp4Src, isActive, isPrefetch, isPreloaded, isHlsSource]);
1851
2114
  const isReady = isHlsSource ? hlsReady : mp4Ready;
2115
+ const [capturedPoster, setCapturedPoster] = react.useState(null);
2116
+ react.useEffect(() => {
2117
+ const video = videoRef.current;
2118
+ if (!video || !shouldLoadSrc) return;
2119
+ let cancelled = false;
2120
+ const captureFrame = () => {
2121
+ if (cancelled) return;
2122
+ if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
2123
+ if (video.videoWidth === 0 || video.videoHeight === 0) return;
2124
+ try {
2125
+ const canvas = document.createElement("canvas");
2126
+ canvas.width = video.videoWidth;
2127
+ canvas.height = video.videoHeight;
2128
+ const ctx = canvas.getContext("2d");
2129
+ if (!ctx) return;
2130
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
2131
+ const dataUrl = canvas.toDataURL("image/webp", 0.85);
2132
+ if (!cancelled) {
2133
+ setCapturedPoster(dataUrl);
2134
+ }
2135
+ } catch {
2136
+ }
2137
+ };
2138
+ if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth > 0) {
2139
+ captureFrame();
2140
+ } else {
2141
+ video.addEventListener("loadeddata", captureFrame, { once: true });
2142
+ }
2143
+ return () => {
2144
+ cancelled = true;
2145
+ video.removeEventListener("loadeddata", captureFrame);
2146
+ };
2147
+ }, [src, shouldLoadSrc]);
2148
+ react.useEffect(() => {
2149
+ setCapturedPoster(null);
2150
+ }, [src]);
1852
2151
  const [isVideoPlaying, setIsVideoPlaying] = react.useState(false);
1853
2152
  react.useEffect(() => {
1854
2153
  const video = videoRef.current;
@@ -1872,32 +2171,38 @@ function VideoSlotInner({
1872
2171
  const prevMuted = video.muted;
1873
2172
  video.muted = true;
1874
2173
  let cancelled = false;
2174
+ let release = null;
1875
2175
  const doPlayAhead = async () => {
1876
- await acquirePlayAhead();
2176
+ release = await acquirePlayAhead();
2177
+ if (cancelled) {
2178
+ release();
2179
+ return;
2180
+ }
1877
2181
  try {
1878
2182
  await video.play();
1879
2183
  if (cancelled) {
1880
2184
  video.pause();
1881
- releasePlayAhead();
2185
+ release();
1882
2186
  return;
1883
2187
  }
1884
2188
  const pauseAfterDecode = () => {
1885
2189
  video.pause();
1886
2190
  video.currentTime = 0;
1887
2191
  video.muted = prevMuted;
1888
- releasePlayAhead();
2192
+ release?.();
1889
2193
  if (!cancelled) {
1890
2194
  setHasPlayedAhead(true);
1891
2195
  }
1892
2196
  };
1893
2197
  setTimeout(pauseAfterDecode, 50);
1894
2198
  } catch {
1895
- releasePlayAhead();
2199
+ release?.();
1896
2200
  }
1897
2201
  };
1898
2202
  doPlayAhead();
1899
2203
  return () => {
1900
2204
  cancelled = true;
2205
+ release?.();
1901
2206
  };
1902
2207
  }, [isActive, isReady, hasPlayedAhead]);
1903
2208
  react.useEffect(() => {
@@ -2103,13 +2408,13 @@ function VideoSlotInner({
2103
2408
  }
2104
2409
  }
2105
2410
  ),
2106
- item.poster && !isPreDecoded && /* @__PURE__ */ jsxRuntime.jsx(
2411
+ (capturedPoster || item.poster) && !isPreDecoded && /* @__PURE__ */ jsxRuntime.jsx(
2107
2412
  "div",
2108
2413
  {
2109
2414
  style: {
2110
2415
  position: "absolute",
2111
2416
  inset: 0,
2112
- backgroundImage: `url(${item.poster})`,
2417
+ backgroundImage: `url(${capturedPoster || item.poster})`,
2113
2418
  backgroundSize: "cover",
2114
2419
  backgroundPosition: "center",
2115
2420
  opacity: showPosterOverlay ? 1 : 0,
@@ -2321,7 +2626,7 @@ function ReelsFeed({
2321
2626
  }, 16);
2322
2627
  };
2323
2628
  const observer = new MutationObserver(debouncedRebuild);
2324
- observer.observe(container, { childList: true, subtree: true });
2629
+ observer.observe(container, { childList: true, subtree: false });
2325
2630
  return () => {
2326
2631
  observer.disconnect();
2327
2632
  if (rebuildTimer !== null) clearTimeout(rebuildTimer);
@@ -2558,6 +2863,56 @@ function parsePxTranslateY(el) {
2558
2863
  if (!match || !match[1]) return 0;
2559
2864
  return Number.parseFloat(match[1]);
2560
2865
  }
2866
+ function useNavigationSelector(selector) {
2867
+ const { navigationManager } = useSDK();
2868
+ const selectorRef = react.useRef(selector);
2869
+ selectorRef.current = selector;
2870
+ const lastSnapshot = react.useRef(void 0);
2871
+ const lastState = react.useRef(void 0);
2872
+ const getSnapshot = react.useCallback(() => {
2873
+ const state = navigationManager.store.getState();
2874
+ if (state !== lastState.current) {
2875
+ lastState.current = state;
2876
+ lastSnapshot.current = selectorRef.current(state);
2877
+ }
2878
+ return lastSnapshot.current;
2879
+ }, [navigationManager]);
2880
+ return react.useSyncExternalStore(
2881
+ navigationManager.store.subscribe,
2882
+ getSnapshot,
2883
+ getSnapshot
2884
+ );
2885
+ }
2886
+ function useNavigation() {
2887
+ const { navigationManager } = useSDK();
2888
+ const selectPhase = react.useCallback((s) => s.phase, []);
2889
+ const selectOpenIndex = react.useCallback((s) => s.openIndex, []);
2890
+ const phase = useNavigationSelector(selectPhase);
2891
+ const openIndex = useNavigationSelector(selectOpenIndex);
2892
+ const isOpen = phase === "open" || phase === "opening";
2893
+ const shouldMount = phase !== "closed";
2894
+ const open = react.useCallback(
2895
+ (index) => navigationManager.open(index),
2896
+ [navigationManager]
2897
+ );
2898
+ const close = react.useCallback(
2899
+ () => navigationManager.close(),
2900
+ [navigationManager]
2901
+ );
2902
+ const setPhase = react.useCallback(
2903
+ (next) => navigationManager.setPhase(next),
2904
+ [navigationManager]
2905
+ );
2906
+ return {
2907
+ phase,
2908
+ openIndex,
2909
+ isOpen,
2910
+ shouldMount,
2911
+ open,
2912
+ close,
2913
+ setPhase
2914
+ };
2915
+ }
2561
2916
  function ReelsFeedThumbnail({
2562
2917
  renderThumbnail,
2563
2918
  onThumbnailClick,
@@ -2567,33 +2922,48 @@ function ReelsFeedThumbnail({
2567
2922
  className = "grid grid-cols-2 gap-3",
2568
2923
  wrap = true,
2569
2924
  setFocusOnClick = true,
2925
+ openOnClick = false,
2570
2926
  prefetchOnHover = false,
2927
+ prewarmOnClick = true,
2571
2928
  getKey
2572
2929
  }) {
2573
2930
  const { items, loading, error, refresh } = useFeed();
2574
2931
  const { setFocusedIndexImmediate } = useResource();
2932
+ const { open } = useNavigation();
2575
2933
  const { adapters } = useSDK();
2576
2934
  const prefetchedRef = react.useRef(/* @__PURE__ */ new Set());
2935
+ const prewarm = react.useCallback(
2936
+ (item) => {
2937
+ if (!isVideoItem(item)) return;
2938
+ if (item.source.type !== "hls") return;
2939
+ const url = item.source.url;
2940
+ if (prefetchedRef.current.has(url)) return;
2941
+ prefetchedRef.current.add(url);
2942
+ adapters.videoLoader?.preloadMetadata?.(url);
2943
+ },
2944
+ [adapters.videoLoader]
2945
+ );
2577
2946
  const handleClick = react.useCallback(
2578
2947
  (id, item, index) => {
2948
+ if (prewarmOnClick) {
2949
+ prewarm(item);
2950
+ }
2579
2951
  if (setFocusOnClick) {
2580
2952
  setFocusedIndexImmediate(index);
2581
2953
  }
2954
+ if (openOnClick) {
2955
+ open(index);
2956
+ }
2582
2957
  onThumbnailClick?.(id, item, index);
2583
2958
  },
2584
- [setFocusOnClick, setFocusedIndexImmediate, onThumbnailClick]
2959
+ [prewarmOnClick, prewarm, setFocusOnClick, setFocusedIndexImmediate, openOnClick, open, onThumbnailClick]
2585
2960
  );
2586
2961
  const handlePointerEnter = react.useCallback(
2587
2962
  (item) => {
2588
2963
  if (!prefetchOnHover) return;
2589
- if (!isVideoItem(item)) return;
2590
- if (item.source.type !== "hls") return;
2591
- const url = item.source.url;
2592
- if (prefetchedRef.current.has(url)) return;
2593
- prefetchedRef.current.add(url);
2594
- adapters.videoLoader?.preloadMetadata?.(url);
2964
+ prewarm(item);
2595
2965
  },
2596
- [prefetchOnHover, adapters.videoLoader]
2966
+ [prefetchOnHover, prewarm]
2597
2967
  );
2598
2968
  if (loading && items.length === 0) {
2599
2969
  if (renderLoading) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderLoading() });
@@ -2633,6 +3003,309 @@ function ReelsFeedThumbnail({
2633
3003
  }
2634
3004
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className, children: content });
2635
3005
  }
3006
+ var DEFAULT_DURATION = 300;
3007
+ var DEFAULT_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
3008
+ var DEFAULT_DIRECTION = "up";
3009
+ var DEFAULT_Z_INDEX = 1e3;
3010
+ var DEFAULT_PREWARM_FORWARD = 2;
3011
+ function offscreenTransform(direction) {
3012
+ switch (direction) {
3013
+ case "down":
3014
+ return "translateY(-100%)";
3015
+ case "fade":
3016
+ return "translateY(0)";
3017
+ case "up":
3018
+ default:
3019
+ return "translateY(100%)";
3020
+ }
3021
+ }
3022
+ function prefersReducedMotion() {
3023
+ if (typeof window === "undefined" || !window.matchMedia) return false;
3024
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3025
+ }
3026
+ var FOCUSABLE = 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
3027
+ function ReelsModal({
3028
+ feedProps,
3029
+ animationConfig,
3030
+ renderBackdrop,
3031
+ renderCloseButton,
3032
+ onOpen,
3033
+ onClose,
3034
+ closeOnBackdropClick = true,
3035
+ closeOnEscape = true,
3036
+ lockBodyScroll = true,
3037
+ prewarmForward = DEFAULT_PREWARM_FORWARD,
3038
+ className,
3039
+ zIndex = DEFAULT_Z_INDEX,
3040
+ portalTarget
3041
+ }) {
3042
+ const { phase, openIndex, shouldMount, close, setPhase } = useNavigation();
3043
+ const { setFocusedIndexImmediate } = useResource();
3044
+ const { items } = useFeed();
3045
+ const { adapters } = useSDK();
3046
+ const duration = animationConfig?.duration ?? DEFAULT_DURATION;
3047
+ const easing = animationConfig?.easing ?? DEFAULT_EASING;
3048
+ const direction = animationConfig?.direction ?? DEFAULT_DIRECTION;
3049
+ const backdropRef = react.useRef(null);
3050
+ const panelRef = react.useRef(null);
3051
+ const animationsRef = react.useRef([]);
3052
+ const previouslyFocusedRef = react.useRef(null);
3053
+ const renderState = react.useMemo(
3054
+ () => ({ phase, openIndex, close }),
3055
+ [phase, openIndex, close]
3056
+ );
3057
+ const prewarmedRef = react.useRef(null);
3058
+ react.useEffect(() => {
3059
+ if (phase !== "opening") {
3060
+ if (phase === "closed") prewarmedRef.current = null;
3061
+ return;
3062
+ }
3063
+ if (openIndex == null) return;
3064
+ if (prewarmedRef.current === openIndex) return;
3065
+ prewarmedRef.current = openIndex;
3066
+ setFocusedIndexImmediate(openIndex);
3067
+ const preload = adapters.videoLoader?.preloadMetadata;
3068
+ if (!preload) return;
3069
+ const targets = /* @__PURE__ */ new Set();
3070
+ targets.add(openIndex);
3071
+ for (let d = 1; d <= prewarmForward; d++) targets.add(openIndex + d);
3072
+ targets.add(openIndex - 1);
3073
+ for (const idx of targets) {
3074
+ if (idx < 0 || idx >= items.length) continue;
3075
+ const item = items[idx];
3076
+ if (item && isVideoItem(item) && item.source.type === "hls") {
3077
+ preload(item.source.url);
3078
+ }
3079
+ }
3080
+ }, [phase, openIndex, items, adapters.videoLoader, prewarmForward, setFocusedIndexImmediate]);
3081
+ react.useEffect(() => {
3082
+ if (!lockBodyScroll || !shouldMount) return;
3083
+ if (typeof document === "undefined") return;
3084
+ const prev = document.body.style.overflow;
3085
+ document.body.style.overflow = "hidden";
3086
+ return () => {
3087
+ document.body.style.overflow = prev;
3088
+ };
3089
+ }, [lockBodyScroll, shouldMount]);
3090
+ const cancelAnimations = react.useCallback(() => {
3091
+ for (const a of animationsRef.current) a.cancel();
3092
+ animationsRef.current = [];
3093
+ }, []);
3094
+ react.useLayoutEffect(() => {
3095
+ const panel = panelRef.current;
3096
+ const backdrop = backdropRef.current;
3097
+ if (!panel) return;
3098
+ if (phase === "open" || phase === "closed") return;
3099
+ const reduce = prefersReducedMotion();
3100
+ const enter = phase === "opening";
3101
+ const offscreen2 = offscreenTransform(direction);
3102
+ const panelFrames = direction === "fade" ? enter ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0 }] : enter ? [{ transform: offscreen2 }, { transform: "translateY(0)" }] : [{ transform: "translateY(0)" }, { transform: offscreen2 }];
3103
+ const backdropFrames = enter ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0 }];
3104
+ const animDuration = reduce ? 0 : duration;
3105
+ let cancelled = false;
3106
+ cancelAnimations();
3107
+ const anims = [];
3108
+ const panelAnim = panel.animate(panelFrames, {
3109
+ duration: animDuration,
3110
+ easing,
3111
+ fill: "forwards"
3112
+ });
3113
+ anims.push(panelAnim);
3114
+ if (backdrop) {
3115
+ anims.push(
3116
+ backdrop.animate(backdropFrames, {
3117
+ duration: animDuration,
3118
+ easing,
3119
+ fill: "forwards"
3120
+ })
3121
+ );
3122
+ }
3123
+ animationsRef.current = anims;
3124
+ const finalize = () => {
3125
+ if (cancelled) return;
3126
+ if (enter) {
3127
+ if (direction === "fade") panel.style.opacity = "1";
3128
+ else panel.style.transform = "translateY(0)";
3129
+ if (backdrop) backdrop.style.opacity = "1";
3130
+ } else {
3131
+ if (direction === "fade") panel.style.opacity = "0";
3132
+ else panel.style.transform = offscreen2;
3133
+ if (backdrop) backdrop.style.opacity = "0";
3134
+ }
3135
+ for (const a of anims) a.cancel();
3136
+ animationsRef.current = [];
3137
+ setPhase(enter ? "open" : "closed");
3138
+ };
3139
+ panelAnim.addEventListener("finish", finalize);
3140
+ return () => {
3141
+ cancelled = true;
3142
+ panelAnim.removeEventListener("finish", finalize);
3143
+ cancelAnimations();
3144
+ };
3145
+ }, [phase, direction, duration, easing, setPhase, cancelAnimations]);
3146
+ const prevPhaseRef = react.useRef("closed");
3147
+ react.useEffect(() => {
3148
+ const prev = prevPhaseRef.current;
3149
+ if (prev !== "open" && phase === "open") onOpen?.(openIndex);
3150
+ if (prev !== "closed" && phase === "closed") onClose?.();
3151
+ prevPhaseRef.current = phase;
3152
+ }, [phase, openIndex, onOpen, onClose]);
3153
+ react.useEffect(() => {
3154
+ if (phase === "opening" && typeof document !== "undefined") {
3155
+ previouslyFocusedRef.current = document.activeElement;
3156
+ const id = requestAnimationFrame(() => {
3157
+ const panel = panelRef.current;
3158
+ if (!panel) return;
3159
+ const first = panel.querySelector(FOCUSABLE);
3160
+ (first ?? panel).focus();
3161
+ });
3162
+ return () => cancelAnimationFrame(id);
3163
+ }
3164
+ if (phase === "closed") {
3165
+ previouslyFocusedRef.current?.focus?.();
3166
+ previouslyFocusedRef.current = null;
3167
+ }
3168
+ return void 0;
3169
+ }, [phase]);
3170
+ const handleKeyDown = react.useCallback(
3171
+ (e) => {
3172
+ if (closeOnEscape && e.key === "Escape") {
3173
+ e.stopPropagation();
3174
+ close();
3175
+ return;
3176
+ }
3177
+ if (e.key !== "Tab") return;
3178
+ const panel = panelRef.current;
3179
+ if (!panel) return;
3180
+ const focusables = Array.from(
3181
+ panel.querySelectorAll(FOCUSABLE)
3182
+ ).filter((el) => el.offsetParent !== null);
3183
+ if (focusables.length === 0) {
3184
+ e.preventDefault();
3185
+ panel.focus();
3186
+ return;
3187
+ }
3188
+ const first = focusables[0];
3189
+ const last = focusables[focusables.length - 1];
3190
+ const active = document.activeElement;
3191
+ if (e.shiftKey && active === first) {
3192
+ e.preventDefault();
3193
+ last.focus();
3194
+ } else if (!e.shiftKey && active === last) {
3195
+ e.preventDefault();
3196
+ first.focus();
3197
+ }
3198
+ },
3199
+ [closeOnEscape, close]
3200
+ );
3201
+ const handleBackdropClick = react.useCallback(() => {
3202
+ if (closeOnBackdropClick) close();
3203
+ }, [closeOnBackdropClick, close]);
3204
+ if (!shouldMount) return null;
3205
+ if (typeof document === "undefined") return null;
3206
+ const target = portalTarget ?? document.body;
3207
+ if (!target) return null;
3208
+ const enterStart = phase === "opening";
3209
+ const offscreen = offscreenTransform(direction);
3210
+ const panelInitialTransform = direction === "fade" ? "translateY(0)" : enterStart ? offscreen : "translateY(0)";
3211
+ const panelInitialOpacity = direction === "fade" ? enterStart ? 0 : 1 : 1;
3212
+ const backdropInitialOpacity = enterStart ? 0 : 1;
3213
+ const rootStyle = {
3214
+ position: "fixed",
3215
+ inset: 0,
3216
+ zIndex,
3217
+ // While opening, the panel is parked off-screen but MUST stay painted so
3218
+ // the <video> decodes its first frame. overflow:hidden keeps the
3219
+ // off-screen panel from creating scrollbars / capturing stray taps.
3220
+ overflow: "hidden"
3221
+ };
3222
+ const backdropStyle = {
3223
+ position: "absolute",
3224
+ inset: 0,
3225
+ opacity: backdropInitialOpacity,
3226
+ background: renderBackdrop ? "transparent" : "rgba(0,0,0,0.85)",
3227
+ // willChange hints the compositor for the opacity fade.
3228
+ willChange: "opacity"
3229
+ };
3230
+ const panelStyle = {
3231
+ position: "absolute",
3232
+ inset: 0,
3233
+ transform: panelInitialTransform,
3234
+ opacity: panelInitialOpacity,
3235
+ willChange: direction === "fade" ? "opacity" : "transform",
3236
+ outline: "none"
3237
+ };
3238
+ return reactDom.createPortal(
3239
+ /* @__PURE__ */ jsxRuntime.jsxs(
3240
+ "div",
3241
+ {
3242
+ style: rootStyle,
3243
+ onKeyDown: handleKeyDown,
3244
+ "data-reels-modal-phase": phase,
3245
+ children: [
3246
+ /* @__PURE__ */ jsxRuntime.jsx(
3247
+ "div",
3248
+ {
3249
+ ref: backdropRef,
3250
+ style: backdropStyle,
3251
+ onClick: handleBackdropClick,
3252
+ "aria-hidden": "true",
3253
+ "data-reels-modal-backdrop": true,
3254
+ children: renderBackdrop ? renderBackdrop(renderState) : null
3255
+ }
3256
+ ),
3257
+ /* @__PURE__ */ jsxRuntime.jsxs(
3258
+ "div",
3259
+ {
3260
+ ref: panelRef,
3261
+ role: "dialog",
3262
+ "aria-modal": "true",
3263
+ "aria-label": "Reels viewer",
3264
+ tabIndex: -1,
3265
+ className,
3266
+ style: panelStyle,
3267
+ "data-reels-modal-panel": true,
3268
+ children: [
3269
+ /* @__PURE__ */ jsxRuntime.jsx(ReelsFeed, { ...feedProps }),
3270
+ renderCloseButton ? renderCloseButton(renderState) : /* @__PURE__ */ jsxRuntime.jsx(DefaultCloseButton, { onClose: close })
3271
+ ]
3272
+ }
3273
+ )
3274
+ ]
3275
+ }
3276
+ ),
3277
+ target
3278
+ );
3279
+ }
3280
+ function DefaultCloseButton({ onClose }) {
3281
+ return /* @__PURE__ */ jsxRuntime.jsx(
3282
+ "button",
3283
+ {
3284
+ type: "button",
3285
+ onClick: onClose,
3286
+ "aria-label": "Close reels viewer",
3287
+ style: {
3288
+ position: "absolute",
3289
+ top: "max(12px, env(safe-area-inset-top))",
3290
+ right: 12,
3291
+ width: 40,
3292
+ height: 40,
3293
+ display: "flex",
3294
+ alignItems: "center",
3295
+ justifyContent: "center",
3296
+ borderRadius: "50%",
3297
+ border: "none",
3298
+ background: "rgba(0,0,0,0.45)",
3299
+ color: "#fff",
3300
+ fontSize: 22,
3301
+ lineHeight: 1,
3302
+ cursor: "pointer",
3303
+ zIndex: 2
3304
+ },
3305
+ children: "\u2715"
3306
+ }
3307
+ );
3308
+ }
2636
3309
  function usePlayerSelector(selector) {
2637
3310
  const { playerEngine } = useSDK();
2638
3311
  const selectorRef = react.useRef(selector);
@@ -3248,6 +3921,7 @@ var HttpError = class extends Error {
3248
3921
  };
3249
3922
 
3250
3923
  exports.DEFAULT_FEED_CONFIG = DEFAULT_FEED_CONFIG;
3924
+ exports.DEFAULT_NAVIGATION_CONFIG = DEFAULT_NAVIGATION_CONFIG;
3251
3925
  exports.DEFAULT_PLAYER_CONFIG = DEFAULT_PLAYER_CONFIG;
3252
3926
  exports.DEFAULT_RESOURCE_CONFIG = DEFAULT_RESOURCE_CONFIG;
3253
3927
  exports.DefaultActions = DefaultActions;
@@ -3265,11 +3939,13 @@ exports.MockLogger = MockLogger;
3265
3939
  exports.MockNetworkAdapter = MockNetworkAdapter;
3266
3940
  exports.MockSessionStorage = MockSessionStorage;
3267
3941
  exports.MockVideoLoader = MockVideoLoader;
3942
+ exports.NavigationManager = NavigationManager;
3268
3943
  exports.OptimisticManager = OptimisticManager;
3269
3944
  exports.PlayerEngine = PlayerEngine;
3270
3945
  exports.PlayerStatus = PlayerStatus;
3271
3946
  exports.ReelsFeed = ReelsFeed;
3272
3947
  exports.ReelsFeedThumbnail = ReelsFeedThumbnail;
3948
+ exports.ReelsModal = ReelsModal;
3273
3949
  exports.ReelsProvider = ReelsProvider;
3274
3950
  exports.ResourceGovernor = ResourceGovernor;
3275
3951
  exports.VALID_TRANSITIONS = VALID_TRANSITIONS;
@@ -3278,11 +3954,14 @@ exports.canPause = canPause;
3278
3954
  exports.canPlay = canPlay;
3279
3955
  exports.canSeek = canSeek;
3280
3956
  exports.isArticle = isArticle;
3957
+ exports.isValidNavTransition = isValidNavTransition;
3281
3958
  exports.isValidTransition = isValidTransition;
3282
3959
  exports.isVideoItem = isVideoItem;
3283
3960
  exports.useFeed = useFeed;
3284
3961
  exports.useFeedSelector = useFeedSelector;
3285
3962
  exports.useHls = useHls;
3963
+ exports.useNavigation = useNavigation;
3964
+ exports.useNavigationSelector = useNavigationSelector;
3286
3965
  exports.usePlayer = usePlayer;
3287
3966
  exports.usePlayerSelector = usePlayerSelector;
3288
3967
  exports.usePointerGesture = usePointerGesture;