@xhub-reels/sdk 0.2.17 → 0.2.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
@@ -2112,42 +2112,6 @@ function VideoSlotInner({
2112
2112
  }
2113
2113
  }, [mp4Src, isActive, isPrefetch, isPreloaded, isHlsSource]);
2114
2114
  const isReady = isHlsSource ? hlsReady : mp4Ready;
2115
- const [capturedPoster, setCapturedPoster] = react.useState(null);
2116
- react.useEffect(() => {
2117
- const video = videoRef.current;
2118
- if (!video || !shouldLoadSrc) return;
2119
- let cancelled = false;
2120
- const captureFrame = () => {
2121
- if (cancelled) return;
2122
- if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
2123
- if (video.videoWidth === 0 || video.videoHeight === 0) return;
2124
- try {
2125
- const canvas = document.createElement("canvas");
2126
- canvas.width = video.videoWidth;
2127
- canvas.height = video.videoHeight;
2128
- const ctx = canvas.getContext("2d");
2129
- if (!ctx) return;
2130
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
2131
- const dataUrl = canvas.toDataURL("image/webp", 0.85);
2132
- if (!cancelled) {
2133
- setCapturedPoster(dataUrl);
2134
- }
2135
- } catch {
2136
- }
2137
- };
2138
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth > 0) {
2139
- captureFrame();
2140
- } else {
2141
- video.addEventListener("loadeddata", captureFrame, { once: true });
2142
- }
2143
- return () => {
2144
- cancelled = true;
2145
- video.removeEventListener("loadeddata", captureFrame);
2146
- };
2147
- }, [src, shouldLoadSrc]);
2148
- react.useEffect(() => {
2149
- setCapturedPoster(null);
2150
- }, [src]);
2151
2115
  const [isVideoPlaying, setIsVideoPlaying] = react.useState(false);
