@xhub-reels/sdk 0.1.18 → 0.1.20

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
@@ -741,9 +741,12 @@ var OptimisticManager = class {
741
741
  }
742
742
  };
743
743
  var DEFAULT_RESOURCE_CONFIG = {
744
- maxAllocations: 11,
745
- bufferWindow: 3,
746
- warmWindow: 4,
744
+ // bufferWindow=2 → ±2 hot slots + 1 active = 5 HLS instances max.
745
+ // Reduced from 3 to eliminate media pipeline thread contention on iOS/Android WebView
746
+ // that caused jank after scrolling 4-5 videos (7 concurrent HLS instances was too many).
747
+ maxAllocations: 8,
748
+ bufferWindow: 2,
749
+ warmWindow: 3,
747
750
  // 0ms debounce — setFocusedIndexImmediate is already used post-snap.
748
751
  focusDebounceMs: 0,
749
752
  preloadLookAhead: 3
@@ -1393,7 +1396,7 @@ function mapHlsError(data) {
1393
1396
  }
1394
1397
  }
1395
1398
  function useHls(options) {
1396
- const { src, videoRef, isActive, isPrefetch, bufferTier = "active", hlsConfig, onError } = options;
1399
+ const { src, videoRef, isActive, isPrefetch, bufferTier = "active", hlsConfig, onError, isDragging = false } = options;
1397
1400
  const isHlsSupported = typeof window !== "undefined" && Hls__default.default.isSupported();
1398
1401
  const isNative = supportsNativeHls();
1399
1402
  const isHlsJs = isHlsSupported && !isNative;
@@ -1549,6 +1552,21 @@ function useHls(options) {
1549
1552
  }
1550
1553
  }
1551
1554
  }, [bufferTier]);
1555
+ react.useEffect(() => {
1556
+ const hls = hlsRef.current;
1557
+ if (!hls || isActive) return;
1558
+ if (isDragging) {
1559
+ try {
1560
+ hls.stopLoad();
1561
+ } catch {
1562
+ }
1563
+ } else {
1564
+ try {
1565
+ hls.startLoad(-1);
1566
+ } catch {
1567
+ }
1568
+ }
1569
+ }, [isDragging, isActive]);
1552
1570
  return {
1553
1571
  isHlsJs,
1554
1572
  isReady,
@@ -1706,6 +1724,7 @@ function DefaultPauseAction() {
1706
1724
  );
1707
1725
  }
1708
1726
  var PLAY_AHEAD_MAX_CONCURRENT = 2;
1727
+ var PLAY_AHEAD_STAGGER_MS = 80;
1709
1728
  var _playAheadActive = 0;
1710
1729
  var _playAheadQueue = [];
1711
1730
  function acquirePlayAhead() {
@@ -1713,7 +1732,11 @@ function acquirePlayAhead() {
1713
1732
  _playAheadActive++;
1714
1733
  return Promise.resolve();
1715
1734
  }
1716
- return new Promise((resolve) => _playAheadQueue.push(resolve));
1735
+ return new Promise((resolve) => {
1736
+ _playAheadQueue.push(() => {
1737
+ setTimeout(resolve, PLAY_AHEAD_STAGGER_MS);
1738
+ });
1739
+ });
1717
1740
  }
