@xhub-reels/sdk 0.2.1 → 0.2.6

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/README.md CHANGED
@@ -86,6 +86,72 @@ function MyFeed() {
86
86
  }
87
87
  ```
88
88
 
89
+ ## Thumbnail grid
90
+
91
+ `<ReelsFeedThumbnail>` is a headless grid component for the pre-drawer browsing UX
92
+ (e.g. a community feed that opens the player drawer on tap). It must be mounted
93
+ inside `<ReelsProvider>` and reads items from the same shared feed. The host
94
+ provides card visuals via `renderThumbnail`.
95
+
96
+ ```tsx
97
+ import { ReelsProvider, ReelsFeedThumbnail } from 'xhub-reels-sdk';
98
+
99
+ function CommunityPage() {
100
+ const [drawerOpen, setDrawerOpen] = useState(false);
101
+
102
+ return (
103
+ <ReelsProvider adapters={{ dataSource, interaction }}>
104
+ <ReelsFeedThumbnail
105
+ renderThumbnail={(item) => (
106
+ <article className="aspect-[3/2] rounded-lg overflow-hidden">
107
+ <img src={item.poster} alt="" />
108
+ <p>@{item.author.name}</p>
109
+ </article>
110
+ )}
111
+ onThumbnailClick={(id) => {
112
+ setDrawerOpen(true);
113
+ window.history.replaceState(null, '', `#reel_uuid=${id}`);
114
+ }}
115
+ />
116
+ {drawerOpen && <ReelsDrawer onClose={() => setDrawerOpen(false)} />}
117
+ </ReelsProvider>
118
+ );
119
+ }
120
+ ```
121
+
122
+ By default, clicking a card calls `setFocusedIndexImmediate(index)` on the
123
+ `ResourceGovernor` so the player opens instantly without a scroll-to-index. To
124
+ disable this glue (e.g. if the host manages focus separately), pass
125
+ `setFocusOnClick={false}`.
126
+
127
+ ### Props
128
+
129
+ | Prop | Type | Default | Description |
130
+ |---|---|---|---|
131
+ | `renderThumbnail` | `(item, index) => ReactNode` | required | Card visual for a single item |
132
+ | `onThumbnailClick` | `(id, item, index) => void` | — | Click handler |
133
+ | `renderLoading` | `() => ReactNode` | — | Shown while loading with no items |
134
+ | `renderEmpty` | `() => ReactNode` | — | Shown when feed is empty |
135
+ | `renderError` | `({ message, retry }) => ReactNode` | — | Shown on error with no items |
136
+ | `className` | `string` | `'grid grid-cols-2 gap-3'` | Outer wrapper className |
137
+ | `wrap` | `boolean` | `true` | Set `false` to render without an outer `<div>` |
138
+ | `setFocusOnClick` | `boolean` | `true` | Pre-focus the slot before `onThumbnailClick` fires |
139
+ | `prefetchOnHover` | `boolean` | `false` | Opt-in HLS metadata prefetch on `pointerenter` |
140
+ | `getKey` | `(item, index) => string` | `item.id` | Key override for duplicate lists |
141
+
142
+ ### Hover prefetch
143
+
144
+ For hover-capable devices, opt into manifest prefetch so opening the player
145
+ feels instant:
146
+
147
+ ```tsx
148
+ <ReelsFeedThumbnail prefetchOnHover renderThumbnail={...} />
149
+ ```
150
+
151
+ The SDK uses `adapters.videoLoader?.preloadMetadata?.(url)` and dedupes per item
152
+ so a card only triggers one prefetch regardless of how many times it's hovered.
153
+ Off by default to avoid surprising bandwidth on touch devices.
154
+
89
155
  ## Gesture Engine
90
156
 
91
157
  ```tsx
@@ -111,48 +177,6 @@ function SwipeableFeed() {
111
177
  }
112
178
  ```
113
179
 
114
- ## Thumbnail grid
115
-
116
- Headless pre-drawer browsing UX. Mount inside `<ReelsProvider>`. Host provides the
117
- card visuals via `renderThumbnail`; the SDK ships no default card.
118
-
119
- ```tsx
120
- import { ReelsFeedThumbnail } from 'xhub-reels-sdk';
121
-
122
- <ReelsFeedThumbnail
123
- renderThumbnail={(videoData) => (
124
- <article className="aspect-[3/2] rounded-lg overflow-hidden">
125
- <img src={videoData.poster} alt="" />
126
- <p>@{videoData.author.name}</p>
127
- </article>
128
- )}
129
- onThumbnailClick={(id) => openDrawer(id)}
130
- />
131
- ```
132
-
133
- Opt-in hover prefetch (warms HLS manifest via `adapters.videoLoader.preloadMetadata`):
134
-
135
- ```tsx
136
- <ReelsFeedThumbnail prefetchOnHover renderThumbnail={...} />
137
- ```
138
-
139
- On click the SDK calls `ResourceGovernor.setFocusedIndexImmediate(index)` before
140
- invoking `onThumbnailClick`, so opening `<ReelsFeed>` lands on the tapped item
141
- without a scroll-to-index flash. Disable with `setFocusOnClick={false}`.
142
-
143
- Custom slots for the zero-items branches:
144
-
145
- ```tsx
146
- <ReelsFeedThumbnail
147
- renderThumbnail={(item) => <Card item={item} />}
148
- renderLoading={() => <SkeletonGrid />}
149
- renderEmpty={() => <EmptyState />}
150
- renderError={({ message, retry }) => (
151
- <ErrorBox message={message} onRetry={retry} />
152
- )}
153
- />
154
- ```
155
-
156
180
  ## Architecture
157
181
 
158
182
  ```