2152
2116
  react.useEffect(() => {
2153
2117
  const video = videoRef.current;
@@ -2408,13 +2372,13 @@ function VideoSlotInner({
2408
2372
  }
2409
2373
  }
2410
2374
  ),
2411
- (capturedPoster || item.poster) && !isPreDecoded && /* @__PURE__ */ jsxRuntime.jsx(
2375
+ item.poster && !isPreDecoded && /* @__PURE__ */ jsxRuntime.jsx(
2412
2376
  "div",
2413
2377
  {
2414
2378
  style: {
2415
2379
  position: "absolute",
2416
2380
  inset: 0,
2417
- backgroundImage: `url(${capturedPoster || item.poster})`,
2381
+ backgroundImage: `url(${item.poster})`,
2418
2382
  backgroundSize: "cover",
2419
2383
  backgroundPosition: "center",
2420
2384
  opacity: showPosterOverlay ? 1 : 0,
@@ -3070,7 +3034,8 @@ function ReelsModal({
3070
3034
  if (prewarmedRef.current === openIndex) return;
3071
3035
  prewarmedRef.current = openIndex;
3072
3036
  setFocusedIndexImmediate(openIndex);
3073
- const preload = adapters.videoLoader?.preloadMetadata;
3037
+ const loader = adapters.videoLoader;
3038
+ const preload = loader?.preloadMetadata?.bind(loader);
3074
3039
  if (!preload) return;
3075
3040
  const targets = /* @__PURE__ */ new Set();
3076
3041
  targets.add(openIndex);
@@ -3926,6 +3891,109 @@ var HttpError = class extends Error {
3926
3891
  }
3927
3892
  };
3928
3893
 
3894
+ // src/adapters/browser/BrowserVideoLoader.ts
3895
+ var BrowserVideoLoader = class {
3896
+ constructor(config = {}) {
3897
+ this.statusById = /* @__PURE__ */ new Map();
3898
+ this.controllers = /* @__PURE__ */ new Map();
3899
+ /** URLs already warmed via preloadMetadata — dedupes fire-and-forget calls. */
3900
+ this.warmedUrls = /* @__PURE__ */ new Set();
3901
+ this.timeoutMs = config.timeoutMs ?? 8e3;
3902
+ this.mp4PrefetchBytes = config.mp4PrefetchBytes ?? 512 * 1024;
3903
+ this.headers = config.headers ?? {};
3904
+ this.logger = config.logger;
3905
+ }
3906
+ async preload(videoId, url, signal) {
3907
+ if (typeof fetch === "undefined") {
3908
+ return { videoId, status: "idle" };
3909
+ }
3910
+ if (this.statusById.get(videoId) === "loaded") {
3911
+ return { videoId, status: "loaded" };
3912
+ }
3913
+ this.cancel(videoId);
3914
+ const controller = new AbortController();
3915
+ this.controllers.set(videoId, controller);
3916
+ this.statusById.set(videoId, "loading");
3917
+ const onAbort = () => controller.abort();
3918
+ signal?.addEventListener("abort", onAbort);
3919
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
3920
+ const isHls = url.includes(".m3u8");
3921
+ const requestHeaders = { ...this.headers };
3922
+ if (!isHls && this.mp4PrefetchBytes > 0) {
3923
+ requestHeaders["Range"] = `bytes=0-${this.mp4PrefetchBytes - 1}`;
3924
+ }
3925
+ try {
3926
+ const res = await fetch(url, {
3927
+ method: "GET",
3928
+ signal: controller.signal,
3929
+ headers: requestHeaders
3930
+ });
3931
+ if (!res.ok && res.status !== 206) {
3932
+ throw new Error(`HTTP ${res.status}`);
3933
+ }
3934
+ const buf = await res.arrayBuffer();
3935
+ this.statusById.set(videoId, "loaded");
3936
+ this.warmedUrls.add(url);
3937
+ return { videoId, status: "loaded", loadedBytes: buf.byteLength };
3938
+ } catch (err) {
3939
+ const error = err instanceof Error ? err : new Error(String(err));
3940
+ if (error.name === "AbortError") {
3941
+ this.statusById.set(videoId, "idle");
3942
+ return { videoId, status: "idle", error };
3943
+ }
3944
+ this.statusById.set(videoId, "error");
3945
+ this.logger?.warn(`[BrowserVideoLoader] preload failed for ${videoId}`, error.message);
3946
+ return { videoId, status: "error", error };
3947
+ } finally {
3948
+ clearTimeout(timer);
3949
+ signal?.removeEventListener("abort", onAbort);
3950
+ this.controllers.delete(videoId);
3951
+ }
3952
+ }
3953
+ cancel(videoId) {
3954
+ const controller = this.controllers.get(videoId);
3955
+ if (controller) {
3956
+ controller.abort();
3957
+ this.controllers.delete(videoId);
3958
+ }
3959
+ if (this.statusById.get(videoId) === "loading") {
3960
+ this.statusById.set(videoId, "idle");
3961
+ }
3962
+ }
3963
+ isPreloaded(videoId) {
3964
+ return this.statusById.get(videoId) === "loaded";
3965
+ }
3966
+ getPreloadStatus(videoId) {
3967
+ return this.statusById.get(videoId) ?? "idle";
3968
+ }
3969
+ clearAll() {
3970
+ for (const controller of this.controllers.values()) {
3971
+ controller.abort();
3972
+ }
3973
+ this.controllers.clear();
3974
+ this.statusById.clear();
3975
+ this.warmedUrls.clear();
3976
+ }
3977
+ /**
3978
+ * Tier-4 cold prefetch. Fire-and-forget GET that warms the HTTP cache.
3979
+ * No DOM, no segment download, never throws. Deduped per-URL.
3980
+ */
3981
+ preloadMetadata(url) {
3982
+ if (typeof fetch === "undefined") return;
3983
+ if (!url || this.warmedUrls.has(url)) return;
3984
+ this.warmedUrls.add(url);
3985
+ const isHls = url.includes(".m3u8");
3986
+ const headers = { ...this.headers };
3987
+ if (!isHls) {
3988
+ headers["Range"] = "bytes=0-0";
3989
+ }
3990
+ void fetch(url, { method: "GET", headers }).catch(() => {
3991
+ this.warmedUrls.delete(url);
3992
+ });
3993
+ }
3994
+ };
3995
+
3996
+ exports.BrowserVideoLoader = BrowserVideoLoader;
3929
3997
  exports.DEFAULT_FEED_CONFIG = DEFAULT_FEED_CONFIG;
3930
3998
  exports.DEFAULT_NAVIGATION_CONFIG = DEFAULT_NAVIGATION_CONFIG;
3931
3999
  exports.DEFAULT_PLAYER_CONFIG = DEFAULT_PLAYER_CONFIG;
package/dist/index.d.cts CHANGED
@@ -1423,4 +1423,62 @@ declare class HttpError extends Error {
1423
1423
  constructor(status: number, message: string, body?: string | undefined);
1424
1424
  }
1425
1425
 
1426
- export { type Article, type ArticleImage, type Author, type BufferTier, type CommentItem, type CommentPage, type ContentItem, type ContentStats, DEFAULT_FEED_CONFIG, DEFAULT_NAVIGATION_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_RESOURCE_CONFIG, DefaultActions, DefaultOverlay, DefaultPauseAction, DefaultSkeleton, type FeedConfig, type FeedError, FeedManager, type FeedPage, type FeedState, HttpDataSource, type HttpDataSourceConfig, HttpError, type IAnalytics, type ICommentAdapter, type IDataSource, type IInteraction, type ILogger, type INetworkAdapter, type ISessionStorage, type IVideoLoader, type InteractionState, type LogLevel, MockAnalytics, MockCommentAdapter, MockDataSource, MockInteraction, MockLogger, MockNetworkAdapter, MockSessionStorage, MockVideoLoader, type NavigationConfig, NavigationManager, type NavigationPhase, type NavigationState, type NetworkType, OptimisticManager, type PauseSlotActions, type PlayerConfig, PlayerEngine, type PlayerError, type PlayerEvent, type PlayerEventListener, type PlayerState, PlayerStatus, type PointerGestureConfig, type PreloadResult, type PreloadStatus, ReelsFeed, type ReelsFeedProps, ReelsFeedThumbnail, type ReelsFeedThumbnailProps, ReelsModal, type ReelsModalAnimationConfig, type ReelsModalProps, type ReelsModalRenderState, ReelsProvider, type ReelsProviderProps, type ResourceConfig, ResourceGovernor, type ResourceState, type SDKAdapters, type SDKContextValue, type SlotActions, type SnapTarget, type UseHlsOptions, type UseHlsReturn, VALID_TRANSITIONS, type VideoItem, type VideoQuality, VideoSlot, type VideoSource, canPause, canPlay, canSeek, isArticle, isValidNavTransition, isValidTransition, isVideoItem, useFeed, useFeedSelector, useHls, useNavigation, useNavigationSelector, usePlayer, usePlayerSelector, usePointerGesture, useResource, useResourceSelector, useSDK, useSnapAnimation };
1426
+ /**
1427
+ * BrowserVideoLoader — IVideoLoader implementation for browsers / WebViews.
1428
+ *
1429
+ * Warms the HTTP browser cache so hls.js / <video> can start from cache instead
1430
+ * of hitting the network cold on mount. This is what closes the "poster stuck
1431
+ * ~3s" stall: both the modal-owned prewarm path (ReelsModal `opening` phase) and
1432
+ * the ResourceGovernor Tier-4 cold prefetch call `videoLoader?.preloadMetadata`.
1433
+ * That method is OPTIONAL on IVideoLoader, so if no loader implements it the
1434
+ * entire prewarm path is silently a no-op. This adapter implements it.
1435
+ *
1436
+ * Strategy:
1437
+ * - `preloadMetadata(url)` — fire-and-forget GET of the HLS manifest (or mp4
1438
+ * head via `Range: bytes=0-0`) into the HTTP cache. No DOM, no segment
1439
+ * download, never throws. Deduped per-URL so repeated taps are cheap.
1440
+ * - `preload(videoId, url, signal?)` — awaitable warm with state tracking and
1441
+ * cancellation; resolves to a PreloadResult. For progressive mp4 it fetches
1442
+ * only the first `mp4PrefetchBytes` via a Range request.
1443
+ *
1444
+ * SSR-safe: when `fetch` is unavailable (server render) every method degrades to
1445
+ * a no-op rather than throwing.
1446
+ */
1447
+
1448
+ interface BrowserVideoLoaderConfig {
1449
+ /** Abort an in-flight {@link BrowserVideoLoader.preload} after this many ms (default: 8000). */
1450
+ timeoutMs?: number;
1451
+ /**
1452
+ * For progressive mp4 sources, fetch only the first N bytes via a Range
1453
+ * request to warm the cache without downloading the whole file.
1454
+ * Set to 0 to fetch the full file. Default: 524288 (512 KB).
1455
+ */
1456
+ mp4PrefetchBytes?: number;
1457
+ /** Static headers attached to every warm request (e.g. auth). */
1458
+ headers?: Record<string, string>;
1459
+ /** Optional logger for warm failures. */
1460
+ logger?: ILogger;
1461
+ }
1462
+ declare class BrowserVideoLoader implements IVideoLoader {
1463
+ private readonly timeoutMs;
1464
+ private readonly mp4PrefetchBytes;
1465
+ private readonly headers;
1466
+ private readonly logger?;
1467
+ private readonly statusById;
1468
+ private readonly controllers;
1469
+ /** URLs already warmed via preloadMetadata — dedupes fire-and-forget calls. */
1470
+ private readonly warmedUrls;
1471
+ constructor(config?: BrowserVideoLoaderConfig);
1472
+ preload(videoId: string, url: string, signal?: AbortSignal): Promise<PreloadResult>;
1473
+ cancel(videoId: string): void;
1474
+ isPreloaded(videoId: string): boolean;
1475
+ getPreloadStatus(videoId: string): PreloadStatus;
1476
+ clearAll(): void;
1477
+ /**
1478
+ * Tier-4 cold prefetch. Fire-and-forget GET that warms the HTTP cache.
1479
+ * No DOM, no segment download, never throws. Deduped per-URL.
1480
+ */
1481
+ preloadMetadata(url: string): void;
1482
+ }
1483
+
1484
+ export { type Article, type ArticleImage, type Author, BrowserVideoLoader, type BrowserVideoLoaderConfig, type BufferTier, type CommentItem, type CommentPage, type ContentItem, type ContentStats, DEFAULT_FEED_CONFIG, DEFAULT_NAVIGATION_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_RESOURCE_CONFIG, DefaultActions, DefaultOverlay, DefaultPauseAction, DefaultSkeleton, type FeedConfig, type FeedError, FeedManager, type FeedPage, type FeedState, HttpDataSource, type HttpDataSourceConfig, HttpError, type IAnalytics, type ICommentAdapter, type IDataSource, type IInteraction, type ILogger, type INetworkAdapter, type ISessionStorage, type IVideoLoader, type InteractionState, type LogLevel, MockAnalytics, MockCommentAdapter, MockDataSource, MockInteraction, MockLogger, MockNetworkAdapter, MockSessionStorage, MockVideoLoader, type NavigationConfig, NavigationManager, type NavigationPhase, type NavigationState, type NetworkType, OptimisticManager, type PauseSlotActions, type PlayerConfig, PlayerEngine, type PlayerError, type PlayerEvent, type PlayerEventListener, type PlayerState, PlayerStatus, type PointerGestureConfig, type PreloadResult, type PreloadStatus, ReelsFeed, type ReelsFeedProps, ReelsFeedThumbnail, type ReelsFeedThumbnailProps, ReelsModal, type ReelsModalAnimationConfig, type ReelsModalProps, type ReelsModalRenderState, ReelsProvider, type ReelsProviderProps, type ResourceConfig, ResourceGovernor, type ResourceState, type SDKAdapters, type SDKContextValue, type SlotActions, type SnapTarget, type UseHlsOptions, type UseHlsReturn, VALID_TRANSITIONS, type VideoItem, type VideoQuality, VideoSlot, type VideoSource, canPause, canPlay, canSeek, isArticle, isValidNavTransition, isValidTransition, isVideoItem, useFeed, useFeedSelector, useHls, useNavigation, useNavigationSelector, usePlayer, usePlayerSelector, usePointerGesture, useResource, useResourceSelector, useSDK, useSnapAnimation };
package/dist/index.d.ts CHANGED
@@ -1423,4 +1423,62 @@ declare class HttpError extends Error {
1423
1423
  constructor(status: number, message: string, body?: string | undefined);
1424
1424
  }
1425
1425
 
1426
- export { type Article, type ArticleImage, type Author, type BufferTier, type CommentItem, type CommentPage, type ContentItem, type ContentStats, DEFAULT_FEED_CONFIG, DEFAULT_NAVIGATION_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_RESOURCE_CONFIG, DefaultActions, DefaultOverlay, DefaultPauseAction, DefaultSkeleton, type FeedConfig, type FeedError, FeedManager, type FeedPage, type FeedState, HttpDataSource, type HttpDataSourceConfig, HttpError, type IAnalytics, type ICommentAdapter, type IDataSource, type IInteraction, type ILogger, type INetworkAdapter, type ISessionStorage, type IVideoLoader, type InteractionState, type LogLevel, MockAnalytics, MockCommentAdapter, MockDataSource, MockInteraction, MockLogger, MockNetworkAdapter, MockSessionStorage, MockVideoLoader, type NavigationConfig, NavigationManager, type NavigationPhase, type NavigationState, type NetworkType, OptimisticManager, type PauseSlotActions, type PlayerConfig, PlayerEngine, type PlayerError, type PlayerEvent, type PlayerEventListener, type PlayerState, PlayerStatus, type PointerGestureConfig, type PreloadResult, type PreloadStatus, ReelsFeed, type ReelsFeedProps, ReelsFeedThumbnail, type ReelsFeedThumbnailProps, ReelsModal, type ReelsModalAnimationConfig, type ReelsModalProps, type ReelsModalRenderState, ReelsProvider, type ReelsProviderProps, type ResourceConfig, ResourceGovernor, type ResourceState, type SDKAdapters, type SDKContextValue, type SlotActions, type SnapTarget, type UseHlsOptions, type UseHlsReturn, VALID_TRANSITIONS, type VideoItem, type VideoQuality, VideoSlot, type VideoSource, canPause, canPlay, canSeek, isArticle, isValidNavTransition, isValidTransition, isVideoItem, useFeed, useFeedSelector, useHls, useNavigation, useNavigationSelector, usePlayer, usePlayerSelector, usePointerGesture, useResource, useResourceSelector, useSDK, useSnapAnimation };
1426
+ /**
1427
+ * BrowserVideoLoader — IVideoLoader implementation for browsers / WebViews.
1428
+ *
1429
+ * Warms the HTTP browser cache so hls.js / <video> can start from cache instead
1430
+ * of hitting the network cold on mount. This is what closes the "poster stuck
1431
+ * ~3s" stall: both the modal-owned prewarm path (ReelsModal `opening` phase) and
1432
+ * the ResourceGovernor Tier-4 cold prefetch call `videoLoader?.preloadMetadata`.
1433
+ * That method is OPTIONAL on IVideoLoader, so if no loader implements it the
1434
+ * entire prewarm path is silently a no-op. This adapter implements it.
1435
+ *
1436
+ * Strategy:
1437
+ * - `preloadMetadata(url)` — fire-and-forget GET of the HLS manifest (or mp4
1438
+ * head via `Range: bytes=0-0`) into the HTTP cache. No DOM, no segment
1439
+ * download, never throws. Deduped per-URL so repeated taps are cheap.
1440
+ * - `preload(videoId, url, signal?)` — awaitable warm with state tracking and
1441
+ * cancellation; resolves to a PreloadResult. For progressive mp4 it fetches
1442
+ * only the first `mp4PrefetchBytes` via a Range request.
1443
+ *
1444
+ * SSR-safe: when `fetch` is unavailable (server render) every method degrades to
1445
+ * a no-op rather than throwing.
1446
+ */
1447
+
1448
+ interface BrowserVideoLoaderConfig {
1449
+ /** Abort an in-flight {@link BrowserVideoLoader.preload} after this many ms (default: 8000). */
1450
+ timeoutMs?: number;
1451
+ /**
1452
+ * For progressive mp4 sources, fetch only the first N bytes via a Range
1453
+ * request to warm the cache without downloading the whole file.
1454
+ * Set to 0 to fetch the full file. Default: 524288 (512 KB).
1455
+ */
1456
+ mp4PrefetchBytes?: number;
1457
+ /** Static headers attached to every warm request (e.g. auth). */
1458
+ headers?: Record<string, string>;
1459
+ /** Optional logger for warm failures. */
1460
+ logger?: ILogger;
1461
+ }
1462
+ declare class BrowserVideoLoader implements IVideoLoader {
1463
+ private readonly timeoutMs;
1464
+ private readonly mp4PrefetchBytes;
1465
+ private readonly headers;
1466
+ private readonly logger?;
1467
+ private readonly statusById;
1468
+ private readonly controllers;
1469
+ /** URLs already warmed via preloadMetadata — dedupes fire-and-forget calls. */
1470
+ private readonly warmedUrls;
1471
+ constructor(config?: BrowserVideoLoaderConfig);
1472
+ preload(videoId: string, url: string, signal?: AbortSignal): Promise<PreloadResult>;
1473
+ cancel(videoId: string): void;
1474
+ isPreloaded(videoId: string): boolean;
1475
+ getPreloadStatus(videoId: string): PreloadStatus;
1476
+ clearAll(): void;
1477
+ /**
1478
+ * Tier-4 cold prefetch. Fire-and-forget GET that warms the HTTP cache.
1479
+ * No DOM, no segment download, never throws. Deduped per-URL.
1480
+ */
1481
+ preloadMetadata(url: string): void;
1482
+ }
1483
+
1484
+ export { type Article, type ArticleImage, type Author, BrowserVideoLoader, type BrowserVideoLoaderConfig, type BufferTier, type CommentItem, type CommentPage, type ContentItem, type ContentStats, DEFAULT_FEED_CONFIG, DEFAULT_NAVIGATION_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_RESOURCE_CONFIG, DefaultActions, DefaultOverlay, DefaultPauseAction, DefaultSkeleton, type FeedConfig, type FeedError, FeedManager, type FeedPage, type FeedState, HttpDataSource, type HttpDataSourceConfig, HttpError, type IAnalytics, type ICommentAdapter, type IDataSource, type IInteraction, type ILogger, type INetworkAdapter, type ISessionStorage, type IVideoLoader, type InteractionState, type LogLevel, MockAnalytics, MockCommentAdapter, MockDataSource, MockInteraction, MockLogger, MockNetworkAdapter, MockSessionStorage, MockVideoLoader, type NavigationConfig, NavigationManager, type NavigationPhase, type NavigationState, type NetworkType, OptimisticManager, type PauseSlotActions, type PlayerConfig, PlayerEngine, type PlayerError, type PlayerEvent, type PlayerEventListener, type PlayerState, PlayerStatus, type PointerGestureConfig, type PreloadResult, type PreloadStatus, ReelsFeed, type ReelsFeedProps, ReelsFeedThumbnail, type ReelsFeedThumbnailProps, ReelsModal, type ReelsModalAnimationConfig, type ReelsModalProps, type ReelsModalRenderState, ReelsProvider, type ReelsProviderProps, type ResourceConfig, ResourceGovernor, type ResourceState, type SDKAdapters, type SDKContextValue, type SlotActions, type SnapTarget, type UseHlsOptions, type UseHlsReturn, VALID_TRANSITIONS, type VideoItem, type VideoQuality, VideoSlot, type VideoSource, canPause, canPlay, canSeek, isArticle, isValidNavTransition, isValidTransition, isVideoItem, useFeed, useFeedSelector, useHls, useNavigation, useNavigationSelector, usePlayer, usePlayerSelector, usePointerGesture, useResource, useResourceSelector, useSDK, useSnapAnimation };
package/dist/index.js CHANGED
@@ -2106,42 +2106,6 @@ function VideoSlotInner({
2106
2106
  }
2107
2107
  }, [mp4Src, isActive, isPrefetch, isPreloaded, isHlsSource]);
2108
2108
  const isReady = isHlsSource ? hlsReady : mp4Ready;
2109
- const [capturedPoster, setCapturedPoster] = useState(null);
2110
- useEffect(() => {
2111
- const video = videoRef.current;
2112
- if (!video || !shouldLoadSrc) return;
2113
- let cancelled = false;
2114
- const captureFrame = () => {
2115
- if (cancelled) return;
2116
- if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
2117
- if (video.videoWidth === 0 || video.videoHeight === 0) return;
2118
- try {
2119
- const canvas = document.createElement("canvas");
2120
- canvas.width = video.videoWidth;
2121
- canvas.height = video.videoHeight;
2122
- const ctx = canvas.getContext("2d");
2123
- if (!ctx) return;
2124
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
2125
- const dataUrl = canvas.toDataURL("image/webp", 0.85);
2126
- if (!cancelled) {
2127
- setCapturedPoster(dataUrl);
2128
- }
2129
- } catch {
2130
- }
2131
- };
2132
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth > 0) {
2133
- captureFrame();
2134
- } else {
2135
- video.addEventListener("loadeddata", captureFrame, { once: true });
2136
- }
2137
- return () => {
2138
- cancelled = true;
2139
- video.removeEventListener("loadeddata", captureFrame);
2140
- };
2141
- }, [src, shouldLoadSrc]);
2142
- useEffect(() => {
2143
- setCapturedPoster(null);
2144
- }, [src]);
2145
2109
  const [isVideoPlaying, setIsVideoPlaying] = useState(false);
2146
2110
  useEffect(() => {
2147
2111
  const video = videoRef.current;
@@ -2402,13 +2366,13 @@ function VideoSlotInner({
2402
2366
  }
2403
2367
  }
2404
2368
  ),
2405
- (capturedPoster || item.poster) && !isPreDecoded && /* @__PURE__ */ jsx(
2369
+ item.poster && !isPreDecoded && /* @__PURE__ */ jsx(
2406
2370
  "div",
2407
2371
  {
2408
2372
  style: {
2409
2373
  position: "absolute",
2410
2374
  inset: 0,
2411
- backgroundImage: `url(${capturedPoster || item.poster})`,
2375
+ backgroundImage: `url(${item.poster})`,
2412
2376
  backgroundSize: "cover",
2413
2377
  backgroundPosition: "center",
2414
2378
  opacity: showPosterOverlay ? 1 : 0,
@@ -3064,7 +3028,8 @@ function ReelsModal({
3064
3028
  if (prewarmedRef.current === openIndex) return;
3065
3029
  prewarmedRef.current = openIndex;
3066
3030
  setFocusedIndexImmediate(openIndex);
3067
- const preload = adapters.videoLoader?.preloadMetadata;
3031
+ const loader = adapters.videoLoader;
3032
+ const preload = loader?.preloadMetadata?.bind(loader);
3068
3033
  if (!preload) return;
3069
3034
  const targets = /* @__PURE__ */ new Set();
3070
3035
  targets.add(openIndex);
@@ -3920,4 +3885,106 @@ var HttpError = class extends Error {
3920
3885
  }
3921
3886
  };
3922
3887
 
3923
- export { DEFAULT_FEED_CONFIG, DEFAULT_NAVIGATION_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_RESOURCE_CONFIG, DefaultActions, DefaultOverlay, DefaultPauseAction, DefaultSkeleton, FeedManager, HttpDataSource, HttpError, MockAnalytics, MockCommentAdapter, MockDataSource, MockInteraction, MockLogger, MockNetworkAdapter, MockSessionStorage, MockVideoLoader, NavigationManager, OptimisticManager, PlayerEngine, PlayerStatus, ReelsFeed, ReelsFeedThumbnail, ReelsModal, ReelsProvider, ResourceGovernor, VALID_TRANSITIONS, VideoSlot, canPause, canPlay, canSeek, isArticle, isValidNavTransition, isValidTransition, isVideoItem, useFeed, useFeedSelector, useHls, useNavigation, useNavigationSelector, usePlayer, usePlayerSelector, usePointerGesture, useResource, useResourceSelector, useSDK, useSnapAnimation };
3888
+ // src/adapters/browser/BrowserVideoLoader.ts
3889
+ var BrowserVideoLoader = class {
3890
+ constructor(config = {}) {
3891
+ this.statusById = /* @__PURE__ */ new Map();
3892
+ this.controllers = /* @__PURE__ */ new Map();
3893
+ /** URLs already warmed via preloadMetadata — dedupes fire-and-forget calls. */
3894
+ this.warmedUrls = /* @__PURE__ */ new Set();
3895
+ this.timeoutMs = config.timeoutMs ?? 8e3;
3896
+ this.mp4PrefetchBytes = config.mp4PrefetchBytes ?? 512 * 1024;
3897
+ this.headers = config.headers ?? {};
3898
+ this.logger = config.logger;
3899
+ }
3900
+ async preload(videoId, url, signal) {
3901
+ if (typeof fetch === "undefined") {
3902
+ return { videoId, status: "idle" };
3903
+ }
3904
+ if (this.statusById.get(videoId) === "loaded") {
3905
+ return { videoId, status: "loaded" };
3906
+ }
3907
+ this.cancel(videoId);
3908
+ const controller = new AbortController();
3909
+ this.controllers.set(videoId, controller);
3910
+ this.statusById.set(videoId, "loading");
3911
+ const onAbort = () => controller.abort();
3912
+ signal?.addEventListener("abort", onAbort);
3913
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
3914
+ const isHls = url.includes(".m3u8");
3915
+ const requestHeaders = { ...this.headers };
3916
+ if (!isHls && this.mp4PrefetchBytes > 0) {
3917
+ requestHeaders["Range"] = `bytes=0-${this.mp4PrefetchBytes - 1}`;
3918
+ }
3919
+ try {
3920
+ const res = await fetch(url, {
3921
+ method: "GET",
3922
+ signal: controller.signal,
3923
+ headers: requestHeaders
3924
+ });
3925
+ if (!res.ok && res.status !== 206) {
3926
+ throw new Error(`HTTP ${res.status}`);
3927
+ }
3928
+ const buf = await res.arrayBuffer();
3929
+ this.statusById.set(videoId, "loaded");
3930
+ this.warmedUrls.add(url);
3931
+ return { videoId, status: "loaded", loadedBytes: buf.byteLength };
3932
+ } catch (err) {
3933
+ const error = err instanceof Error ? err : new Error(String(err));
3934
+ if (error.name === "AbortError") {
3935
+ this.statusById.set(videoId, "idle");
3936
+ return { videoId, status: "idle", error };
3937
+ }
3938
+ this.statusById.set(videoId, "error");
3939
+ this.logger?.warn(`[BrowserVideoLoader] preload failed for ${videoId}`, error.message);
3940
+ return { videoId, status: "error", error };
3941
+ } finally {
3942
+ clearTimeout(timer);
3943
+ signal?.removeEventListener("abort", onAbort);
3944
+ this.controllers.delete(videoId);
3945
+ }
3946
+ }
3947
+ cancel(videoId) {
3948
+ const controller = this.controllers.get(videoId);
3949
+ if (controller) {
3950
+ controller.abort();
3951
+ this.controllers.delete(videoId);
3952
+ }
3953
+ if (this.statusById.get(videoId) === "loading") {
3954
+ this.statusById.set(videoId, "idle");
3955
+ }
3956
+ }
3957
+ isPreloaded(videoId) {
3958
+ return this.statusById.get(videoId) === "loaded";
3959
+ }
3960
+ getPreloadStatus(videoId) {
3961
+ return this.statusById.get(videoId) ?? "idle";
3962
+ }
3963
+ clearAll() {
3964
+ for (const controller of this.controllers.values()) {
3965
+ controller.abort();
3966
+ }
3967
+ this.controllers.clear();
3968
+ this.statusById.clear();
3969
+ this.warmedUrls.clear();
3970
+ }
3971
+ /**
3972
+ * Tier-4 cold prefetch. Fire-and-forget GET that warms the HTTP cache.
3973
+ * No DOM, no segment download, never throws. Deduped per-URL.
3974
+ */
3975
+ preloadMetadata(url) {
3976
+ if (typeof fetch === "undefined") return;
3977
+ if (!url || this.warmedUrls.has(url)) return;
3978
+ this.warmedUrls.add(url);
3979
+ const isHls = url.includes(".m3u8");
3980
+ const headers = { ...this.headers };
3981
+ if (!isHls) {
3982
+ headers["Range"] = "bytes=0-0";
3983
+ }
3984
+ void fetch(url, { method: "GET", headers }).catch(() => {
3985
+ this.warmedUrls.delete(url);
3986
+ });
3987
+ }
3988
+ };
3989
+
3990
+ export { BrowserVideoLoader, DEFAULT_FEED_CONFIG, DEFAULT_NAVIGATION_CONFIG, DEFAULT_PLAYER_CONFIG, DEFAULT_RESOURCE_CONFIG, DefaultActions, DefaultOverlay, DefaultPauseAction, DefaultSkeleton, FeedManager, HttpDataSource, HttpError, MockAnalytics, MockCommentAdapter, MockDataSource, MockInteraction, MockLogger, MockNetworkAdapter, MockSessionStorage, MockVideoLoader, NavigationManager, OptimisticManager, PlayerEngine, PlayerStatus, ReelsFeed, ReelsFeedThumbnail, ReelsModal, ReelsProvider, ResourceGovernor, VALID_TRANSITIONS, VideoSlot, canPause, canPlay, canSeek, isArticle, isValidNavTransition, isValidTransition, isVideoItem, useFeed, useFeedSelector, useHls, useNavigation, useNavigationSelector, usePlayer, usePlayerSelector, usePointerGesture, useResource, useResourceSelector, useSDK, useSnapAnimation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xhub-reels/sdk",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "High-performance Short Video / Reels SDK for React — optimized for Flutter WebView",
5
5
  "license": "MIT",
6
6
  "type": "module",