@xhub-reels/sdk 0.1.15 → 0.1.18

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
@@ -833,6 +833,21 @@ var ResourceGovernor = class {
833
833
  isPreloading(index) {
834
834
  return this.store.getState().preloadQueue.includes(index);
835
835
  }
836
+ /**
837
+ * Returns the buffer tier (1–4) for a given index.
838
+ * - 1: Active (playing, 10s buffer)
839
+ * - 2: Hot (±bufferWindow, 2s buffer, instant swap)
840
+ * - 3: Warm (manifest + 1 segment, ~300ms show)
841
+ * - 4: Cold (preload queue — manifest in HTTP cache, no DOM)
842
+ */
843
+ getTier(index) {
844
+ const { focusedIndex, activeAllocations, warmAllocations, preloadQueue } = this.store.getState();
845
+ if (index === focusedIndex) return 1;
846
+ if (activeAllocations.has(index)) return 2;
847
+ if (warmAllocations.has(index)) return 3;
848
+ if (preloadQueue.includes(index)) return 4;
849
+ return null;
850
+ }
836
851
  getActiveAllocations() {
837
852
  return [...this.store.getState().activeAllocations];
838
853
  }
@@ -905,7 +920,9 @@ function usePointerGesture(config = {}) {
905
920
  containerSize,
906
921
  dragThresholdRatio = 0.5,
907
922
  onSnap,
908
- onBounceBack
923
+ onBounceBack,
924
+ onDragStart,
925
+ onDragEnd
909
926
  } = config;
910
927
  const isDraggingRef = react.useRef(false);
911
928
  const dragOffsetRef = react.useRef(0);
@@ -922,6 +939,8 @@ function usePointerGesture(config = {}) {
922
939
  const onDragThresholdRef = react.useRef(onDragThreshold);
923
940
  const onSnapRef = react.useRef(onSnap);
924
941
  const onBounceBackRef = react.useRef(onBounceBack);
942
+ const onDragStartRef = react.useRef(onDragStart);
943
+ const onDragEndRef = react.useRef(onDragEnd);
925
944
  const disabledRef = react.useRef(disabled);
926
945
  const containerSizeRef = react.useRef(containerSize);
927
946
  const dragThresholdRatioRef = react.useRef(dragThresholdRatio);
@@ -930,6 +949,8 @@ function usePointerGesture(config = {}) {
930
949
  onDragThresholdRef.current = onDragThreshold;
931
950
  onSnapRef.current = onSnap;
932
951
  onBounceBackRef.current = onBounceBack;
952
+ onDragStartRef.current = onDragStart;
953
+ onDragEndRef.current = onDragEnd;
933
954
  disabledRef.current = disabled;
934
955
  containerSizeRef.current = containerSize;
935
956
  dragThresholdRatioRef.current = dragThresholdRatio;
@@ -996,6 +1017,7 @@ function usePointerGesture(config = {}) {
996
1017
  window.removeEventListener("pointermove", handlePointerMove);
997
1018
  window.removeEventListener("pointerup", handlePointerUp);
998
1019
  window.removeEventListener("pointercancel", handlePointerUp);
1020
+ onDragEndRef.current?.();
999
1021
  const offset = dragOffsetRef.current;
1000
1022
  const velocity = velocityRef.current;
1001
1023
  const shouldSnap = Math.abs(velocity) > velocityThreshold || Math.abs(offset) > distanceThreshold;
@@ -1027,6 +1049,7 @@ function usePointerGesture(config = {}) {
1027
1049
  lastTimeRef.current = performance.now();
1028
1050
  velocityRef.current = 0;
1029
1051
  dragOffsetRef.current = 0;
1052
+ onDragStartRef.current?.();
1030
1053
  e.currentTarget.setPointerCapture(e.pointerId);
1031
1054
  window.addEventListener("pointermove", handlePointerMove, { passive: true });
1032
1055
  window.addEventListener("pointerup", handlePointerUp);
@@ -1244,6 +1267,7 @@ function useResource() {
1244
1267
  const networkType = useResourceSelector((s) => s.networkType);
1245
1268
  const isActive = useResourceSelector((s) => s.isActive);
1246
1269
  const prefetchIndex = useResourceSelector((s) => s.prefetchIndex);
1270
+ const preloadQueue = useResourceSelector((s) => s.preloadQueue);
1247
1271
  const activeIndices = react.useMemo(() => [...activeAllocations], [activeAllocations]);
1248
1272
  const warmIndices = react.useMemo(() => [...warmAllocations], [warmAllocations]);
1249
1273
  const setFocusedIndex = react.useCallback(
@@ -1274,6 +1298,10 @@ function useResource() {
1274
1298
  (i) => resourceGovernor.setPrefetchIndex(i),
1275
1299
  [resourceGovernor]
1276
1300
  );
1301
+ const getTier = react.useCallback(
1302
+ (index) => resourceGovernor.getTier(index),
1303
+ [resourceGovernor]
1304
+ );
1277
1305
  return {
1278
1306
  activeIndices,
1279
1307
  warmIndices,
@@ -1282,13 +1310,15 @@ function useResource() {
1282
1310
  networkType,
1283
1311
  isActive,
1284
1312
  prefetchIndex,
1313
+ preloadQueue,
1285
1314
  setFocusedIndex,
1286
1315
  setFocusedIndexImmediate,
1287
1316
  setTotalItems,
1288
1317
  shouldRenderVideo,
1289
1318
  isAllocated,
1290
1319
  isWarmAllocated,
1291
- setPrefetchIndex
1320
+ setPrefetchIndex,
1321
+ getTier
1292
1322
  };
1293
1323
  }
1294
1324
  var ACTIVE_HLS_DEFAULTS = {
@@ -1364,18 +1394,9 @@ function mapHlsError(data) {
1364
1394
  }
1365
1395
  function useHls(options) {
1366
1396
  const { src, videoRef, isActive, isPrefetch, bufferTier = "active", hlsConfig, onError } = options;
1367
- const [isHlsJs, setIsHlsJs] = react.useState(false);
1368
- const [isNativeHls, setIsNativeHls] = react.useState(false);
1369
- react.useEffect(() => {
1370
- const hlsSupported = Hls__default.default.isSupported();
1371
- const native = supportsNativeHls();
1372
- setIsHlsJs(hlsSupported && !native);
1373
- setIsNativeHls(native);
1374
- }, []);
1375
- const isHlsJsRef = react.useRef(false);
1376
- const isNativeRef = react.useRef(false);
1377
- isHlsJsRef.current = isHlsJs;
1378
- isNativeRef.current = isNativeHls;
1397
+ const isHlsSupported = typeof window !== "undefined" && Hls__default.default.isSupported();
1398
+ const isNative = supportsNativeHls();
1399
+ const isHlsJs = isHlsSupported && !isNative;
1379
1400
  const [isReady, setIsReady] = react.useState(false);
1380
1401
  const hlsRef = react.useRef(null);
1381
1402
  const onErrorRef = react.useRef(onError);
@@ -1400,18 +1421,7 @@ function useHls(options) {
1400
1421
  currentSrcRef.current = void 0;
1401
1422
  return;
1402
1423
  }
1403
- const isNative = isNativeRef.current;
1404
- const isHlsSupported = isHlsJsRef.current;
1405
1424
  if (!isActive && !isPrefetch) {
1406
- if (isNative) {
1407
- if (video.src) {
1408
- video.removeAttribute("src");
1409
- video.load();
1410
- }
1411
- setIsReady(false);
1412
- currentSrcRef.current = void 0;
1413
- return;
1414
- }
1415
1425
  destroy();
1416
1426
  setIsReady(false);
1417
1427
  canPlayFiredRef.current = false;
@@ -1419,45 +1429,40 @@ function useHls(options) {
1419
1429
  return;
1420
1430
  }
1421
1431
  if (isNative) {
1422
- if (currentSrcRef.current === src) {
1423
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
1424
- setIsReady(true);
1425
- return void 0;
1426
- }
1427
- const handleCanPlayReuse = () => setIsReady(true);
1428
- video.addEventListener("canplay", handleCanPlayReuse, { once: true });
1429
- video.addEventListener("loadeddata", handleCanPlayReuse, { once: true });
1430
- video.addEventListener("playing", handleCanPlayReuse, { once: true });
1431
- if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
1432
- video.load();
1433
- }
1434
- return () => {
1435
- video.removeEventListener("canplay", handleCanPlayReuse);
1436
- video.removeEventListener("loadeddata", handleCanPlayReuse);
1437
- video.removeEventListener("playing", handleCanPlayReuse);
1438
- };
1439
- }
1440
- video.src = src;
1441
- currentSrcRef.current = src;
1442
- if (!isActive) {
1432
+ if (video.src !== src) {
1433
+ video.src = src;
1443
1434
  video.load();
1444
1435
  }
1445
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
1436
+ if (!video.hasAttribute("webkit-playsinline")) {
1437
+ video.setAttribute("webkit-playsinline", "");
1438
+ }
1439
+ if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
1446
1440
  setIsReady(true);
1447
- return void 0;
1441
+ currentSrcRef.current = src;
1442
+ return;
1448
1443
  }
1449
1444
  setIsReady(false);
1450
- const handleCanPlay2 = () => setIsReady(true);
1445
+ canPlayFiredRef.current = false;
1446
+ currentSrcRef.current = src;
1447
+ const handleCanPlay2 = () => {
1448
+ canPlayFiredRef.current = true;
1449
+ setIsReady(true);
1450
+ };
1451
+ const handleLoadedData = () => {
1452
+ if (!canPlayFiredRef.current) {
1453
+ canPlayFiredRef.current = true;
1454
+ setIsReady(true);
1455
+ }
1456
+ };
1451
1457
  video.addEventListener("canplay", handleCanPlay2, { once: true });
1452
- video.addEventListener("loadeddata", handleCanPlay2, { once: true });
1453
- video.addEventListener("playing", handleCanPlay2, { once: true });
1458
+ video.addEventListener("loadeddata", handleLoadedData, { once: true });
1454
1459
  return () => {
1455
1460
  video.removeEventListener("canplay", handleCanPlay2);
1456
- video.removeEventListener("loadeddata", handleCanPlay2);
1457
- video.removeEventListener("playing", handleCanPlay2);
1461
+ video.removeEventListener("loadeddata", handleLoadedData);
1458
1462
  };
1459
1463
  }
1460
1464
  if (!isHlsSupported) {
1465
+ onErrorRef.current?.("UNKNOWN", "HLS playback not supported in this browser");
1461
1466
  return;
1462
1467
  }
1463
1468
  if (hlsRef.current && currentSrcRef.current === src) {
@@ -1521,7 +1526,7 @@ function useHls(options) {
1521
1526
  currentSrcRef.current = void 0;
1522
1527
  }
1523
1528
  };
1524
- }, [src, isActive, isPrefetch, isHlsJs, isNativeHls]);
1529
+ }, [src, isActive, isPrefetch]);
1525
1530
  react.useEffect(() => {
1526
1531
  const hls = hlsRef.current;
1527
1532
  if (!hls) {
@@ -1537,10 +1542,15 @@ function useHls(options) {
1537
1542
  for (const key of configKeys) {
1538
1543
  hlsAnyConfig[key] = newConfig[key];
1539
1544
  }
1545
+ if (prevTier === "warm" && bufferTier === "active") {
1546
+ try {
1547
+ hls.startLoad();
1548
+ } catch {
1549
+ }
1550
+ }
1540
1551
  }, [bufferTier]);
1541
1552
  return {
1542
1553
  isHlsJs,
1543
- isNativeHls,
1544
1554
  isReady,
1545
1555
  destroy
1546
1556
  };
@@ -1659,43 +1669,60 @@ function skeletonCircle(size) {
1659
1669
  background: "rgba(255,255,255,0.1)"
1660
1670
  };
1661
1671
  }
1662
- function DefaultDoubleTap({ isDoubleTap }) {
1663
- if (!isDoubleTap) return null;
1664
- return /* @__PURE__ */ jsxRuntime.jsxs(
1672
+ function DefaultPauseAction() {
1673
+ return /* @__PURE__ */ jsxRuntime.jsx(
1665
1674
  "div",
1666
1675
  {
1667
1676
  style: {
1668
- position: "absolute",
1669
- inset: 0,
1677
+ width: 72,
1678
+ height: 72,
1679
+ borderRadius: "50%",
1680
+ background: "rgba(0, 0, 0, 0.55)",
1670
1681
  display: "flex",
1671
1682
  alignItems: "center",
1672
1683
  justifyContent: "center",
1673
1684
  pointerEvents: "none",
1674
- zIndex: 20
1685
+ // Inset the triangle visually — triangles look off-center without this
1686
+ paddingLeft: 6
1675
1687
  },
1676
- children: [
1677
- /* @__PURE__ */ jsxRuntime.jsx(
1678
- "div",
1679
- {
1680
- style: {
1681
- fontSize: 80,
1682
- animation: "reels-sdk-doubleTapHeart 0.6s ease forwards"
1683
- },
1684
- children: "\u2764\uFE0F"
1685
- }
1686
- ),
1687
- /* @__PURE__ */ jsxRuntime.jsx("style", { children: `
1688
- @keyframes reels-sdk-doubleTapHeart {
1689
- 0% { opacity: 0; transform: scale(0.5); }
1690
- 30% { opacity: 1; transform: scale(1.2); }
1691
- 60% { opacity: 1; transform: scale(1.0); }
1692
- 100% { opacity: 0; transform: scale(1.1); }
1693
- }
1694
- ` })
1695
- ]
1688
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1689
+ "svg",
1690
+ {
1691
+ width: "32",
1692
+ height: "32",
1693
+ viewBox: "0 0 32 32",
1694
+ fill: "none",
1695
+ xmlns: "http://www.w3.org/2000/svg",
1696
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1697
+ "path",
1698
+ {
1699
+ d: "M8 5.5L27 16L8 26.5V5.5Z",
1700
+ fill: "white"
1701
+ }
1702
+ )
1703
+ }
1704
+ )
1696
1705
  }
1697
1706
  );
1698
1707
  }
1708
+ var PLAY_AHEAD_MAX_CONCURRENT = 2;
1709
+ var _playAheadActive = 0;
1710
+ var _playAheadQueue = [];
1711
+ function acquirePlayAhead() {
1712
+ if (_playAheadActive < PLAY_AHEAD_MAX_CONCURRENT) {
1713
+ _playAheadActive++;
1714
+ return Promise.resolve();
1715
+ }
1716
+ return new Promise((resolve) => _playAheadQueue.push(resolve));
1717
+ }
1718
+ function releasePlayAhead() {
1719
+ _playAheadActive = Math.max(0, _playAheadActive - 1);
1720
+ const next = _playAheadQueue.shift();
1721
+ if (next) {
1722
+ _playAheadActive++;
1723
+ next();
1724
+ }
1725
+ }
1699
1726
  function VideoSlot({
1700
1727
  item,
1701
1728
  index,
@@ -1705,11 +1732,11 @@ function VideoSlot({
1705
1732
  bufferTier,
1706
1733
  isMuted,
1707
1734
  onToggleMute,
1735
+ onAutoplayBlocked,
1708
1736
  showFps = false,
1709
1737
  renderOverlay,
1710
1738
  renderActions,
1711
- renderPauseIndicator,
1712
- renderDoubleTap
1739
+ renderPauseAction
1713
1740
  }) {
1714
1741
  const { optimisticManager, adapters } = useSDK();
1715
1742
  if (!isVideoItem(item)) {
@@ -1742,11 +1769,11 @@ function VideoSlot({
1742
1769
  bufferTier,
1743
1770
  isMuted,
1744
1771
  onToggleMute,
1772
+ onAutoplayBlocked,
1745
1773
  showFps,
1746
1774
  renderOverlay,
1747
1775
  renderActions,
1748
- renderPauseIndicator,
1749
- renderDoubleTap,
1776
+ renderPauseAction,
1750
1777
  optimisticManager,
1751
1778
  adapters
1752
1779
  }
@@ -1761,11 +1788,11 @@ function VideoSlotInner({
1761
1788
  bufferTier,
1762
1789
  isMuted,
1763
1790
  onToggleMute,
1791
+ onAutoplayBlocked,
1764
1792
  showFps,
1765
1793
  renderOverlay,
1766
1794
  renderActions,
1767
- renderPauseIndicator,
1768
- renderDoubleTap,
1795
+ renderPauseAction,
1769
1796
  optimisticManager,
1770
1797
  adapters
1771
1798
  }) {
@@ -1776,7 +1803,7 @@ function VideoSlotInner({
1776
1803
  const isHlsSource = sourceType === "hls";
1777
1804
  const hlsSrc = isHlsSource && shouldLoadSrc ? src : void 0;
1778
1805
  const mp4Src = !isHlsSource && shouldLoadSrc ? src : void 0;
1779
- const { isReady: hlsReady, isNativeHls } = useHls({
1806
+ const { isReady: hlsReady } = useHls({
1780
1807
  src: hlsSrc,
1781
1808
  videoRef,
1782
1809
  isActive,
@@ -1815,9 +1842,7 @@ function VideoSlotInner({
1815
1842
  }, [mp4Src, isActive, isPrefetch, isPreloaded, isHlsSource]);
1816
1843
  const isReady = isHlsSource ? hlsReady : mp4Ready;
1817
1844
  const [hasPlayedAhead, setHasPlayedAhead] = react.useState(false);
1818
- const canPlayAhead = isHlsSource && !isNativeHls;
1819
1845
  react.useEffect(() => {
1820
- if (!canPlayAhead) return;
1821
1846
  const video = videoRef.current;
1822
1847
  if (!video) return;
1823
1848
  if (isActive || !isReady) return;
@@ -1825,206 +1850,146 @@ function VideoSlotInner({
1825
1850
  const prevMuted = video.muted;
1826
1851
  video.muted = true;
1827
1852
  let cancelled = false;
1828
- let rafId = null;
1829
- let vfcHandle = null;
1830
- const pauseAfterDecode = () => {
1831
- if (cancelled) return;
1832
- video.pause();
1833
- video.currentTime = 0;
1834
- video.muted = prevMuted;
1835
- setHasPlayedAhead(true);
1836
- };
1837
1853
  const doPlayAhead = async () => {
1854
+ await acquirePlayAhead();
1838
1855
  try {
1839
1856
  await video.play();
1840
- if (cancelled) return;
1841
- if ("requestVideoFrameCallback" in video) {
1842
- vfcHandle = video.requestVideoFrameCallback(() => {
1843
- vfcHandle = null;
1844
- pauseAfterDecode();
1845
- });
1846
- } else {
1847
- rafId = requestAnimationFrame(() => {
1848
- rafId = requestAnimationFrame(() => {
1849
- rafId = null;
1850
- pauseAfterDecode();
1851
- });
1852
- });
1857
+ if (cancelled) {
1858
+ video.pause();
1859
+ releasePlayAhead();
1860
+ return;
1853
1861
  }
1862
+ const pauseAfterDecode = () => {
1863
+ video.pause();
1864
+ video.currentTime = 0;
1865
+ video.muted = prevMuted;
1866
+ releasePlayAhead();
1867
+ if (!cancelled) {
1868
+ setHasPlayedAhead(true);
1869
+ }
1870
+ };
1871
+ setTimeout(pauseAfterDecode, 50);
1854
1872
  } catch {
1855
- video.muted = prevMuted;
1873
+ releasePlayAhead();
1856
1874
  }
1857
1875
  };
1858
1876
  doPlayAhead();
1859
1877
  return () => {
1860
1878
  cancelled = true;
1861
- if (rafId !== null) {
1862
- cancelAnimationFrame(rafId);
1863
- rafId = null;
1864
- }
1865
- if (vfcHandle !== null && "cancelVideoFrameCallback" in video) {
1866
- video.cancelVideoFrameCallback(vfcHandle);
1867
- vfcHandle = null;
1868
- }
1869
1879
  };
1870
- }, [canPlayAhead, isActive, isReady, hasPlayedAhead]);
1880
+ }, [isActive, isReady, hasPlayedAhead]);
1871
1881
  react.useEffect(() => {
1872
1882
  setHasPlayedAhead(false);
1873
1883
  }, [src]);
1874
1884
  const wasActiveRef = react.useRef(false);
1885
+ const [isManuallyPaused, setIsManuallyPaused] = react.useState(false);
1875
1886
  react.useEffect(() => {
1876
1887
  const video = videoRef.current;
1877
1888
  if (!video) return;
1878
1889
  let onReady = null;
1879
- let fallbackTimerId = null;
1880
- let pollId = null;
1881
- if (isActive) {
1882
- wasActiveRef.current = true;
1883
- const startPlay = () => {
1884
- if (onReady) {
1885
- video.removeEventListener("canplay", onReady);
1886
- video.removeEventListener("loadeddata", onReady);
1887
- video.removeEventListener("playing", onReady);
1888
- onReady = null;
1889
- }
1890
- if (fallbackTimerId !== null) {
1891
- clearTimeout(fallbackTimerId);
1892
- fallbackTimerId = null;
1893
- }
1894
- if (pollId !== null) {
1895
- clearInterval(pollId);
1896
- pollId = null;
1897
- }
1890
+ let cancelled = false;
1891
+ const attemptPlay = () => {
1892
+ if (cancelled) return;
1893
+ if (isMuted) {
1898
1894
  video.muted = true;
1899
- video.play().then(() => {
1900
- video.muted = isMuted;
1901
- }).catch(() => {
1902
- video.muted = isMuted;
1895
+ video.play().catch(() => {
1903
1896
  });
1904
- };
1905
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
1906
- startPlay();
1907
1897
  } else {
1908
- onReady = startPlay;
1909
- video.addEventListener("canplay", onReady, { once: true });
1910
- video.addEventListener("loadeddata", onReady, { once: true });
1911
- video.addEventListener("playing", onReady, { once: true });
1912
- if (isNativeHls && video.src && video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
1913
- video.load();
1914
- }
1915
- if (isNativeHls) {
1916
- pollId = setInterval(() => {
1917
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && onReady) {
1918
- if (pollId !== null) {
1919
- clearInterval(pollId);
1920
- pollId = null;
1898
+ video.muted = false;
1899
+ video.play().then(() => {
1900
+ if (cancelled) {
1901
+ video.pause();
1902
+ }
1903
+ }).catch((err) => {
1904
+ if (cancelled) return;
1905
+ if (err.name === "NotAllowedError") {
1906
+ video.muted = true;
1907
+ video.play().then(() => {
1908
+ if (cancelled) {
1909
+ video.pause();
1910
+ return;
1921
1911
  }
1922
- startPlay();
1923
- }
1924
- }, 100);
1925
- }
1926
- fallbackTimerId = window.setTimeout(() => {
1927
- fallbackTimerId = null;
1928
- if (onReady) {
1929
- startPlay();
1912
+ onAutoplayBlocked?.();
1913
+ }).catch(() => {
1914
+ });
1930
1915
  }
1931
- }, isNativeHls ? 800 : 3e3);
1916
+ });
1932
1917
  }
1918
+ };
1919
+ if (isActive && !isManuallyPaused) {
1920
+ wasActiveRef.current = true;
1921
+ if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
1922
+ attemptPlay();
1923
+ } else {
1924
+ onReady = attemptPlay;
1925
+ video.addEventListener("canplay", onReady, { once: true });
1926
+ }
1927
+ } else if (isActive && isManuallyPaused) {
1928
+ wasActiveRef.current = true;
1929
+ video.pause();
1933
1930
  } else if (wasActiveRef.current) {
1934
1931
  video.pause();
1935
1932
  video.currentTime = 0;
1936
1933
  wasActiveRef.current = false;
1934
+ setIsManuallyPaused(false);
1937
1935
  setHasPlayedAhead(false);
1938
1936
  } else if (!hasPlayedAhead) {
1939
1937
  video.pause();
1940
1938
  }
1941
1939
  return () => {
1942
- if (onReady) {
1943
- video.removeEventListener("canplay", onReady);
1944
- video.removeEventListener("loadeddata", onReady);
1945
- video.removeEventListener("playing", onReady);
1946
- }
1947
- if (fallbackTimerId !== null) {
1948
- clearTimeout(fallbackTimerId);
1949
- fallbackTimerId = null;
1950
- }
1951
- if (pollId !== null) {
1952
- clearInterval(pollId);
1953
- pollId = null;
1954
- }
1940
+ cancelled = true;
1941
+ if (onReady) video.removeEventListener("canplay", onReady);
1955
1942
  };
1956
- }, [isActive, isMuted, hasPlayedAhead, isNativeHls]);
1943
+ }, [isActive, isMuted, hasPlayedAhead, isManuallyPaused, onAutoplayBlocked]);
1957
1944
  react.useEffect(() => {
1958
1945
  const video = videoRef.current;
1959
1946
  if (!video) return;
1960
- if (isActive) {
1961
- video.muted = isMuted;
1962
- } else {
1963
- video.muted = true;
1964
- }
1947
+ video.muted = isActive ? isMuted : true;
1965
1948
  }, [isMuted, isActive]);
1966
- const [isActuallyPlaying, setIsActuallyPlaying] = react.useState(false);
1967
- react.useEffect(() => {
1968
- const video = videoRef.current;
1969
- if (!video) return;
1970
- const onPlaying = () => setIsActuallyPlaying(true);
1971
- const onPause = () => setIsActuallyPlaying(false);
1972
- const onEnded = () => setIsActuallyPlaying(false);
1973
- video.addEventListener("playing", onPlaying);
1974
- video.addEventListener("pause", onPause);
1975
- video.addEventListener("ended", onEnded);
1976
- return () => {
1977
- video.removeEventListener("playing", onPlaying);
1978
- video.removeEventListener("pause", onPause);
1979
- video.removeEventListener("ended", onEnded);
1980
- };
1949
+ const showPosterOverlay = !isReady && !hasPlayedAhead;
1950
+ const isPreDecoded = hasPlayedAhead;
1951
+ const [showMuteIndicator, setShowMuteIndicator] = react.useState(false);
1952
+ const muteIndicatorTimer = react.useRef(null);
1953
+ const handleToggleMute = react.useCallback(() => {
1954
+ onToggleMute();
1955
+ setShowMuteIndicator(true);
1956
+ if (muteIndicatorTimer.current) clearTimeout(muteIndicatorTimer.current);
1957
+ muteIndicatorTimer.current = setTimeout(() => setShowMuteIndicator(false), 1200);
1958
+ }, [onToggleMute]);
1959
+ const tapStartRef = react.useRef(null);
1960
+ const TAP_SLOP_PX = 10;
1961
+ const handlePointerDown = react.useCallback((e) => {
1962
+ tapStartRef.current = { x: e.clientX, y: e.clientY };
1981
1963
  }, []);
1982
- react.useEffect(() => {
1983
- if (!isActive) setIsActuallyPlaying(false);
1984
- }, [isActive]);
1985
- const showPosterOverlay = isActive ? !isReady && !isActuallyPlaying : canPlayAhead ? !hasPlayedAhead : !isReady;
1986
- const [isPaused, setIsPaused] = react.useState(false);
1987
- const [isDoubleTap, setIsDoubleTap] = react.useState(false);
1988
- const lastTapTimeRef = react.useRef(0);
1989
- const doubleTapTimerRef = react.useRef(null);
1990
- const handleTap = react.useCallback(() => {
1964
+ const handleClick = react.useCallback((e) => {
1965
+ if (e.button !== 0) return;
1991
1966
  if (!isActive) return;
1992
- const now = Date.now();
1993
- const delta = now - lastTapTimeRef.current;
1994
- lastTapTimeRef.current = now;
1995
- if (delta < 300) {
1996
- if (doubleTapTimerRef.current !== null) {
1997
- clearTimeout(doubleTapTimerRef.current);
1998
- doubleTapTimerRef.current = null;
1967
+ const start = tapStartRef.current;
1968
+ if (start) {
1969
+ const dx = Math.abs(e.clientX - start.x);
1970
+ const dy = Math.abs(e.clientY - start.y);
1971
+ if (dx > TAP_SLOP_PX || dy > TAP_SLOP_PX) {
1972
+ tapStartRef.current = null;
1973
+ return;
1999
1974
  }
2000
- setIsDoubleTap(true);
2001
- setTimeout(() => setIsDoubleTap(false), 600);
1975
+ }
1976
+ tapStartRef.current = null;
1977
+ const video = videoRef.current;
1978
+ if (!video) return;
1979
+ if (video.paused || isManuallyPaused) {
1980
+ setIsManuallyPaused(false);
1981
+ video.muted = true;
1982
+ video.play().then(() => {
1983
+ requestAnimationFrame(() => {
1984
+ video.muted = isMuted;
1985
+ });
1986
+ }).catch(() => {
1987
+ });
2002
1988
  } else {
2003
- doubleTapTimerRef.current = setTimeout(() => {
2004
- doubleTapTimerRef.current = null;
2005
- const video = videoRef.current;
2006
- if (!video) return;
2007
- if (video.paused) {
2008
- video.play().catch(() => {
2009
- });
2010
- setIsPaused(false);
2011
- } else {
2012
- video.pause();
2013
- setIsPaused(true);
2014
- }
2015
- }, 300);
1989
+ setIsManuallyPaused(true);
1990
+ video.pause();
2016
1991
  }
2017
- }, [isActive]);
2018
- react.useEffect(() => {
2019
- return () => {
2020
- if (doubleTapTimerRef.current !== null) {
2021
- clearTimeout(doubleTapTimerRef.current);
2022
- }
2023
- };
2024
- }, []);
2025
- react.useEffect(() => {
2026
- if (isActive) setIsPaused(false);
2027
- }, [isActive]);
1992
+ }, [isActive, isManuallyPaused, isMuted]);
2028
1993
  const likeDelta = react.useSyncExternalStore(
2029
1994
  optimisticManager.store.subscribe,
2030
1995
  () => optimisticManager.getLikeDelta(item.id),
@@ -2042,12 +2007,36 @@ function VideoSlotInner({
2042
2007
  followState,
2043
2008
  share: () => adapters.interaction?.share?.(item.id),
2044
2009
  isMuted,
2045
- toggleMute: onToggleMute,
2046
- isPaused,
2047
- togglePause: handleTap,
2010
+ toggleMute: handleToggleMute,
2048
2011
  isActive,
2049
2012
  index
2050
- }), [item, likeDelta, followState, isMuted, isPaused, isActive, index, optimisticManager, adapters, onToggleMute, handleTap]);
2013
+ }), [item, likeDelta, followState, isMuted, isActive, index, optimisticManager, adapters, handleToggleMute]);
2014
+ const pauseActions = react.useMemo(() => ({
2015
+ ...actions,
2016
+ isPaused: isManuallyPaused,
2017
+ togglePlayPause: () => {
2018
+ const video = videoRef.current;
2019
+ if (!video) return;
2020
+ if (isManuallyPaused) {
2021
+ setIsManuallyPaused(false);
2022
+ video.muted = true;
2023
+ video.play().then(() => {
2024
+ requestAnimationFrame(() => {
2025
+ video.muted = isMuted;
2026
+ });
2027
+ }).catch(() => {
2028
+ });
2029
+ } else {
2030
+ setIsManuallyPaused(true);
2031
+ video.pause();
2032
+ }
2033
+ }
2034
+ }), [actions, isManuallyPaused, isMuted]);
2035
+ react.useEffect(() => {
2036
+ return () => {
2037
+ if (muteIndicatorTimer.current) clearTimeout(muteIndicatorTimer.current);
2038
+ };
2039
+ }, []);
2051
2040
  return /* @__PURE__ */ jsxRuntime.jsxs(
2052
2041
  "div",
2053
2042
  {
@@ -2058,7 +2047,8 @@ function VideoSlotInner({
2058
2047
  background: "#111",
2059
2048
  overflow: "hidden"
2060
2049
  },
2061
- onClick: handleTap,
2050
+ onPointerDown: handlePointerDown,
2051
+ onClick: handleClick,
2062
2052
  children: [
2063
2053
  /* @__PURE__ */ jsxRuntime.jsx(
2064
2054
  "video",
@@ -2066,21 +2056,21 @@ function VideoSlotInner({
2066
2056
  ref: videoRef,
2067
2057
  src: mp4Src,
2068
2058
  loop: true,
2069
- muted: true,
2059
+ muted: isActive ? isMuted : true,
2070
2060
  playsInline: true,
2071
- autoPlay: isActive,
2072
2061
  preload: shouldLoadSrc ? "auto" : "none",
2073
2062
  style: {
2074
2063
  width: "100%",
2075
2064
  height: "100%",
2076
2065
  objectFit: "cover",
2077
- // Hide video until ready to avoid black frame flash
2066
+ // Hide video until ready to avoid black frame flash.
2067
+ // When pre-decoded, skip transition — first frame is already on canvas.
2078
2068
  opacity: showPosterOverlay ? 0 : 1,
2079
- transition: "opacity 0.15s ease"
2069
+ transition: isPreDecoded ? "none" : "opacity 0.15s ease"
2080
2070
  }
2081
2071
  }
2082
2072
  ),
2083
- item.poster && /* @__PURE__ */ jsxRuntime.jsx(
2073
+ item.poster && !isPreDecoded && /* @__PURE__ */ jsxRuntime.jsx(
2084
2074
  "div",
2085
2075
  {
2086
2076
  style: {
@@ -2095,19 +2085,29 @@ function VideoSlotInner({
2095
2085
  }
2096
2086
  }
2097
2087
  ),
2098
- (isDoubleTap || renderDoubleTap) && /* @__PURE__ */ jsxRuntime.jsx(
2088
+ showMuteIndicator && /* @__PURE__ */ jsxRuntime.jsx(
2099
2089
  "div",
2100
2090
  {
2101
2091
  style: {
2102
2092
  position: "absolute",
2103
- inset: 0,
2093
+ top: "50%",
2094
+ left: "50%",
2095
+ transform: "translate(-50%, -50%)",
2096
+ background: "rgba(0,0,0,0.6)",
2097
+ borderRadius: "50%",
2098
+ width: 64,
2099
+ height: 64,
2100
+ display: "flex",
2101
+ alignItems: "center",
2102
+ justifyContent: "center",
2103
+ fontSize: 28,
2104
2104
  pointerEvents: "none",
2105
- zIndex: 20
2105
+ animation: "reels-sdk-fadeInOut 1.2s ease forwards"
2106
2106
  },
2107
- children: renderDoubleTap ? renderDoubleTap(isDoubleTap) : /* @__PURE__ */ jsxRuntime.jsx(DefaultDoubleTap, { isDoubleTap })
2107
+ children: isMuted ? "\u{1F507}" : "\u{1F50A}"
2108
2108
  }
2109
2109
  ),
2110
- isPaused && /* @__PURE__ */ jsxRuntime.jsx(
2110
+ isActive && isManuallyPaused && /* @__PURE__ */ jsxRuntime.jsx(
2111
2111
  "div",
2112
2112
  {
2113
2113
  style: {
@@ -2116,25 +2116,9 @@ function VideoSlotInner({
2116
2116
  left: "50%",
2117
2117
  transform: "translate(-50%, -50%)",
2118
2118
  pointerEvents: "none",
2119
- zIndex: 5,
2120
- opacity: 0.3
2119
+ animation: "reels-sdk-fadeIn 0.2s ease forwards"
2121
2120
  },
2122
- children: renderPauseIndicator ? renderPauseIndicator(isPaused) : /* @__PURE__ */ jsxRuntime.jsx(
2123
- "div",
2124
- {
2125
- style: {
2126
- background: "rgba(0,0,0,0.6)",
2127
- borderRadius: "50%",
2128
- width: 64,
2129
- height: 64,
2130
- display: "flex",
2131
- alignItems: "center",
2132
- justifyContent: "center",
2133
- fontSize: 28
2134
- },
2135
- children: "\u25B6\uFE0F"
2136
- }
2137
- )
2121
+ children: renderPauseAction ? renderPauseAction(item, pauseActions) : /* @__PURE__ */ jsxRuntime.jsx(DefaultPauseAction, {})
2138
2122
  }
2139
2123
  ),
2140
2124
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2142,13 +2126,11 @@ function VideoSlotInner({
2142
2126
  {
2143
2127
  style: {
2144
2128
  position: "absolute",
2145
- bottom: 0,
2129
+ bottom: 80,
2146
2130
  left: 16,
2147
2131
  right: 80,
2148
- paddingBottom: 16,
2149
2132
  pointerEvents: "none",
2150
- color: "#fff",
2151
- zIndex: 10
2133
+ color: "#fff"
2152
2134
  },
2153
2135
  children: renderOverlay ? renderOverlay(item, actions) : /* @__PURE__ */ jsxRuntime.jsx(DefaultOverlay, { item })
2154
2136
  }
@@ -2156,19 +2138,18 @@ function VideoSlotInner({
2156
2138
  /* @__PURE__ */ jsxRuntime.jsx(
2157
2139
  "div",
2158
2140
  {
2159
- onClick: (e) => e.stopPropagation(),
2160
2141
  style: {
2161
2142
  position: "absolute",
2162
- bottom: 0,
2143
+ bottom: 80,
2163
2144
  right: 16,
2164
- paddingBottom: 16,
2165
2145
  display: "flex",
2166
2146
  flexDirection: "column",
2167
2147
  gap: 20,
2168
- alignItems: "center",
2169
- pointerEvents: "auto",
2170
- zIndex: 10
2148
+ alignItems: "center"
2149
+ // Actions must be clickable; stop propagation so taps don't
2150
+ // also trigger the video play/pause handler on the container.
2171
2151
  },
2152
+ onClick: (e) => e.stopPropagation(),
2172
2153
  children: renderActions ? renderActions(item, actions) : /* @__PURE__ */ jsxRuntime.jsx(DefaultActions, { item, actions })
2173
2154
  }
2174
2155
  ),
@@ -2218,6 +2199,7 @@ function FpsCounter() {
2218
2199
  }
2219
2200
  );
2220
2201
  }
2202
+ var RENDER_WINDOW_RADIUS = 3;
2221
2203
  var centerStyle = {
2222
2204
  height: "100dvh",
2223
2205
  display: "flex",
@@ -2229,8 +2211,7 @@ var centerStyle = {
2229
2211
  function ReelsFeed({
2230
2212
  renderOverlay,
2231
2213
  renderActions,
2232
- renderPauseIndicator,
2233
- renderDoubleTap,
2214
+ renderPauseAction,
2234
2215
  renderLoading,
2235
2216
  renderEmpty,
2236
2217
  renderError: _renderError,
@@ -2238,21 +2219,24 @@ function ReelsFeed({
2238
2219
  loadMoreThreshold = 5,
2239
2220
  onSlotChange,
2240
2221
  gestureConfig,
2241
- snapConfig
2222
+ snapConfig,
2223
+ initialMuted = true,
2224
+ onAutoplayBlocked
2242
2225
  }) {
2243
2226
  const { items, loading, loadInitial, loadMore, hasMore } = useFeed();
2227
+ const { adapters } = useSDK();
2244
2228
  const {
2245
- activeIndices,
2246
- warmIndices,
2247
2229
  focusedIndex,
2248
2230
  prefetchIndex,
2231
+ preloadQueue,
2249
2232
  setFocusedIndexImmediate,
2250
2233
  setTotalItems,
2251
2234
  shouldRenderVideo,
2252
2235
  isWarmAllocated,
2253
2236
  setPrefetchIndex
2254
2237
  } = useResource();
2255
- const [isMuted, setIsMuted] = react.useState(false);
2238
+ const [isMuted, setIsMuted] = react.useState(initialMuted);
2239
+ const [isDragMuted, setIsDragMuted] = react.useState(false);
2256
2240
  const containerRef = react.useRef(null);
2257
2241
  const slotCacheRef = react.useRef(/* @__PURE__ */ new Map());
2258
2242
  const activeIndexRef = react.useRef(0);
@@ -2268,6 +2252,14 @@ function ReelsFeed({
2268
2252
  react.useEffect(() => {
2269
2253
  setTotalItems(items.length);
2270
2254
  }, [items.length, setTotalItems]);
2255
+ react.useEffect(() => {
2256
+ for (const idx of preloadQueue) {
2257
+ const item = items[idx];
2258
+ if (item && isVideoItem(item) && item.source.type === "hls") {
2259
+ adapters.videoLoader?.preloadMetadata?.(item.source.url);
2260
+ }
2261
+ }
2262
+ }, [preloadQueue, items, adapters.videoLoader]);
2271
2263
  react.useEffect(() => {
2272
2264
  if (items.length - focusedIndex <= loadMoreThreshold && hasMore && !loading) {
2273
2265
  loadMore();
@@ -2288,7 +2280,7 @@ function ReelsFeed({
2288
2280
  const observer = new MutationObserver(rebuild);
2289
2281
  observer.observe(container, { childList: true, subtree: true });
2290
2282
  return () => observer.disconnect();
2291
- }, [items.length]);
2283
+ }, [items.length, focusedIndex]);
2292
2284
  const containerHeight = react.useRef(
2293
2285
  typeof window !== "undefined" ? window.innerHeight : 800
2294
2286
  );
@@ -2379,6 +2371,12 @@ function ReelsFeed({
2379
2371
  animateBounceBack(targets);
2380
2372
  setPrefetchIndex(null);
2381
2373
  }, [animateBounceBack, setPrefetchIndex]);
2374
+ const handleDragStart = react.useCallback(() => {
2375
+ setIsDragMuted(true);
2376
+ }, []);
2377
+ const handleDragEnd = react.useCallback(() => {
2378
+ setTimeout(() => setIsDragMuted(false), 50);
2379
+ }, []);
2382
2380
  const { bind } = usePointerGesture({
2383
2381
  axis: "y",
2384
2382
  velocityThreshold: gestureConfig?.velocityThreshold ?? 0.3,
@@ -2391,7 +2389,9 @@ function ReelsFeed({
2391
2389
  },
2392
2390
  onDragThreshold: handleDragThreshold,
2393
2391
  onSnap: handleSnap,
2394
- onBounceBack: handleBounceBack
2392
+ onBounceBack: handleBounceBack,
2393
+ onDragStart: handleDragStart,
2394
+ onDragEnd: handleDragEnd
2395
2395
  });
2396
2396
  const getInitialTransformPx = react.useCallback(
2397
2397
  (index) => {
@@ -2433,59 +2433,58 @@ function ReelsFeed({
2433
2433
  70% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
2434
2434
  100% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
2435
2435
  }
2436
+ @keyframes reels-sdk-fadeIn {
2437
+ from { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
2438
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
2439
+ }
2436
2440
  @keyframes reels-sdk-spin {
2437
2441
  to { transform: rotate(360deg); }
2438
2442
  }
2439
2443
  ` }),
2440
- (() => {
2441
- const windowIndices = /* @__PURE__ */ new Set([
2442
- ...activeIndices,
2443
- ...warmIndices
2444
- ]);
2445
- if (prefetchIndex !== null && prefetchIndex !== void 0) {
2446
- windowIndices.add(prefetchIndex);
2444
+ items.map((item, index) => {
2445
+ const distFromFocus = Math.abs(index - focusedIndex);
2446
+ const wrapperStyle = {
2447
+ position: "absolute",
2448
+ inset: 0,
2449
+ contain: "layout style paint",
2450
+ willChange: distFromFocus <= 1 ? "transform" : "auto",
2451
+ transform: getInitialTransformPx(index)
2452
+ };
2453
+ if (distFromFocus > RENDER_WINDOW_RADIUS) {
2454
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { "data-slot-index": index, style: wrapperStyle }, item.id);
2447
2455
  }
2448
- return [...windowIndices].map((index) => {
2449
- const item = items[index];
2450
- if (!item) return null;
2451
- const isActive = index === focusedIndex;
2452
- const isPrefetchSlot = index === prefetchIndex;
2453
- const isWarm = isWarmAllocated(index);
2454
- const isVisible = shouldRenderVideo(index) || isPrefetchSlot;
2455
- const bufferTier = isActive ? "active" : isWarm ? "warm" : "hot";
2456
- return /* @__PURE__ */ jsxRuntime.jsx(
2457
- "div",
2458
- {
2459
- "data-slot-index": index,
2460
- style: {
2461
- position: "absolute",
2462
- inset: 0,
2463
- willChange: "transform",
2464
- transform: getInitialTransformPx(index)
2465
- },
2466
- children: isVisible ? /* @__PURE__ */ jsxRuntime.jsx(
2467
- VideoSlot,
2468
- {
2469
- item,
2470
- index,
2471
- isActive,
2472
- isPrefetch: isPrefetchSlot,
2473
- isPreloaded: !isActive && !isPrefetchSlot && isVisible,
2474
- bufferTier,
2475
- isMuted,
2476
- onToggleMute: handleToggleMute,
2477
- showFps: showFps && isActive,
2478
- renderOverlay,
2479
- renderActions,
2480
- renderPauseIndicator,
2481
- renderDoubleTap
2482
- }
2483
- ) : null
2484
- },
2485
- item.id
2486
- );
2487
- });
2488
- })()
2456
+ const isActive = index === focusedIndex;
2457
+ const isPrefetch = index === prefetchIndex;
2458
+ const isWarm = isWarmAllocated(index);
2459
+ const isVisible = shouldRenderVideo(index) || isPrefetch;
2460
+ const bufferTier = isActive ? "active" : isWarm ? "warm" : "hot";
2461
+ return /* @__PURE__ */ jsxRuntime.jsx(
2462
+ "div",
2463
+ {
2464
+ "data-slot-index": index,
2465
+ style: wrapperStyle,
2466
+ children: /* @__PURE__ */ jsxRuntime.jsx(
2467
+ VideoSlot,
2468
+ {
2469
+ item,
2470
+ index,
2471
+ isActive,
2472
+ isPrefetch,
2473
+ isPreloaded: !isActive && !isPrefetch && isVisible,
2474
+ bufferTier,
2475
+ isMuted: isMuted || isDragMuted,
2476
+ onToggleMute: handleToggleMute,
2477
+ onAutoplayBlocked,
2478
+ showFps: showFps && isActive,
2479
+ renderOverlay,
2480
+ renderActions,
2481
+ renderPauseAction
2482
+ }
2483
+ )
2484
+ },
2485
+ item.id
2486
+ );
2487
+ })
2489
2488
  ]
2490
2489
  }
2491
2490
  );
@@ -2740,6 +2739,8 @@ var MockVideoLoader = class {
2740
2739
  this.preloaded.clear();
2741
2740
  this.loading.clear();
2742
2741
  }
2742
+ preloadMetadata(_url) {
2743
+ }
2743
2744
  };
2744
2745
  var MockDataSource = class {
2745
2746
  constructor(options = {}) {
@@ -3113,8 +3114,8 @@ exports.DEFAULT_FEED_CONFIG = DEFAULT_FEED_CONFIG;
3113
3114
  exports.DEFAULT_PLAYER_CONFIG = DEFAULT_PLAYER_CONFIG;
3114
3115
  exports.DEFAULT_RESOURCE_CONFIG = DEFAULT_RESOURCE_CONFIG;
3115
3116
  exports.DefaultActions = DefaultActions;
3116
- exports.DefaultDoubleTap = DefaultDoubleTap;
3117
3117
  exports.DefaultOverlay = DefaultOverlay;
3118
+ exports.DefaultPauseAction = DefaultPauseAction;
3118
3119
  exports.DefaultSkeleton = DefaultSkeleton;
3119
3120
  exports.FeedManager = FeedManager;
3120
3121
  exports.HttpDataSource = HttpDataSource;