package/dist/index.cjs CHANGED
@@ -1447,7 +1447,7 @@ function useHls(options) {
1447
1447
  setIsReady(false);
1448
1448
  canPlayFiredRef.current = false;
1449
1449
  currentSrcRef.current = src;
1450
- const handleCanPlay2 = () => {
1450
+ const handleCanPlay = () => {
1451
1451
  canPlayFiredRef.current = true;
1452
1452
  setIsReady(true);
1453
1453
  };
@@ -1457,10 +1457,10 @@ function useHls(options) {
1457
1457
  setIsReady(true);
1458
1458
  }
1459
1459
  };
1460
- video.addEventListener("canplay", handleCanPlay2, { once: true });
1460
+ video.addEventListener("canplay", handleCanPlay, { once: true });
1461
1461
  video.addEventListener("loadeddata", handleLoadedData, { once: true });
1462
1462
  return () => {
1463
- video.removeEventListener("canplay", handleCanPlay2);
1463
+ video.removeEventListener("canplay", handleCanPlay);
1464
1464
  video.removeEventListener("loadeddata", handleLoadedData);
1465
1465
  };
1466
1466
  }
@@ -1470,13 +1470,21 @@ function useHls(options) {
1470
1470
  }
1471
1471
  if (hlsRef.current && currentSrcRef.current === src) {
1472
1472
  if (!canPlayFiredRef.current) {
1473
- const handleCanPlay2 = () => {
1473
+ if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
1474
+ canPlayFiredRef.current = true;
1475
+ setIsReady(true);
1476
+ return void 0;
1477
+ }
1478
+ const handleReady2 = () => {
1479
+ if (canPlayFiredRef.current) return;
1474
1480
  canPlayFiredRef.current = true;
1475
1481
  setIsReady(true);
1476
1482
  };
1477
- video.addEventListener("canplay", handleCanPlay2, { once: true });
1483
+ video.addEventListener("loadeddata", handleReady2, { once: true });
1484
+ video.addEventListener("canplay", handleReady2, { once: true });
1478
1485
  return () => {
1479
- video.removeEventListener("canplay", handleCanPlay2);
1486
+ video.removeEventListener("loadeddata", handleReady2);
1487
+ video.removeEventListener("canplay", handleReady2);
1480
1488
  };
1481
1489
  }
1482
1490
  return void 0;
@@ -1513,15 +1521,18 @@ function useHls(options) {
1513
1521
  const mapped = mapHlsError(data);
1514
1522
  onErrorRef.current?.(mapped.code, mapped.message);
1515
1523
  });
