@xhub-reels/sdk 0.2.16 → 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
@@ -1267,7 +1267,7 @@ function usePointerGesture(config = {}) {
1267
1267
  };
1268
1268
  }
1269
1269
  function useSnapAnimation(config = {}) {
1270
- const { duration = 280, easing = "cubic-bezier(0.25, 0.46, 0.45, 0.94)" } = config;
1270
+ const { duration = 280, easing = "ease-out" } = config;
1271
1271
  const activeAnimations = react.useRef([]);
1272
1272
  const cancelAnimation = react.useCallback(() => {
1273
1273
  for (const anim of activeAnimations.current) {
@@ -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,
@@ -2584,7 +2548,7 @@ function ReelsFeed({
2584
2548
  activeIndexRef.current = focusedIndex;
2585
2549
  const { animateSnap, animateBounceBack, cancelAnimation } = useSnapAnimation({
2586
2550
  duration: snapConfig?.duration ?? 260,
2587
- easing: snapConfig?.easing ?? "cubic-bezier(0.25, 0.46, 0.45, 0.94)"
2551
+ easing: snapConfig?.easing ?? "ease-out"
2588
2552
  });
2589
2553
  react.useEffect(() => {
2590
2554
  loadInitial();
@@ -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);
@@ -3163,12 +3128,12 @@ function ReelsModal({
3163
3128
  const panel = panelRef.current;
3164
3129
  if (!panel) return;
3165
3130
  const first = panel.querySelector(FOCUSABLE);
3166
- (first ?? panel).focus();
3131
+ (first ?? panel).focus({ preventScroll: true });
3167
3132
  });
3168
3133
  return () => cancelAnimationFrame(id);
3169
3134
  }
3170
3135
  if (phase === "closed") {
3171
- previouslyFocusedRef.current?.focus?.();
3136
+ previouslyFocusedRef.current?.focus?.({ preventScroll: true });
3172
3137
  previouslyFocusedRef.current = null;
3173
3138
  }
3174
3139
  return void 0;
@@ -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
@@ -858,7 +858,7 @@ declare function usePointerGesture(config?: PointerGestureConfig): {
858
858
  interface SnapAnimationConfig {
859
859
  /** Duration in ms (default: 280) */
860
860
  duration?: number;
861
- /** CSS easing (default: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)') */
861
+ /** CSS easing (default: 'ease-out') */
862
862
  easing?: string;
863
863
  }
864
864
  interface SnapTarget {
@@ -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
@@ -858,7 +858,7 @@ declare function usePointerGesture(config?: PointerGestureConfig): {
858
858
  interface SnapAnimationConfig {
859
859
  /** Duration in ms (default: 280) */
860
860
  duration?: number;
861
- /** CSS easing (default: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)') */
861
+ /** CSS easing (default: 'ease-out') */
862
862
  easing?: string;
863
863
  }
864
864
  interface SnapTarget {
@@ -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
@@ -1261,7 +1261,7 @@ function usePointerGesture(config = {}) {
1261
1261
  };
1262
1262
  }
1263
1263
  function useSnapAnimation(config = {}) {
1264
- const { duration = 280, easing = "cubic-bezier(0.25, 0.46, 0.45, 0.94)" } = config;
1264
+ const { duration = 280, easing = "ease-out" } = config;
1265
1265
  const activeAnimations = useRef([]);
1266
1266
  const cancelAnimation = useCallback(() => {
1267
1267
  for (const anim of activeAnimations.current) {
@@ -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,
@@ -2578,7 +2542,7 @@ function ReelsFeed({
2578
2542
  activeIndexRef.current = focusedIndex;
2579
2543
  const { animateSnap, animateBounceBack, cancelAnimation } = useSnapAnimation({
2580
2544
  duration: snapConfig?.duration ?? 260,
2581
- easing: snapConfig?.easing ?? "cubic-bezier(0.25, 0.46, 0.45, 0.94)"
2545
+ easing: snapConfig?.easing ?? "ease-out"
2582
2546
  });
2583
2547
  useEffect(() => {
2584
2548
  loadInitial();
@@ -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);
@@ -3157,12 +3122,12 @@ function ReelsModal({
3157
3122
  const panel = panelRef.current;
3158
3123
  if (!panel) return;
3159
3124
  const first = panel.querySelector(FOCUSABLE);
3160
- (first ?? panel).focus();
3125
+ (first ?? panel).focus({ preventScroll: true });
3161
3126
  });
3162
3127
  return () => cancelAnimationFrame(id);
3163
3128
  }
3164
3129
  if (phase === "closed") {
3165
- previouslyFocusedRef.current?.focus?.();
3130
+ previouslyFocusedRef.current?.focus?.({ preventScroll: true });
3166
3131
  previouslyFocusedRef.current = null;
3167
3132
  }
3168
3133
  return void 0;
@@ -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.16",
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",