react-native-stream-scroll
v0.1.0
Published
High-performance vertical live-stream pager for React Native — fused UICollectionView/ViewPager2 with an AVPlayer/ExoPlayer pool, native gestures, neighbor preload, and PiP. Built on Nitro Modules.
Maintainers
Readme
react-native-stream-scroll
High-performance vertical live-stream pager for React Native. Fuses the pager and the video pipeline into a single native view so swipes, snapping, and playback all run off the JS thread.
- iOS:
UICollectionView+AVPlayerpool (HLS + VOD) - Android:
ViewPager2+ Media3 ExoPlayer pool - Native gestures (tap, long-press) — no JS-thread latency
- Active player + N preloaded neighbors with LRU eviction
- VOD position restore across recycles
- Picture-in-Picture
- Overlays stay as RN children (chat, buttons, sheets) — full JSX flexibility on top
Built on Nitro Modules.
Requirements
- React Native 0.78.0+ (Nitro Views require the new architecture)
- iOS 13+, Android API 24+
react-native-nitro-modulespeer dep
Install
npm install react-native-stream-scroll react-native-nitro-modules
cd ios && pod installUsage
import React, { useState } from 'react'
import { View, Text } from 'react-native'
import { StreamScrollView, StreamSource } from 'react-native-stream-scroll'
const STREAMS: StreamSource[] = [
{ id: 'a', url: 'https://.../stream-a.m3u8', isLive: true },
{ id: 'b', url: 'https://.../stream-b.m3u8', isLive: true },
{ id: 'c', url: 'https://.../past-stream.mp4', isLive: false, initialPositionMs: 0 },
]
export default function Live() {
const [index, setIndex] = useState(0)
return (
<View style={{ flex: 1 }}>
<StreamScrollView
style={{ flex: 1 }}
streams={STREAMS}
activeIndex={index}
isMuted={false}
isPaused={false}
preloadCount={1}
loop
resizeMode="cover"
pipEnabled
endReachedThreshold={2}
onIndexChange={({ index }) => setIndex(index)}
onEndReached={() => {/* fetch next page */}}
onTap={({ index, x, y }) => {/* toggle controls */}}
/>
{/* Overlays are plain RN children rendered above the native surface */}
<ChatOverlay />
<ActionButtons />
</View>
)
}API
<StreamScrollView />
Props
| Prop | Type | Notes |
|---|---|---|
| streams | StreamSource[] | The feed. Identified by id for recycling + position cache. |
| activeIndex | number | Current page. Both source-of-truth and sink (echoes back on swipe). |
| isMuted | boolean | Applies to all pooled players. |
| isPaused | boolean | Pauses the active player. |
| preloadCount | number | Neighbors to keep buffered each side. Default 1. Pool capacity = 2 * preloadCount + 2. |
| loop | boolean | Loop the active stream when it ends. |
| resizeMode | 'cover' \| 'contain' \| 'stretch' | Video gravity. |
| showNativeControls | boolean | iOS placeholder; Android uses ExoPlayer controls. Default false — overlays handle controls. |
| pipEnabled | boolean | Allow Picture-in-Picture. |
| endReachedThreshold | number | Fire onEndReached when within this many pages of the end. Default 2. |
Events
| Event | Payload |
|---|---|
| onIndexChange | { index } — fires after user swipe settles, not on JS-driven setActiveIndex. |
| onPlaybackStateChange | { index, state } — 'idle' \| 'loading' \| 'ready' \| 'playing' \| 'paused' \| 'ended' \| 'error'. |
| onProgress | { index, positionMs, durationMs, bufferedMs } — 4Hz. |
| onError | { index, code, message } |
| onTap | { index, x, y } |
| onLongPress | { index, state: 'began' \| 'ended' } |
| onScrollStateChange | { state: 'idle' \| 'dragging' \| 'settling' } |
| onLoadStart | { index } |
| onReadyForDisplay | { index } |
| onEndReached | () — fires once per streams array identity, until streams change. |
Imperative methods (via hybridRef)
import { callback } from 'react-native-nitro-modules'
<StreamScrollView
hybridRef={callback((ref) => {
ref.play()
ref.pause()
ref.seek(15_000)
ref.scrollToIndex(3, true)
ref.refresh() // re-fetch active stream
ref.clearCachedPosition('xyz') // forget VOD seek position
ref.enterPictureInPicture()
ref.exitPictureInPicture()
})}
/>StreamSource
type StreamSource = {
id: string // unique key — drives recycling + position cache
url: string // HLS m3u8 or progressive MP4
isLive: boolean // changes buffer + retry policy
posterUrl?: string // shown until first frame
headers?: Record<string, string> // e.g. Cloud-CDN-Cookie
initialPositionMs?: number // VOD seek-on-load
}Performance notes
- One active player. Only the foreground page calls
play(); neighbors are buffered but paused. - Pool eviction. When
streamschanges, players for removed ids are released. LRU caps to2 * preloadCount + 2. - Position cache survives eviction. VOD streams scrolled away from and back to resume at the same point.
- Gestures off-thread. Tap / long-press fire from
UITapGestureRecognizer/ AndroidGestureDetector— no JS-bridge latency. - Single AVAudioSession setup. Configured once per process at view init.
Integrating with zoop-buyer-app
To replace the existing StreamList.tsx + UnifiedVideoPlayer.tsx pair:
- Map
LiveStreamV2 -> StreamSource(use the stream id asStreamSource.id, the signed HLS URL asurl, the existingCloud-CDN-Cookiecookie as a header entry). - Move the existing Redux
activeIndexselector to driveactiveIndexprop; listen toonIndexChangeto commit user swipes back. - Keep
LiveStreamOverlay,StreamChat,ActionButtonsetc. as children of the<StreamScrollView>— they keep all their props/dispatch wiring. - The existing
globalPlaybackPositionCacheMap becomes redundant — the native pool restores VOD position on its own.
Build
npm install
npm run codegen # nitrogen -> generates Swift/Kotlin specs + JSON config
npm run buildAfter every change to src/specs/*.nitro.ts re-run npm run codegen so the
native spec base classes pick up the new prop/method surface.
Status
v0.1 — core pager + video pipeline + gestures + PiP implemented. Not yet published. Wired through Nitro Modules code generation.