1516
- const handleCanPlay = () => {
1524
+ const handleReady = () => {
1525
+ if (canPlayFiredRef.current) return;
1517
1526
  canPlayFiredRef.current = true;
1518
1527
  setIsReady(true);
1519
1528
  };
1520
- video.addEventListener("canplay", handleCanPlay, { once: true });
1529
+ video.addEventListener("loadeddata", handleReady, { once: true });
1530
+ video.addEventListener("canplay", handleReady, { once: true });
1521
1531
  hls.attachMedia(video);
1522
1532
  hls.loadSource(src);
1523
1533
  return () => {
1524
- video.removeEventListener("canplay", handleCanPlay);
1534
+ video.removeEventListener("loadeddata", handleReady);
1535
+ video.removeEventListener("canplay", handleReady);
1525
1536
  if (hlsRef.current === hls) {
1526
1537
  hls.destroy();
1527
1538
  hlsRef.current = null;
@@ -1892,11 +1903,18 @@ function VideoSlotInner({
1892
1903
  };
1893
1904
  if (isActive && !isManuallyPaused) {
1894
1905
  wasActiveRef.current = true;
1895
- if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
1906
+ if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
1896
1907
  attemptPlay();
1897
1908
  } else {
1898
- onReady = attemptPlay;
1899
- video.addEventListener("canplay", onReady, { once: true });
1909
+ let consumed = false;
1910
+ const handler = () => {
1911
+ if (consumed) return;
1912
+ consumed = true;
1913
+ attemptPlay();
1914
+ };
1915
+ onReady = handler;
1916
+ video.addEventListener("loadeddata", handler, { once: true });
1917
+ video.addEventListener("canplay", handler, { once: true });
1900
1918
  }
1901
1919
  } else if (isActive && isManuallyPaused) {
1902
1920
  wasActiveRef.current = true;
@@ -1912,7 +1930,10 @@ function VideoSlotInner({
1912
1930
  }
1913
1931
  return () => {
1914
1932
  cancelled = true;
1915
- if (onReady) video.removeEventListener("canplay", onReady);
1933
+ if (onReady) {
1934
+ video.removeEventListener("loadeddata", onReady);
1935
+ video.removeEventListener("canplay", onReady);
1936
+ }
1916
1937
  };
1917
1938
  }, [isActive, isMuted, hasPlayedAhead, isManuallyPaused, onAutoplayBlocked]);
1918
1939
  react.useEffect(() => {
@@ -2052,7 +2073,7 @@ function VideoSlotInner({
2052
2073
  inset: 0,
2053
2074
  backgroundImage: `url(${item.poster})`,
2054
2075
  backgroundSize: "cover",
2055
- backgroundPosition: "center center",
2076
+ backgroundPosition: "center",
2056
2077
  opacity: showPosterOverlay ? 1 : 0,
2057
2078
  transition: "opacity 0.15s ease",
2058
2079
  pointerEvents: "none"
@@ -2499,87 +2520,80 @@ function parsePxTranslateY(el) {
2499
2520
  if (!match || !match[1]) return 0;
2500
2521
  return Number.parseFloat(match[1]);
2501
2522
  }
2502
- var DEFAULT_CLASSNAME = "grid grid-cols-2 gap-3";
2503
- var defaultGetKey = (item) => item.id;
2504
- var buttonResetStyle = {
2505
- all: "unset",
2506
- display: "block",
2507
- cursor: "pointer",
2508
- width: "100%",
2509
- boxSizing: "border-box"
2510
- };
2511
2523
  function ReelsFeedThumbnail({
2512
2524
  renderThumbnail,
2513
2525
  onThumbnailClick,
2514
2526
  renderLoading,
2515
2527
  renderEmpty,
2516
2528
  renderError,
2517
- className = DEFAULT_CLASSNAME,
2529
+ className = "grid grid-cols-2 gap-3",
2518
2530
  wrap = true,
2519
2531
  setFocusOnClick = true,
2520
2532
  prefetchOnHover = false,
2521
- getKey = defaultGetKey
2533
+ getKey
2522
2534
  }) {
2523
2535
  const { items, loading, error, refresh } = useFeed();
2524
2536
  const { setFocusedIndexImmediate } = useResource();
2525
2537
  const { adapters } = useSDK();
2526
2538
  const prefetchedRef = react.useRef(/* @__PURE__ */ new Set());
2527
2539
  const handleClick = react.useCallback(
2528
- (item, index) => {
2540
+ (id, item, index) => {
2529
2541
  if (setFocusOnClick) {
2530
2542
  setFocusedIndexImmediate(index);
2531
2543
  }
2532
- onThumbnailClick?.(item.id, item, index);
2544
+ onThumbnailClick?.(id, item, index);
2533
2545
  },
2534
- [onThumbnailClick, setFocusOnClick, setFocusedIndexImmediate]
2546
+ [setFocusOnClick, setFocusedIndexImmediate, onThumbnailClick]
2535
2547
  );
2536
2548
  const handlePointerEnter = react.useCallback(
2537
2549
  (item) => {
2538
2550
  if (!prefetchOnHover) return;
2539
2551
  if (!isVideoItem(item)) return;
2540
2552
  if (item.source.type !== "hls") return;
2541
- if (prefetchedRef.current.has(item.id)) return;
2542
- const loader = adapters.videoLoader;
2543
- if (!loader?.preloadMetadata) return;
2544
- if (loader.isPreloaded(item.id)) {
2545
- prefetchedRef.current.add(item.id);
2546
- return;
2547
- }
2548
- loader.preloadMetadata(item.source.url);
2549
- prefetchedRef.current.add(item.id);
2553
+ const url = item.source.url;
2554
+ if (prefetchedRef.current.has(url)) return;
2555
+ prefetchedRef.current.add(url);
2556
+ adapters.videoLoader?.preloadMetadata?.(url);
2550
2557
  },
2551
2558
  [prefetchOnHover, adapters.videoLoader]
2552
2559
  );
2553
- const children = react.useMemo(
2554
- () => items.map((item, index) => {
2555
- const key = getKey(item, index);
2556
- const onEnter = prefetchOnHover ? (_e) => handlePointerEnter(item) : void 0;
2557
- return /* @__PURE__ */ jsxRuntime.jsx(
2558
- "button",
2559
- {
2560
- type: "button",
2561
- style: buttonResetStyle,
2562
- "data-thumbnail-index": index,
2563
- "data-thumbnail-id": item.id,
2564
- onClick: () => handleClick(item, index),
2565
- onPointerEnter: onEnter,
2566
- children: renderThumbnail(item, index)
2567
- },
2568
- key
2569
- );
2570
- }),
2571
- [items, getKey, prefetchOnHover, handlePointerEnter, handleClick, renderThumbnail]
2572
- );
2573
- if (items.length === 0) {
2574
- if (loading && renderLoading) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderLoading() });
2575
- if (error && renderError) {
2560
+ if (loading && items.length === 0) {
2561
+ if (renderLoading) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderLoading() });
2562
+ return null;
2563
+ }
2564
+ if (error && items.length === 0) {
2565
+ if (renderError) {
2576
2566
  return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderError({ message: error.message, retry: refresh }) });
2577
2567
  }
2578
- if (!loading && !error && renderEmpty) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderEmpty() });
2579
2568
  return null;
2580
2569
  }
2581
- if (!wrap) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
2582
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className, children });
2570
+ if (!loading && items.length === 0) {
2571
+ if (renderEmpty) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: renderEmpty() });
2572
+ return null;
2573
+ }
2574
+ const content = items.map((item, index) => {
2575
+ const key = getKey ? getKey(item, index) : item.id;
2576
+ return /* @__PURE__ */ jsxRuntime.jsx(
2577
+ "button",
2578
+ {
2579
+ type: "button",
2580
+ onClick: () => handleClick(item.id, item, index),
2581
+ onPointerEnter: () => handlePointerEnter(item),
2582
+ style: {
2583
+ all: "unset",
2584
+ cursor: "pointer",
2585
+ display: "block",
2586
+ width: "100%"
2587
+ },
2588
+ children: renderThumbnail(item, index)
2589
+ },
2590
+ key
2591
+ );
2592
+ });
2593
+ if (!wrap) {
2594
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: content });
2595
+ }
2596
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className, children: content });
2583
2597
  }
