@xhub-reels/sdk
v0.2.18
Published
High-performance Short Video / Reels SDK for React — optimized for Flutter WebView
Maintainers
Readme
xhub-reels-sdk
High-performance Short Video / Reels SDK for React — optimized for Flutter WebView.
Why?
Built as a leaner, faster alternative focused on solving real-world WebView performance issues:
| Problem | Solution |
|---|---|
| React state updates during drag → jank | Pointer Events + direct DOM, zero React during drag |
| querySelectorAll per animation frame | MutationObserver slot cache, O(1) lookup |
| setTimeout polling for sync | Single store.subscribe |
| CSS transitions interrupted by React renders | Web Animations API (imperative, cancellable) |
| Static class-level global cache | Instance-scoped prefetch cache |
| 1153-line Feed component | Split into focused, testable units |
Installation
npm install xhub-reels-sdkPeer dependencies:
npm install react react-dom # >=18.0.0Quick Start
import {
ReelsProvider,
MockDataSource,
MockInteraction,
} from 'xhub-reels-sdk';
import { MyFeed } from './MyFeed';
export function App() {
return (
<ReelsProvider
adapters={{
dataSource: new MockDataSource(),
interaction: new MockInteraction(),
}}
>
<MyFeed />
</ReelsProvider>
);
}With Your Own Data Source
import type { IDataSource, FeedPage } from 'xhub-reels-sdk';
class MyAPIDataSource implements IDataSource {
async fetchFeed(cursor?: string | null): Promise<FeedPage> {
const res = await fetch(`/api/feed?cursor=${cursor ?? ''}`);
const data = await res.json();
return {
items: data.videos,
nextCursor: data.nextCursor,
hasMore: data.hasMore,
};
}
}Hooks
import { useFeed, usePlayer, useResource } from 'xhub-reels-sdk';
function MyFeed() {
const { items, loading, loadInitial, loadMore } = useFeed();
const { focusedIndex, setFocusedIndex, shouldRenderVideo } = useResource();
const { isPlaying, togglePlay, handlers } = usePlayer();
// ...
}Thumbnail grid → Modal
<ReelsFeedThumbnail> is a grid component for the pre-player browsing UX (e.g. a
community feed that opens the player on tap). It must be mounted inside
<ReelsProvider> and reads items from the same shared feed. The host provides
card visuals via renderThumbnail.
SDK-owned modal (recommended)
Pair <ReelsFeedThumbnail openOnClick> with <ReelsModal> to let the SDK own the
open→play lifecycle end to end. This eliminates the "poster stuck for ~3s" stall:
on tap, the SDK mounts the feed offscreen-but-painted, prewarms the focused
video and its neighbors synchronously, then slides the panel in — so the first
frame is already decoded by the time the animation finishes.
import { ReelsProvider, ReelsFeedThumbnail, ReelsModal } from 'xhub-reels-sdk';
function CommunityPage() {
return (
<ReelsProvider adapters={{ dataSource, interaction }}>
<ReelsFeedThumbnail
openOnClick
renderThumbnail={(item) => (
<article className="aspect-[3/2] rounded-lg overflow-hidden">
<img src={item.poster} alt="" />
<p>@{item.author.name}</p>
</article>
)}
/>
<ReelsModal feedProps={{ renderOverlay, renderActions, initialMuted: false }} />
</ReelsProvider>
);
}openOnClick calls navigation.open(index) on tap; <ReelsModal> subscribes to
the same navigation store and drives the rest. Because play() now starts inside
the tap's transient-activation window, initialMuted={false} unmuted autoplay
is far more reliable on mobile WebViews.
Headless callback (back-compat)
The original headless approach still works — drive your own drawer/modal via
onThumbnailClick:
function CommunityPage() {
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<ReelsProvider adapters={{ dataSource, interaction }}>
<ReelsFeedThumbnail
renderThumbnail={(item) => (
<article className="aspect-[3/2] rounded-lg overflow-hidden">
<img src={item.poster} alt="" />
</article>
)}
onThumbnailClick={(id) => {
setDrawerOpen(true);
window.history.replaceState(null, '', `#reel_uuid=${id}`);
}}
/>
{drawerOpen && <YourDrawer onClose={() => setDrawerOpen(false)} />}
</ReelsProvider>
);
}By default, clicking a card calls setFocusedIndexImmediate(index) on the
ResourceGovernor so the player opens instantly without a scroll-to-index. To
disable this glue (e.g. if the host manages focus separately), pass
setFocusOnClick={false}.
<ReelsFeedThumbnail> props
| Prop | Type | Default | Description |
|---|---|---|---|
| renderThumbnail | (item, index) => ReactNode | required | Card visual for a single item |
| openOnClick | boolean | false | Open the SDK-owned <ReelsModal> via navigation.open(index) on tap |
| prewarmOnClick | boolean | true | Fire HLS metadata prefetch on tap (mobile-friendly; runs even without hover) |
| onThumbnailClick | (id, item, index) => void | — | Click handler (fires alongside open for back-compat) |
| setFocusOnClick | boolean | true | Pre-focus the slot before open/onThumbnailClick fires |
| prefetchOnHover | boolean | false | Opt-in HLS metadata prefetch on pointerenter |
| renderLoading | () => ReactNode | — | Shown while loading with no items |
| renderEmpty | () => ReactNode | — | Shown when feed is empty |
| renderError | ({ message, retry }) => ReactNode | — | Shown on error with no items |
| className | string | 'grid grid-cols-2 gap-3' | Outer wrapper className |
| wrap | boolean | true | Set false to render without an outer <div> |
| getKey | (item, index) => string | item.id | Key override for duplicate lists |
<ReelsModal> props
The modal is a customizable shell: the SDK owns timing and prewarm, while the host can override the animation and chrome. Omit any prop to fall back to a sensible default.
| Prop | Type | Default | Description |
|---|---|---|---|
| feedProps | ReelsFeedProps | — | Render props forwarded to the inner <ReelsFeed> (renderOverlay, renderActions, initialMuted, …) |
| animationConfig | { duration?, easing?, direction? } | { 300, cubic-bezier(0.22, 1, 0.36, 1), 'up' } | Slide-in tuning; direction is 'up' \| 'down' \| 'fade' |
| renderBackdrop | (state) => ReactNode | dimmed overlay | Custom backdrop; state is { phase, openIndex, close } |
| renderCloseButton | (state) => ReactNode | default ✕ button | Custom close affordance |
| onOpen | (index) => void | — | Fired when a modal session opens |
| onClose | () => void | — | Fired when the modal fully closes |
| closeOnBackdropClick | boolean | true | Close when the backdrop is clicked |
| closeOnEscape | boolean | true | Close on the Escape key |
| lockBodyScroll | boolean | true | Lock <body> scroll while open |
| prewarmForward | number | 2 | How many forward neighbors to prewarm on open |
| className | string | — | Class on the sliding panel |
| zIndex | number | 1000 | Stacking context for the portal |
| portalTarget | HTMLElement \| null | document.body | Portal mount node |
<ReelsModal> honors prefers-reduced-motion (skips the slide), traps focus,
restores focus to the trigger on close, and renders into a portal.
Hover prefetch
For hover-capable devices, opt into manifest prefetch so opening the player feels instant:
<ReelsFeedThumbnail prefetchOnHover renderThumbnail={...} />The SDK uses adapters.videoLoader?.preloadMetadata?.(url) and dedupes per item
so a card only triggers one prefetch regardless of how many times it's hovered.
On touch devices there's no hover, so prefer prewarmOnClick (on by default).
Migrating from the headless callback
If you currently open your own drawer from onThumbnailClick:
- Add
openOnClickto<ReelsFeedThumbnail>and mount<ReelsModal>as a sibling inside<ReelsProvider>. - Move your drawer's render props (
renderOverlay,renderActions,initialMuted, …) onto<ReelsModal feedProps={{ … }}>. - Re-create your drawer's look with
animationConfig,renderBackdrop, andrenderCloseButton— or drop them to use the defaults. - Remove the host
drawerOpenstate and your<ReelsDrawer>/modal wrapper.
onThumbnailClick still fires alongside open, so you can migrate
incrementally (e.g. keep your URL-hash side effect) before deleting host state.
Gesture Engine
import { usePointerGesture, useSnapAnimation } from 'xhub-reels-sdk';
function SwipeableFeed() {
const { animateSnap, animateBounceBack } = useSnapAnimation();
const { bind } = usePointerGesture({
onDragOffset: (offset) => {
// Direct DOM — zero React state during drag
containerRef.current!.style.transform = `translateY(${offset}px)`;
},
onSnap: (direction) => {
const next = direction === 'forward' ? index + 1 : index - 1;
goToIndex(next);
animateSnap(targets);
},
onBounceBack: () => animateBounceBack(targets),
});
return <div {...bind} style={{ touchAction: 'none' }}>...</div>;
}Architecture
xhub-reels-sdk
├── types/ ← Pure TypeScript interfaces (no deps)
├── domain/ ← Business logic (zustand/vanilla, no React)
│ ├── PlayerEngine — State machine + Circuit Breaker
│ ├── FeedManager — Pagination, LRU, SWR, dedup
│ ├── OptimisticManager — Debounced like/follow + rollback
│ └── ResourceGovernor — Max 3 video DOM nodes
├── gesture/ ← Pointer Events + Web Animations API
│ ├── usePointerGesture — Zero React during drag
│ └── useSnapAnimation — Cancellable snap animation
├── components/ ← React components
│ └── ReelsProvider — Context + DI container
├── hooks/ ← useSyncExternalStore-based hooks
│ ├── useFeed
│ ├── usePlayer
│ └── useResource
└── adapters/mock/ ← Development mocksPerformance Targets
| Metric | Target | |---|---| | Bundle size | < 35KB gzip | | First video load | < 1s on WiFi | | Scroll FPS | 60fps | | Max video DOM nodes | 3 | | React renders per swipe | 1 (on snap only) |
Bundle Size Limits
| Package | Limit |
|---|---|
| xhub-reels-sdk | 35KB gzip |
Development
# Install
npm install
# Build package
npm run build
# Run demo app
npm run dev
# Tests
npm run test
# Type check
npm run typecheck
# Lint + format
npm run lint:fixRelease
# Bump version
npm version patch # or minor / major
# Tag and push (triggers GitHub Actions publish)
git tag v0.1.1 && git push --tagsLicense
MIT