1718
1741
  function releasePlayAhead() {
1719
1742
  _playAheadActive = Math.max(0, _playAheadActive - 1);
@@ -1734,6 +1757,7 @@ function VideoSlot({
1734
1757
  onToggleMute,
1735
1758
  onAutoplayBlocked,
1736
1759
  showFps = false,
1760
+ isDragging = false,
1737
1761
  renderOverlay,
1738
1762
  renderActions,
1739
1763
  renderPauseAction
@@ -1771,6 +1795,7 @@ function VideoSlot({
1771
1795
  onToggleMute,
1772
1796
  onAutoplayBlocked,
1773
1797
  showFps,
1798
+ isDragging,
1774
1799
  renderOverlay,
1775
1800
  renderActions,
1776
1801
  renderPauseAction,
@@ -1790,6 +1815,7 @@ function VideoSlotInner({
1790
1815
  onToggleMute,
1791
1816
  onAutoplayBlocked,
1792
1817
  showFps,
1818
+ isDragging,
1793
1819
  renderOverlay,
1794
1820
  renderActions,
1795
1821
  renderPauseAction,
@@ -1811,6 +1837,7 @@ function VideoSlotInner({
1811
1837
  // so useHls creates the HLS instance and starts buffering
1812
1838
  isPrefetch: isPrefetch || isPreloaded,
1813
1839
  bufferTier,
1840
+ isDragging,
1814
1841
  onError: (code, message) => {
1815
1842
  console.error(`[VideoSlot] HLS error: ${code} \u2014 ${message}`);
1816
1843
  }
@@ -2200,6 +2227,7 @@ function FpsCounter() {
2200
2227
  );
2201
2228
  }
2202
2229
  var RENDER_WINDOW_RADIUS = 3;
2230
+ var PLACEHOLDER_EXTRA = 2;
2203
2231
  var centerStyle = {
2204
2232
  height: "100dvh",
2205
2233
  display: "flex",
@@ -2241,7 +2269,6 @@ function ReelsFeed({
2241
2269
  const slotCacheRef = react.useRef(/* @__PURE__ */ new Map());
2242
2270
  const activeIndexRef = react.useRef(0);
2243
2271
  activeIndexRef.current = focusedIndex;
2244
- const [isSnapping, setIsSnapping] = react.useState(false);
2245
2272
  const { animateSnap, animateBounceBack, cancelAnimation } = useSnapAnimation({
2246
2273
  duration: snapConfig?.duration ?? 260,
2247
2274
  easing: snapConfig?.easing ?? "cubic-bezier(0.25, 0.46, 0.45, 0.94)"
@@ -2277,9 +2304,20 @@ function ReelsFeed({
2277
2304
  }
2278
2305
  };
2279
2306
  rebuild();
2280
- const observer = new MutationObserver(rebuild);
2307
+ let rebuildTimer = null;
2308
+ const debouncedRebuild = () => {
2309
+ if (rebuildTimer !== null) return;
2310
+ rebuildTimer = setTimeout(() => {
2311
+ rebuildTimer = null;
2312
+ rebuild();
2313
+ }, 16);
2314
+ };
2315
+ const observer = new MutationObserver(debouncedRebuild);
2281
2316
  observer.observe(container, { childList: true, subtree: true });
2282
- return () => observer.disconnect();
2317
+ return () => {
2318
+ observer.disconnect();
2319
+ if (rebuildTimer !== null) clearTimeout(rebuildTimer);
2320
+ };
2283
2321
  }, [items.length, focusedIndex]);
2284
2322
  const containerHeight = react.useRef(
2285
2323
  typeof window !== "undefined" ? window.innerHeight : 800
@@ -2336,13 +2374,7 @@ function ReelsFeed({
2336
2374
  return;
2337
2375
  }
2338
2376
  cancelAnimation();
2339
- setIsSnapping(true);
2340
2377
  setPrefetchIndex(null);
2341
- setFocusedIndexImmediate(next);
2342
- const nextItem = items[next];
2343
- if (nextItem) {
2344
- onSlotChange?.(next, nextItem, current);
2345
- }
2346
2378
  const h = containerHeight.current;
2347
2379
  const targets = [];
2348
2380
  for (const [idx, el] of slotCacheRef.current) {
@@ -2353,7 +2385,13 @@ function ReelsFeed({
2353
2385
  });
2354
2386
  }
2355
2387
  animateSnap(targets);
2356
- setTimeout(() => setIsSnapping(false), 300);
2388
+ requestAnimationFrame(() => {
2389
+ setFocusedIndexImmediate(next);
2390
+ const nextItem = items[next];
2391
+ if (nextItem) {
2392
+ onSlotChange?.(next, nextItem, current);
2393
+ }
2394
+ });
2357
2395
  },
2358
2396
  [items, animateSnap, animateBounceBack, cancelAnimation, setFocusedIndexImmediate, setPrefetchIndex, onSlotChange]
2359
2397
  );
@@ -2375,13 +2413,13 @@ function ReelsFeed({
2375
2413
  setIsDragMuted(true);
2376
2414
  }, []);
2377
2415
  const handleDragEnd = react.useCallback(() => {
2378
- setTimeout(() => setIsDragMuted(false), 50);
2416
+ setTimeout(() => setIsDragMuted(false), 300);
2379
2417
  }, []);
2380
2418
  const { bind } = usePointerGesture({
2381
2419
  axis: "y",
2382
2420
  velocityThreshold: gestureConfig?.velocityThreshold ?? 0.3,
2383
2421
  distanceThreshold: gestureConfig?.distanceThreshold ?? 80,
2384
- disabled: isSnapping,
2422
+ disabled: false,
2385
2423
  containerSize: containerHeight.current,
2386
2424
  dragThresholdRatio: gestureConfig?.dragThresholdRatio ?? 0.5,
2387
2425
  onDragOffset: (offset) => {
@@ -2405,6 +2443,15 @@ function ReelsFeed({
2405
2443
  const handleToggleMute = react.useCallback(() => {
2406
2444
  setIsMuted((prev) => !prev);
2407
2445
  }, []);
2446
+ const windowedItems = react.useMemo(() => {
2447
+ const start = Math.max(0, focusedIndex - RENDER_WINDOW_RADIUS - PLACEHOLDER_EXTRA);
2448
+ const end = Math.min(items.length - 1, focusedIndex + RENDER_WINDOW_RADIUS + PLACEHOLDER_EXTRA);
2449
+ const result = [];
2450
+ for (let i = start; i <= end; i++) {
2451
+ result.push({ item: items[i], index: i });
2452
+ }
2453
+ return result;
2454
+ }, [items, focusedIndex]);
2408
2455
  if (loading && items.length === 0) {
2409
2456
  return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { ...centerStyle, flexDirection: "column", gap: 0 }, children: renderLoading ? renderLoading() : /* @__PURE__ */ jsxRuntime.jsx(DefaultSkeleton, {}) });
2410
2457
  }
@@ -2441,12 +2488,18 @@ function ReelsFeed({
2441
2488
  to { transform: rotate(360deg); }
2442
2489
  }
2443
2490
  ` }),
2444
- items.map((item, index) => {
2491
+ windowedItems.map(({ item, index }) => {
2445
2492
  const distFromFocus = Math.abs(index - focusedIndex);
2446
2493
  const wrapperStyle = {
2447
2494
  position: "absolute",
2448
2495
  inset: 0,
2449
- contain: "layout style paint",
2496
+ // Fix 4: 'strict' = layout + style + paint + size containment.
2497
+ // Tells browser this slot is fully self-contained — no layout
2498
+ // escapes to parent. Eliminates layout thrash during animation.
2499
+ contain: "strict",
2500
+ // Fix 4: willChange only on active ±1 (3 slots max).
2501
+ // Previously ±1 already, keep it. Avoids unnecessary GPU layers
2502
+ // on warm/cold slots that are never animated directly.
2450
2503
  willChange: distFromFocus <= 1 ? "transform" : "auto",
2451
2504
  transform: getInitialTransformPx(index)
2452
2505
  };
@@ -2476,6 +2529,7 @@ function ReelsFeed({
2476
2529
  onToggleMute: handleToggleMute,
2477
2530
  onAutoplayBlocked,
2478
2531
  showFps: showFps && isActive,
2532
+ isDragging: isDragMuted,
2479
2533
  renderOverlay,
2480
2534
  renderActions,
2481
2535
  renderPauseAction
package/dist/index.d.cts CHANGED
@@ -577,7 +577,10 @@ declare class OptimisticManager {
577
577
  * Tier 4 (Cold): preloadLookAhead beyond warm — metadata/manifest prefetch only (no DOM)
578
578
  *
579
579
  * Total DOM nodes = 1 + 2×bufferWindow + warmWindow (forward) + 1 (backward)
580
- * Default: 1 + 6 + 4 = 11 DOM nodes, ~112 MB memory (fits comfortably in 1 GB budget)
580
+ * Default: 1 + 4 + 3 = 8 DOM nodes, ~80 MB memory (fits comfortably in 1 GB budget)
581
+ *
582
+ * bufferWindow reduced from 3→2 to cut concurrent HLS instances from 7→5,
583
+ * eliminating media pipeline thread contention on iOS/Android WebView.
581
584
  */
582
585
 
583
586
  interface ResourceState {
@@ -831,6 +834,13 @@ interface UseHlsOptions {
831
834
  hlsConfig?: Partial<HlsConfig>;
832
835
  /** Called when hls.js encounters a fatal error. Maps to PlayerEngine error codes. */
833
836
  onError?: (code: 'NETWORK_ERROR' | 'MEDIA_ERROR' | 'DECODE_ERROR' | 'UNKNOWN', message: string) => void;
837
+ /**
838
+ * Whether the user is currently dragging/swiping.
839
+ * When true, non-active slots pause HLS network fetching (hls.stopLoad) to
840
+ * free bandwidth for the active slot and reduce main-thread contention.
841
+ * Active slot is never paused regardless of this flag.
842
+ */
843
+ isDragging?: boolean;
834
844
  }
835
845
  interface UseHlsReturn {
836
846
  /** Whether hls.js is being used (false = native HLS on Safari) */
@@ -854,11 +864,16 @@ interface VideoSlotProps {
854
864
  /** Called when unmuted autoplay fails and SDK falls back to muted playback */
855
865
  onAutoplayBlocked?: () => void;
856
866
  showFps?: boolean;
867
+ /**
868
+ * Whether the user is currently dragging. Passed to useHls to pause
869
+ * non-active HLS fetching during gesture, freeing bandwidth + main thread.
870
+ */
871
+ isDragging?: boolean;
857
872
  renderOverlay?: (item: ContentItem, actions: SlotActions) => react.ReactNode;
858
873
  renderActions?: (item: ContentItem, actions: SlotActions) => react.ReactNode;
859
874
  renderPauseAction?: (item: ContentItem, actions: PauseSlotActions) => react.ReactNode;
860
875
  }
861
- declare function VideoSlot({ item, index, isActive, isPrefetch, isPreloaded, bufferTier, isMuted, onToggleMute, onAutoplayBlocked, showFps, renderOverlay, renderActions, renderPauseAction, }: VideoSlotProps): react_jsx_runtime.JSX.Element;
876
+ declare function VideoSlot({ item, index, isActive, isPrefetch, isPreloaded, bufferTier, isMuted, onToggleMute, onAutoplayBlocked, showFps, isDragging, renderOverlay, renderActions, renderPauseAction, }: VideoSlotProps): react_jsx_runtime.JSX.Element;
862
877
 
863
878
  declare function DefaultOverlay({ item }: {
864
879
  item: ContentItem;
package/dist/index.d.ts CHANGED
@@ -577,7 +577,10 @@ declare class OptimisticManager {
577
577
  * Tier 4 (Cold): preloadLookAhead beyond warm — metadata/manifest prefetch only (no DOM)
578
578
  *
579
579
  * Total DOM nodes = 1 + 2×bufferWindow + warmWindow (forward) + 1 (backward)
580
- * Default: 1 + 6 + 4 = 11 DOM nodes, ~112 MB memory (fits comfortably in 1 GB budget)
580
+ * Default: 1 + 4 + 3 = 8 DOM nodes, ~80 MB memory (fits comfortably in 1 GB budget)
581
+ *
582
+ * bufferWindow reduced from 3→2 to cut concurrent HLS instances from 7→5,
583
+ * eliminating media pipeline thread contention on iOS/Android WebView.
581
584
  */
582
585
 
583
586
  interface ResourceState {
@@ -831,6 +834,13 @@ interface UseHlsOptions {
831
834
  hlsConfig?: Partial<HlsConfig>;
832
835
  /** Called when hls.js encounters a fatal error. Maps to PlayerEngine error codes. */
833
836
  onError?: (code: 'NETWORK_ERROR' | 'MEDIA_ERROR' | 'DECODE_ERROR' | 'UNKNOWN', message: string) => void;
837
+ /**
838
+ * Whether the user is currently dragging/swiping.
839
+ * When true, non-active slots pause HLS network fetching (hls.stopLoad) to
840
+ * free bandwidth for the active slot and reduce main-thread contention.
841
+ * Active slot is never paused regardless of this flag.
842
+ */
843
+ isDragging?: boolean;
834
844
  }
835
845
  interface UseHlsReturn {
836
846
  /** Whether hls.js is being used (false = native HLS on Safari) */
@@ -854,11 +864,16 @@ interface VideoSlotProps {
854
864
  /** Called when unmuted autoplay fails and SDK falls back to muted playback */
855
865
  onAutoplayBlocked?: () => void;
856
866
  showFps?: boolean;
867
+ /**
868
+ * Whether the user is currently dragging. Passed to useHls to pause
869
+ * non-active HLS fetching during gesture, freeing bandwidth + main thread.
870
+ */
871
+ isDragging?: boolean;
857
872
  renderOverlay?: (item: ContentItem, actions: SlotActions) => react.ReactNode;
858
873
  renderActions?: (item: ContentItem, actions: SlotActions) => react.ReactNode;
859
874
  renderPauseAction?: (item: ContentItem, actions: PauseSlotActions) => react.ReactNode;
860
875
  }
861
- declare function VideoSlot({ item, index, isActive, isPrefetch, isPreloaded, bufferTier, isMuted, onToggleMute, onAutoplayBlocked, showFps, renderOverlay, renderActions, renderPauseAction, }: VideoSlotProps): react_jsx_runtime.JSX.Element;
876
+ declare function VideoSlot({ item, index, isActive, isPrefetch, isPreloaded, bufferTier, isMuted, onToggleMute, onAutoplayBlocked, showFps, isDragging, renderOverlay, renderActions, renderPauseAction, }: VideoSlotProps): react_jsx_runtime.JSX.Element;
862
877
 
863
878
  declare function DefaultOverlay({ item }: {
864
879
  item: ContentItem;
package/dist/index.js CHANGED
@@ -735,9 +735,12 @@ var OptimisticManager = class {
735
735
  }
736
736
  };
737
737
  var DEFAULT_RESOURCE_CONFIG = {
738
- maxAllocations: 11,
739
- bufferWindow: 3,
740
- warmWindow: 4,
738
+ // bufferWindow=2 → ±2 hot slots + 1 active = 5 HLS instances max.
739
+ // Reduced from 3 to eliminate media pipeline thread contention on iOS/Android WebView
740
+ // that caused jank after scrolling 4-5 videos (7 concurrent HLS instances was too many).
741
+ maxAllocations: 8,
742
+ bufferWindow: 2,
743
+ warmWindow: 3,
741
744
  // 0ms debounce — setFocusedIndexImmediate is already used post-snap.
742
745
  focusDebounceMs: 0,
743
746
  preloadLookAhead: 3
@@ -1387,7 +1390,7 @@ function mapHlsError(data) {
1387
1390
  }
1388
1391
  }
1389
1392
  function useHls(options) {
1390
- const { src, videoRef, isActive, isPrefetch, bufferTier = "active", hlsConfig, onError } = options;
1393
+ const { src, videoRef, isActive, isPrefetch, bufferTier = "active", hlsConfig, onError, isDragging = false } = options;
1391
1394
  const isHlsSupported = typeof window !== "undefined" && Hls.isSupported();
1392
1395
  const isNative = supportsNativeHls();
1393
1396
  const isHlsJs = isHlsSupported && !isNative;
@@ -1543,6 +1546,21 @@ function useHls(options) {
1543
1546
  }
1544
1547
  }
1545
1548
  }, [bufferTier]);
1549
+ useEffect(() => {
1550
+ const hls = hlsRef.current;
1551
+ if (!hls || isActive) return;
1552
+ if (isDragging) {
1553
+ try {
1554
+ hls.stopLoad();
1555
+ } catch {
1556
+ }
1557
+ } else {
1558
+ try {
1559
+ hls.startLoad(-1);
1560
+ } catch {
1561
+ }
1562
+ }
1563
+ }, [isDragging, isActive]);
1546
1564
  return {
1547
1565
  isHlsJs,
1548
1566
  isReady,
@@ -1700,6 +1718,7 @@ function DefaultPauseAction() {
1700
1718
  );
1701
1719
  }
1702
1720
  var PLAY_AHEAD_MAX_CONCURRENT = 2;
1721
+ var PLAY_AHEAD_STAGGER_MS = 80;
1703
1722
  var _playAheadActive = 0;
1704
1723
  var _playAheadQueue = [];
1705
1724
  function acquirePlayAhead() {
@@ -1707,7 +1726,11 @@ function acquirePlayAhead() {
1707
1726
  _playAheadActive++;
1708
1727
  return Promise.resolve();
1709
1728
  }
1710
- return new Promise((resolve) => _playAheadQueue.push(resolve));
1729
+ return new Promise((resolve) => {
1730
+ _playAheadQueue.push(() => {
1731
+ setTimeout(resolve, PLAY_AHEAD_STAGGER_MS);
1732
+ });
1733
+ });
1711
1734
  }
1712
1735
  function releasePlayAhead() {
1713
1736
  _playAheadActive = Math.max(0, _playAheadActive - 1);
@@ -1728,6 +1751,7 @@ function VideoSlot({
1728
1751
  onToggleMute,
1729
1752
  onAutoplayBlocked,
1730
1753
  showFps = false,
1754
+ isDragging = false,
1731
1755
  renderOverlay,
1732
1756
  renderActions,
1733
1757
  renderPauseAction
@@ -1765,6 +1789,7 @@ function VideoSlot({
1765
1789
  onToggleMute,
1766
1790
  onAutoplayBlocked,
1767
1791
  showFps,
1792
+ isDragging,
1768
1793
  renderOverlay,
1769
1794
  renderActions,
1770
1795
  renderPauseAction,
@@ -1784,6 +1809,7 @@ function VideoSlotInner({
1784
1809
  onToggleMute,
1785
1810
  onAutoplayBlocked,
1786
1811
  showFps,
1812
+ isDragging,
1787
1813
  renderOverlay,
1788
1814
  renderActions,
1789
1815
  renderPauseAction,
@@ -1805,6 +1831,7 @@ function VideoSlotInner({
1805
1831
  // so useHls creates the HLS instance and starts buffering
1806
1832
  isPrefetch: isPrefetch || isPreloaded,
1807
1833
  bufferTier,
1834
+ isDragging,
1808
1835
  onError: (code, message) => {
1809
1836
  console.error(`[VideoSlot] HLS error: ${code} \u2014 ${message}`);
1810
1837
  }
@@ -2194,6 +2221,7 @@ function FpsCounter() {
2194
2221
  );
2195
2222
  }
2196
2223
  var RENDER_WINDOW_RADIUS = 3;
2224
+ var PLACEHOLDER_EXTRA = 2;
2197
2225
  var centerStyle = {
2198
2226
  height: "100dvh",
2199
2227
  display: "flex",
@@ -2235,7 +2263,6 @@ function ReelsFeed({
2235
2263
  const slotCacheRef = useRef(/* @__PURE__ */ new Map());
2236
2264
  const activeIndexRef = useRef(0);
2237
2265
  activeIndexRef.current = focusedIndex;
2238
- const [isSnapping, setIsSnapping] = useState(false);
2239
2266
  const { animateSnap, animateBounceBack, cancelAnimation } = useSnapAnimation({
2240
2267
  duration: snapConfig?.duration ?? 260,
2241
2268
  easing: snapConfig?.easing ?? "cubic-bezier(0.25, 0.46, 0.45, 0.94)"
@@ -2271,9 +2298,20 @@ function ReelsFeed({
2271
2298
  }
2272
2299
  };
2273
2300
  rebuild();
2274
- const observer = new MutationObserver(rebuild);
2301
+ let rebuildTimer = null;
2302
+ const debouncedRebuild = () => {
2303
+ if (rebuildTimer !== null) return;
2304
+ rebuildTimer = setTimeout(() => {
2305
+ rebuildTimer = null;
2306
+ rebuild();
2307
+ }, 16);
2308
+ };
2309
+ const observer = new MutationObserver(debouncedRebuild);
2275
2310
  observer.observe(container, { childList: true, subtree: true });
2276
- return () => observer.disconnect();
2311
+ return () => {
2312
+ observer.disconnect();
2313
+ if (rebuildTimer !== null) clearTimeout(rebuildTimer);
2314
+ };
2277
2315
  }, [items.length, focusedIndex]);
2278
2316
  const containerHeight = useRef(
2279
2317
  typeof window !== "undefined" ? window.innerHeight : 800
@@ -2330,13 +2368,7 @@ function ReelsFeed({
2330
2368
  return;
2331
2369
  }
2332
2370
  cancelAnimation();
2333
- setIsSnapping(true);
2334
2371
  setPrefetchIndex(null);
2335
- setFocusedIndexImmediate(next);
2336
- const nextItem = items[next];
2337
- if (nextItem) {
2338
- onSlotChange?.(next, nextItem, current);
2339
- }
2340
2372
  const h = containerHeight.current;
2341
2373
  const targets = [];
2342
2374
  for (const [idx, el] of slotCacheRef.current) {
@@ -2347,7 +2379,13 @@ function ReelsFeed({
2347
2379
  });
2348
2380
  }
2349
2381
  animateSnap(targets);
2350
- setTimeout(() => setIsSnapping(false), 300);
2382
+ requestAnimationFrame(() => {
2383
+ setFocusedIndexImmediate(next);
2384
+ const nextItem = items[next];
2385
+ if (nextItem) {
2386
+ onSlotChange?.(next, nextItem, current);
2387
+ }
2388
+ });
2351
2389
  },
2352
2390
  [items, animateSnap, animateBounceBack, cancelAnimation, setFocusedIndexImmediate, setPrefetchIndex, onSlotChange]
2353
2391
  );
@@ -2369,13 +2407,13 @@ function ReelsFeed({
2369
2407
  setIsDragMuted(true);
2370
2408
  }, []);
2371
2409
  const handleDragEnd = useCallback(() => {
2372
- setTimeout(() => setIsDragMuted(false), 50);
2410
+ setTimeout(() => setIsDragMuted(false), 300);
2373
2411
  }, []);
2374
2412
  const { bind } = usePointerGesture({
2375
2413
  axis: "y",
2376
2414
  velocityThreshold: gestureConfig?.velocityThreshold ?? 0.3,
2377
2415
  distanceThreshold: gestureConfig?.distanceThreshold ?? 80,
2378
- disabled: isSnapping,
2416
+ disabled: false,
2379
2417
  containerSize: containerHeight.current,
2380
2418
  dragThresholdRatio: gestureConfig?.dragThresholdRatio ?? 0.5,
2381
2419
  onDragOffset: (offset) => {
@@ -2399,6 +2437,15 @@ function ReelsFeed({
2399
2437
  const handleToggleMute = useCallback(() => {
2400
2438
  setIsMuted((prev) => !prev);
2401
2439
  }, []);
2440
+ const windowedItems = useMemo(() => {
2441
+ const start = Math.max(0, focusedIndex - RENDER_WINDOW_RADIUS - PLACEHOLDER_EXTRA);
2442
+ const end = Math.min(items.length - 1, focusedIndex + RENDER_WINDOW_RADIUS + PLACEHOLDER_EXTRA);
2443
+ const result = [];
2444
+ for (let i = start; i <= end; i++) {
2445
+ result.push({ item: items[i], index: i });
2446
+ }
2447
+ return result;
2448
+ }, [items, focusedIndex]);
2402
2449
  if (loading && items.length === 0) {
2403
2450
  return /* @__PURE__ */ jsx("div", { style: { ...centerStyle, flexDirection: "column", gap: 0 }, children: renderLoading ? renderLoading() : /* @__PURE__ */ jsx(DefaultSkeleton, {}) });
2404
2451
  }
@@ -2435,12 +2482,18 @@ function ReelsFeed({
2435
2482
  to { transform: rotate(360deg); }
2436
2483
  }
2437
2484
  ` }),
2438
- items.map((item, index) => {
2485
+ windowedItems.map(({ item, index }) => {
2439
2486
  const distFromFocus = Math.abs(index - focusedIndex);
2440
2487
  const wrapperStyle = {
2441
2488
  position: "absolute",
2442
2489
  inset: 0,
2443
- contain: "layout style paint",
2490
+ // Fix 4: 'strict' = layout + style + paint + size containment.
2491
+ // Tells browser this slot is fully self-contained — no layout
2492
+ // escapes to parent. Eliminates layout thrash during animation.
2493
+ contain: "strict",
2494
+ // Fix 4: willChange only on active ±1 (3 slots max).
2495
+ // Previously ±1 already, keep it. Avoids unnecessary GPU layers
2496
+ // on warm/cold slots that are never animated directly.
2444
2497
  willChange: distFromFocus <= 1 ? "transform" : "auto",
2445
2498
  transform: getInitialTransformPx(index)
2446
2499
  };
@@ -2470,6 +2523,7 @@ function ReelsFeed({
2470
2523
  onToggleMute: handleToggleMute,
2471
2524
  onAutoplayBlocked,
2472
2525
  showFps: showFps && isActive,
2526
+ isDragging: isDragMuted,
2473
2527
  renderOverlay,
2474
2528
  renderActions,
2475
2529
  renderPauseAction
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xhub-reels/sdk",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "High-performance Short Video / Reels SDK for React — optimized for Flutter WebView",
5
5
  "license": "MIT",
6
6
  "type": "module",