2584
2598
  function usePlayerSelector(selector) {
2585
2599
  const { playerEngine } = useSDK();
@@ -3001,12 +3015,16 @@ function transformVideoItem(raw) {
3001
3015
  const stats = transformStats(obj);
3002
3016
  const interaction = transformInteraction(obj);
3003
3017
  let poster;
3004
- let duration = 0;
3005
- const mediaArr = obj["media"];
3006
- if (Array.isArray(mediaArr) && mediaArr.length > 0) {
3007
- const first = mediaArr[0];
3008
- poster = toStr(first["poster"], "") || void 0;
3009
- duration = toNum(first["duration"], 0);
3018
+ const thumbnailObj = obj["thumbnail"];
3019
+ if (thumbnailObj && thumbnailObj["url"]) {
3020
+ poster = toStr(thumbnailObj["url"], "") || void 0;
3021
+ }
3022
+ if (!poster) {
3023
+ const mediaArr2 = obj["media"];
3024
+ if (Array.isArray(mediaArr2) && mediaArr2.length > 0) {
3025
+ const first = mediaArr2[0];
3026
+ poster = toStr(first["poster"], "") || void 0;
3027
+ }
3010
3028
  }
3011
3029
  if (!poster) {
3012
3030
  poster = toStr(
@@ -3014,11 +3032,11 @@ function transformVideoItem(raw) {
3014
3032
  void 0
3015
3033
  ) || void 0;
3016
3034
  }
3017
- if (!poster) {
3018
- const thumbnailObj = obj["thumbnail"];
3019
- if (thumbnailObj && thumbnailObj["url"]) {
3020
- poster = toStr(thumbnailObj["url"], "") || void 0;
3021
- }
3035
+ let duration = 0;
3036
+ const mediaArr = obj["media"];
3037
+ if (Array.isArray(mediaArr) && mediaArr.length > 0) {
3038
+ const first = mediaArr[0];
3039
+ duration = toNum(first["duration"], 0);
3022
3040
  }
3023
3041
  if (duration === 0) {
3024
3042
  duration = toNum(tryFields(obj, "duration", "duration_seconds", "length", "video_duration"), 0);
package/dist/index.d.cts CHANGED
@@ -789,6 +789,33 @@ declare function useSDK(): SDKContextValue;
789
789
 
790
790
  declare function ReelsFeed({ renderOverlay, renderActions, renderPauseAction, renderLoading, renderEmpty, renderError: _renderError, showFps, loadMoreThreshold, onSlotChange, gestureConfig, snapConfig, initialMuted, onAutoplayBlocked, }: ReelsFeedProps): string | number | bigint | boolean | Iterable<react.ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<react.ReactNode> | null | undefined> | react_jsx_runtime.JSX.Element | null | undefined;
791
791
 
792
+ interface ReelsFeedThumbnailProps {
793
+ /** Render function for a single item's visual card. */
794
+ renderThumbnail: (videoData: ContentItem, index: number) => ReactNode;
795
+ /** Click handler — receives the item id, full data, and index in the feed. */
796
+ onThumbnailClick?: (id: string, videoData: ContentItem, index: number) => void;
797
+ /** Rendered when the feed is loading and has zero items yet. */
798
+ renderLoading?: () => ReactNode;
799
+ /** Rendered when the feed has loaded but is empty. */
800
+ renderEmpty?: () => ReactNode;
801
+ /** Rendered when the feed fails with no items — receives a retry callback. */
802
+ renderError?: (error: {
803
+ message: string;
804
+ retry: () => void;
805
+ }) => ReactNode;
806
+ /** Outer wrapper className. Defaults to `grid grid-cols-2 gap-3`. */
807
+ className?: string;
808
+ /** If the host wants to render its own wrapper, pass `false`. Default: true. */
809
+ wrap?: boolean;
810
+ /** Enable click → update focused index on ReelsProvider before host reacts. Default: true. */
811
+ setFocusOnClick?: boolean;
812
+ /** Prefetch metadata for this slot on pointer enter. Off by default. */
813
+ prefetchOnHover?: boolean;
814
+ /** Key override — useful when host renders multiple lists from the same feed. */
815
+ getKey?: (item: ContentItem, index: number) => string;
816
+ }
817
+ declare function ReelsFeedThumbnail({ renderThumbnail, onThumbnailClick, renderLoading, renderEmpty, renderError, className, wrap, setFocusOnClick, prefetchOnHover, getKey, }: ReelsFeedThumbnailProps): react_jsx_runtime.JSX.Element | null;
818
+
792
819
  /**
793
820
  * useHls — React hook for hls.js lifecycle management (3-Tier buffer support)
794
821
  *
@@ -875,41 +902,6 @@ interface VideoSlotProps {
875
902
  }
876
903
  declare function VideoSlot({ item, index, isActive, isPrefetch, isPreloaded, bufferTier, isMuted, onToggleMute, onAutoplayBlocked, showFps, isDragging, renderOverlay, renderActions, renderPauseAction, }: VideoSlotProps): react_jsx_runtime.JSX.Element;
877
904
 
878
- interface ReelsFeedThumbnailProps {
879
- /** Render function for a single item's visual card. */
880
- renderThumbnail: (videoData: ContentItem, index: number) => ReactNode;
881
- /** Click handler — receives the item id, full data, and index in the feed. */
882
- onThumbnailClick?: (id: string, videoData: ContentItem, index: number) => void;
883
- /** Rendered when the feed is loading and has zero items yet. */
884
- renderLoading?: () => ReactNode;
885
- /** Rendered when the feed has loaded but is empty. */
886
- renderEmpty?: () => ReactNode;
887
- /** Rendered when the feed fails with no items — receives a retry callback. */
888
- renderError?: (error: {
889
- message: string;
890
- retry: () => void;
891
- }) => ReactNode;
892
- /** Outer wrapper className. Defaults to `grid grid-cols-2 gap-3`. */
893
- className?: string;
894
- /** If the host wants to render its own wrapper, pass `false`. */
895
- wrap?: boolean;
896
- /**
897
- * Update ResourceGovernor's focused index on click so the drawer/feed opens
898
- * already pointing at the tapped item.
899
- * @default true
900
- */
901
- setFocusOnClick?: boolean;
902
- /**
903
- * Prefetch HLS metadata for this slot on `pointerenter`. Off by default;
904
- * only useful on hover-capable devices.
905
- * @default false
906
- */
907
- prefetchOnHover?: boolean;
908
- /** Key override — useful when host renders multiple lists from the same feed. */
909
- getKey?: (item: ContentItem, index: number) => string;
910
- }
911
- declare function ReelsFeedThumbnail({ renderThumbnail, onThumbnailClick, renderLoading, renderEmpty, renderError, className, wrap, setFocusOnClick, prefetchOnHover, getKey, }: ReelsFeedThumbnailProps): react_jsx_runtime.JSX.Element | null;
912
-
913
905
  declare function DefaultOverlay({ item }: {
914
906
  item: ContentItem;
915
907
  }): react_jsx_runtime.JSX.Element;
package/dist/index.d.ts CHANGED
@@ -789,6 +789,33 @@ declare function useSDK(): SDKContextValue;
789
789
 
790
790
  declare function ReelsFeed({ renderOverlay, renderActions, renderPauseAction, renderLoading, renderEmpty, renderError: _renderError, showFps, loadMoreThreshold, onSlotChange, gestureConfig, snapConfig, initialMuted, onAutoplayBlocked, }: ReelsFeedProps): string | number | bigint | boolean | Iterable<react.ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<react.ReactNode> | null | undefined> | react_jsx_runtime.JSX.Element | null | undefined;
791
791
 
792
+ interface ReelsFeedThumbnailProps {
793
+ /** Render function for a single item's visual card. */
794
+ renderThumbnail: (videoData: ContentItem, index: number) => ReactNode;
795
+ /** Click handler — receives the item id, full data, and index in the feed. */
796
+ onThumbnailClick?: (id: string, videoData: ContentItem, index: number) => void;
797
+ /** Rendered when the feed is loading and has zero items yet. */
798
+ renderLoading?: () => ReactNode;
799
+ /** Rendered when the feed has loaded but is empty. */
800
+ renderEmpty?: () => ReactNode;
801
+ /** Rendered when the feed fails with no items — receives a retry callback. */
802
+ renderError?: (error: {
803
+ message: string;
804
+ retry: () => void;
805
+ }) => ReactNode;
806
+ /** Outer wrapper className. Defaults to `grid grid-cols-2 gap-3`. */
807
+ className?: string;
808
+ /** If the host wants to render its own wrapper, pass `false`. Default: true. */
809
+ wrap?: boolean;
810
+ /** Enable click → update focused index on ReelsProvider before host reacts. Default: true. */
811
+ setFocusOnClick?: boolean;
812
+ /** Prefetch metadata for this slot on pointer enter. Off by default. */
813
+ prefetchOnHover?: boolean;
814
+ /** Key override — useful when host renders multiple lists from the same feed. */
815
+ getKey?: (item: ContentItem, index: number) => string;
816
+ }
817
+ declare function ReelsFeedThumbnail({ renderThumbnail, onThumbnailClick, renderLoading, renderEmpty, renderError, className, wrap, setFocusOnClick, prefetchOnHover, getKey, }: ReelsFeedThumbnailProps): react_jsx_runtime.JSX.Element | null;
818
+
792
819
  /**
793
820
  * useHls — React hook for hls.js lifecycle management (3-Tier buffer support)
794
821
  *
@@ -875,41 +902,6 @@ interface VideoSlotProps {
875
902
  }
876
903
  declare function VideoSlot({ item, index, isActive, isPrefetch, isPreloaded, bufferTier, isMuted, onToggleMute, onAutoplayBlocked, showFps, isDragging, renderOverlay, renderActions, renderPauseAction, }: VideoSlotProps): react_jsx_runtime.JSX.Element;
877
904
 
878
- interface ReelsFeedThumbnailProps {
879
- /** Render function for a single item's visual card. */
880
- renderThumbnail: (videoData: ContentItem, index: number) => ReactNode;
881
- /** Click handler — receives the item id, full data, and index in the feed. */
882
- onThumbnailClick?: (id: string, videoData: ContentItem, index: number) => void;
883
- /** Rendered when the feed is loading and has zero items yet. */
884
- renderLoading?: () => ReactNode;
885
- /** Rendered when the feed has loaded but is empty. */
886
- renderEmpty?: () => ReactNode;
887
- /** Rendered when the feed fails with no items — receives a retry callback. */
888
- renderError?: (error: {
889
- message: string;
890
- retry: () => void;
891
- }) => ReactNode;
892
- /** Outer wrapper className. Defaults to `grid grid-cols-2 gap-3`. */
893
- className?: string;
894
- /** If the host wants to render its own wrapper, pass `false`. */
895
- wrap?: boolean;
896
- /**
897
- * Update ResourceGovernor's focused index on click so the drawer/feed opens
898
- * already pointing at the tapped item.
899
- * @default true
900
- */
901
- setFocusOnClick?: boolean;
902
- /**
903
- * Prefetch HLS metadata for this slot on `pointerenter`. Off by default;
904
- * only useful on hover-capable devices.
905
- * @default false
906
- */
907
- prefetchOnHover?: boolean;
908
- /** Key override — useful when host renders multiple lists from the same feed. */
909
- getKey?: (item: ContentItem, index: number) => string;
910
- }
911
- declare function ReelsFeedThumbnail({ renderThumbnail, onThumbnailClick, renderLoading, renderEmpty, renderError, className, wrap, setFocusOnClick, prefetchOnHover, getKey, }: ReelsFeedThumbnailProps): react_jsx_runtime.JSX.Element | null;
912
-
913
905
  declare function DefaultOverlay({ item }: {
914
906
  item: ContentItem;
915
907
  }): react_jsx_runtime.JSX.Element;
package/dist/index.js CHANGED
@@ -1441,7 +1441,7 @@ function useHls(options) {
1441
1441
  setIsReady(false);
1442
1442
  canPlayFiredRef.current = false;
1443
1443
  currentSrcRef.current = src;
1444
- const handleCanPlay2 = () => {
1444
+ const handleCanPlay = () => {
1445
1445
  canPlayFiredRef.current = true;
1446
1446
  setIsReady(true);
1447
1447
  };
@@ -1451,10 +1451,10 @@ function useHls(options) {
1451
1451
  setIsReady(true);
1452
1452
  }
1453
1453
  };
1454
- video.addEventListener("canplay", handleCanPlay2, { once: true });
1454
+ video.addEventListener("canplay", handleCanPlay, { once: true });
1455
1455
  video.addEventListener("loadeddata", handleLoadedData, { once: true });
1456
1456
  return () => {
1457
- video.removeEventListener("canplay", handleCanPlay2);
1457
+ video.removeEventListener("canplay", handleCanPlay);
1458
1458
  video.removeEventListener("loadeddata", handleLoadedData);
1459
1459
  };
1460
1460
  }
@@ -1464,13 +1464,21 @@ function useHls(options) {
1464
1464
  }
1465
1465
  if (hlsRef.current && currentSrcRef.current === src) {
1466
1466
  if (!canPlayFiredRef.current) {
1467
- const handleCanPlay2 = () => {
1467
+ if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
1468
+ canPlayFiredRef.current = true;
1469
+ setIsReady(true);
1470
+ return void 0;
1471
+ }
1472
+ const handleReady2 = () => {
1473
+ if (canPlayFiredRef.current) return;
1468
1474
  canPlayFiredRef.current = true;
1469
1475
  setIsReady(true);
1470
1476
  };
1471
- video.addEventListener("canplay", handleCanPlay2, { once: true });
1477
+ video.addEventListener("loadeddata", handleReady2, { once: true });
1478
+ video.addEventListener("canplay", handleReady2, { once: true });
1472
1479
  return () => {
1473
- video.removeEventListener("canplay", handleCanPlay2);
1480
+ video.removeEventListener("loadeddata", handleReady2);
1481
+ video.removeEventListener("canplay", handleReady2);
1474
1482
  };
1475
1483
  }
1476
1484
  return void 0;
@@ -1507,15 +1515,18 @@ function useHls(options) {
1507
1515
  const mapped = mapHlsError(data);
1508
1516
  onErrorRef.current?.(mapped.code, mapped.message);
1509
1517
  });
1510
- const handleCanPlay = () => {
1518
+ const handleReady = () => {
1519
+ if (canPlayFiredRef.current) return;
1511
1520
  canPlayFiredRef.current = true;
1512
1521
  setIsReady(true);
1513
1522
  };
1514
- video.addEventListener("canplay", handleCanPlay, { once: true });
1523
+ video.addEventListener("loadeddata", handleReady, { once: true });
1524
+ video.addEventListener("canplay", handleReady, { once: true });
1515
1525
  hls.attachMedia(video);
1516
1526
  hls.loadSource(src);
1517
1527
  return () => {
1518
- video.removeEventListener("canplay", handleCanPlay);
1528
+ video.removeEventListener("loadeddata", handleReady);
1529
+ video.removeEventListener("canplay", handleReady);
1519
1530
  if (hlsRef.current === hls) {
1520
1531
  hls.destroy();
1521
1532
  hlsRef.current = null;
@@ -1886,11 +1897,18 @@ function VideoSlotInner({
1886
1897
  };
1887
1898
  if (isActive && !isManuallyPaused) {
1888
1899
  wasActiveRef.current = true;
1889
- if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
1900
+ if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
1890
1901
  attemptPlay();
1891
1902
  } else {
1892
- onReady = attemptPlay;
1893
- video.addEventListener("canplay", onReady, { once: true });
1903
+ let consumed = false;
1904
+ const handler = () => {
1905
+ if (consumed) return;
1906
+ consumed = true;
1907
+ attemptPlay();
1908
+ };
1909
+ onReady = handler;
1910
+ video.addEventListener("loadeddata", handler, { once: true });
1911
+ video.addEventListener("canplay", handler, { once: true });
1894
1912
  }
1895
1913
  } else if (isActive && isManuallyPaused) {
1896
1914
  wasActiveRef.current = true;
@@ -1906,7 +1924,10 @@ function VideoSlotInner({
1906
1924
  }
1907
1925
  return () => {
1908
1926
  cancelled = true;
1909
- if (onReady) video.removeEventListener("canplay", onReady);
1927
+ if (onReady) {
1928
+ video.removeEventListener("loadeddata", onReady);
1929
+ video.removeEventListener("canplay", onReady);
1930
+ }
1910
1931
  };
1911
1932
  }, [isActive, isMuted, hasPlayedAhead, isManuallyPaused, onAutoplayBlocked]);
1912
1933
  useEffect(() => {
@@ -2046,7 +2067,7 @@ function VideoSlotInner({
2046
2067
  inset: 0,
2047
2068
  backgroundImage: `url(${item.poster})`,
2048
2069
  backgroundSize: "cover",
2049
- backgroundPosition: "center center",
2070
+ backgroundPosition: "center",
2050
2071
  opacity: showPosterOverlay ? 1 : 0,
2051
2072
  transition: "opacity 0.15s ease",
2052
2073
  pointerEvents: "none"
@@ -2493,87 +2514,80 @@ function parsePxTranslateY(el) {
2493
2514
  if (!match || !match[1]) return 0;
2494
2515
  return Number.parseFloat(match[1]);
2495
2516
  }
2496
- var DEFAULT_CLASSNAME = "grid grid-cols-2 gap-3";
2497
- var defaultGetKey = (item) => item.id;
2498
- var buttonResetStyle = {
2499
- all: "unset",
2500
- display: "block",
2501
- cursor: "pointer",
2502
- width: "100%",
2503
- boxSizing: "border-box"
2504
- };
2505
2517
  function ReelsFeedThumbnail({
2506
2518
  renderThumbnail,
2507
2519
  onThumbnailClick,
2508
2520
  renderLoading,
2509
2521
  renderEmpty,
2510
2522
  renderError,
2511
- className = DEFAULT_CLASSNAME,
2523
+ className = "grid grid-cols-2 gap-3",
2512
2524
  wrap = true,
2513
2525
  setFocusOnClick = true,
2514
2526
  prefetchOnHover = false,
2515
- getKey = defaultGetKey
2527
+ getKey
2516
2528
  }) {
2517
2529
  const { items, loading, error, refresh } = useFeed();
2518
2530
  const { setFocusedIndexImmediate } = useResource();
2519
2531
  const { adapters } = useSDK();
2520
2532
  const prefetchedRef = useRef(/* @__PURE__ */ new Set());
2521
2533
  const handleClick = useCallback(
2522
- (item, index) => {
2534
+ (id, item, index) => {
2523
2535
  if (setFocusOnClick) {
2524
2536
  setFocusedIndexImmediate(index);
2525
2537
  }
2526
- onThumbnailClick?.(item.id, item, index);
2538
+ onThumbnailClick?.(id, item, index);
2527
2539
  },
2528
- [onThumbnailClick, setFocusOnClick, setFocusedIndexImmediate]
2540
+ [setFocusOnClick, setFocusedIndexImmediate, onThumbnailClick]
2529
2541
  );
2530
2542
  const handlePointerEnter = useCallback(
2531
2543
  (item) => {
2532
2544
  if (!prefetchOnHover) return;
2533
2545
  if (!isVideoItem(item)) return;
2534
2546
  if (item.source.type !== "hls") return;
2535
- if (prefetchedRef.current.has(item.id)) return;
2536
- const loader = adapters.videoLoader;
2537
- if (!loader?.preloadMetadata) return;
2538
- if (loader.isPreloaded(item.id)) {
2539
- prefetchedRef.current.add(item.id);
2540
- return;
2541
- }
2542
- loader.preloadMetadata(item.source.url);
2543
- prefetchedRef.current.add(item.id);
2547
+ const url = item.source.url;
2548
+ if (prefetchedRef.current.has(url)) return;
2549
+ prefetchedRef.current.add(url);
2550
+ adapters.videoLoader?.preloadMetadata?.(url);
2544
2551
  },
2545
2552
  [prefetchOnHover, adapters.videoLoader]
2546
2553
  );
2547
- const children = useMemo(
2548
- () => items.map((item, index) => {
2549
- const key = getKey(item, index);
2550
- const onEnter = prefetchOnHover ? (_e) => handlePointerEnter(item) : void 0;
2551
- return /* @__PURE__ */ jsx(
2552
- "button",
2553
- {
2554
- type: "button",
2555
- style: buttonResetStyle,
2556
- "data-thumbnail-index": index,
2557
- "data-thumbnail-id": item.id,
2558
- onClick: () => handleClick(item, index),
2559
- onPointerEnter: onEnter,
2560
- children: renderThumbnail(item, index)
2561
- },
2562
- key
2563
- );
2564
- }),
2565
- [items, getKey, prefetchOnHover, handlePointerEnter, handleClick, renderThumbnail]
2566
- );
2567
- if (items.length === 0) {
2568
- if (loading && renderLoading) return /* @__PURE__ */ jsx(Fragment, { children: renderLoading() });
2569
- if (error && renderError) {
2554
+ if (loading && items.length === 0) {
2555
+ if (renderLoading) return /* @__PURE__ */ jsx(Fragment, { children: renderLoading() });
2556
+ return null;
2557
+ }
2558
+ if (error && items.length === 0) {
2559
+ if (renderError) {
2570
2560
  return /* @__PURE__ */ jsx(Fragment, { children: renderError({ message: error.message, retry: refresh }) });
2571
2561
  }
2572
- if (!loading && !error && renderEmpty) return /* @__PURE__ */ jsx(Fragment, { children: renderEmpty() });
2573
2562
  return null;
2574
2563
  }
2575
- if (!wrap) return /* @__PURE__ */ jsx(Fragment, { children });
2576
- return /* @__PURE__ */ jsx("div", { className, children });
2564
+ if (!loading && items.length === 0) {
2565
+ if (renderEmpty) return /* @__PURE__ */ jsx(Fragment, { children: renderEmpty() });
2566
+ return null;
2567
+ }
2568
+ const content = items.map((item, index) => {
2569
+ const key = getKey ? getKey(item, index) : item.id;
2570
+ return /* @__PURE__ */ jsx(
2571
+ "button",
2572
+ {
2573
+ type: "button",
2574
+ onClick: () => handleClick(item.id, item, index),
2575
+ onPointerEnter: () => handlePointerEnter(item),
2576
+ style: {
2577
+ all: "unset",
2578
+ cursor: "pointer",
2579
+ display: "block",
2580
+ width: "100%"
2581
+ },
2582
+ children: renderThumbnail(item, index)
2583
+ },
2584
+ key
2585
+ );
2586
+ });
2587
+ if (!wrap) {
2588
+ return /* @__PURE__ */ jsx(Fragment, { children: content });
2589
+ }
2590
+ return /* @__PURE__ */ jsx("div", { className, children: content });
2577
2591
  }
2578
2592
  function usePlayerSelector(selector) {
2579
2593
  const { playerEngine } = useSDK();
@@ -2995,12 +3009,16 @@ function transformVideoItem(raw) {
2995
3009
  const stats = transformStats(obj);
2996
3010
  const interaction = transformInteraction(obj);
2997
3011
  let poster;
2998
- let duration = 0;
2999
- const mediaArr = obj["media"];
3000
- if (Array.isArray(mediaArr) && mediaArr.length > 0) {
3001
- const first = mediaArr[0];
3002
- poster = toStr(first["poster"], "") || void 0;
3003
- duration = toNum(first["duration"], 0);
3012
+ const thumbnailObj = obj["thumbnail"];
3013
+ if (thumbnailObj && thumbnailObj["url"]) {
3014
+ poster = toStr(thumbnailObj["url"], "") || void 0;
3015
+ }
3016
+ if (!poster) {
3017
+ const mediaArr2 = obj["media"];
3018
+ if (Array.isArray(mediaArr2) && mediaArr2.length > 0) {
3019
+ const first = mediaArr2[0];
3020
+ poster = toStr(first["poster"], "") || void 0;
3021
+ }
3004
3022
  }
3005
3023
  if (!poster) {
3006
3024
  poster = toStr(
@@ -3008,11 +3026,11 @@ function transformVideoItem(raw) {
3008
3026
  void 0
3009
3027
  ) || void 0;
3010
3028
  }
3011
- if (!poster) {
3012
- const thumbnailObj = obj["thumbnail"];
3013
- if (thumbnailObj && thumbnailObj["url"]) {
3014
- poster = toStr(thumbnailObj["url"], "") || void 0;
3015
- }
3029
+ let duration = 0;
3030
+ const mediaArr = obj["media"];
3031
+ if (Array.isArray(mediaArr) && mediaArr.length > 0) {
3032
+ const first = mediaArr[0];
3033
+ duration = toNum(first["duration"], 0);
3016
3034
  }
3017
3035
  if (duration === 0) {
3018
3036
  duration = toNum(tryFields(obj, "duration", "duration_seconds", "length", "video_duration"), 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xhub-reels/sdk",
3
- "version": "0.2.1",
3
+ "version": "0.2.6",
4
4
  "description": "High-performance Short Video / Reels SDK for React — optimized for Flutter WebView",
5
5
  "license": "MIT",
6
6
  "type": "module",