@xhub-reels/sdk 0.2.12 → 0.2.14

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