rn-videofeed 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Venkatesh Mandapati
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # rn-videofeed
2
+
3
+ Native vertical full-screen video feed for React Native β€” Reels / TikTok / Shorts style.
4
+ Playback is powered by **AVPlayer** on iOS and **Media3 ExoPlayer** on Android, driven by a
5
+ native `UICollectionView` / `RecyclerView` for smooth scrolling with many videos.
6
+
7
+ - Full-page vertical paging (snap-to-page)
8
+ - Seamless resume β€” scrolling back continues **where you left off** instead of restarting
9
+ - Warm player pool (keeps ~8 videos ready) for instant playback
10
+ - Thumbnail placeholder until the video is ready; tap to play/pause; auto-pause on scroll/background
11
+ - Pagination hook (`onEndReached`) and per-video change events
12
+
13
+ ## Requirements
14
+
15
+ - React Native >= 0.76
16
+ - iOS 16.0+
17
+ - Android β€” **Old Architecture** (`newArchEnabled=false`); the component is a legacy `SimpleViewManager`
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install rn-videofeed
23
+ # iOS
24
+ cd ios && pod install
25
+ ```
26
+
27
+ Ensure `android/gradle.properties` has `newArchEnabled=false`.
28
+
29
+ ## Usage
30
+
31
+ ```tsx
32
+ import React, { useEffect, useRef } from 'react'
33
+ import { View, StyleSheet, findNodeHandle } from 'react-native'
34
+ import VideoFeedView, { VideoFeedManagerNative, VideoFeedEmitter } from 'rn-videofeed'
35
+
36
+ const VIDEOS = [
37
+ { id: '1', videoUrl: 'https://.../clip.mp4', thumbnailUrl: 'https://.../clip.jpg' },
38
+ ]
39
+
40
+ export default function Feed() {
41
+ const feedRef = useRef(null)
42
+
43
+ useEffect(() => {
44
+ const sub = VideoFeedEmitter.addListener('onVideoChange', e => console.log(e?.videoId))
45
+ return () => sub.remove()
46
+ }, [])
47
+
48
+ useEffect(() => {
49
+ const handle = findNodeHandle(feedRef.current)
50
+ if (handle != null) VideoFeedManagerNative.setVideos(handle, VIDEOS)
51
+ }, [])
52
+
53
+ return (
54
+ <View style={{ flex: 1, backgroundColor: 'black' }}>
55
+ <VideoFeedView ref={feedRef} style={StyleSheet.absoluteFill} />
56
+ </View>
57
+ )
58
+ }
59
+ ```
60
+
61
+ ## API
62
+
63
+ Drive the native view imperatively via `VideoFeedManagerNative` using `findNodeHandle(ref.current)`:
64
+
65
+ | Method | Description |
66
+ |--------|-------------|
67
+ | `setVideos(reactTag, videos)` | Replace the feed contents |
68
+ | `appendVideos(reactTag, videos)` | Append more videos (pagination) |
69
+ | `setFeedActive(reactTag, isActive)` | Pause/resume the whole feed |
70
+ | `pauseVideo(reactTag)` / `playVideo(reactTag)` | Control the current video |
71
+ | `togglePlayPause(reactTag)` | Toggle current video |
72
+ | `isVideoPlaying(reactTag)` | Query playing state |
73
+
74
+ Video item shape:
75
+
76
+ ```ts
77
+ type FeedPlayerNativeProps = {
78
+ id?: string
79
+ videoUrl?: string
80
+ thumbnailUrl?: string
81
+ viewCount?: number
82
+ }
83
+ ```
84
+
85
+ Events (`VideoFeedEmitter` / `DeviceEventEmitter`): `onVideoChange` `{ videoId }`,
86
+ `onVideoTapped` `{ isPlaying }`, `onEndReached`.
87
+
88
+ ## License
89
+
90
+ MIT Β© Venkatesh Mandapati
91
+
92
+ Full docs, the sample app, and source: https://github.com/venky145/RN-VideoFeed
@@ -0,0 +1,25 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'RNVideoFeed'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.homepage = 'https://github.com/venky145/RN-VideoFeed'
10
+ s.license = { :type => 'MIT', :file => 'LICENSE' }
11
+ s.author = package['author']
12
+ s.source = { :git => 'https://github.com/venky145/RN-VideoFeed.git', :tag => "v#{s.version}" }
13
+
14
+ s.platforms = { :ios => '16.0' }
15
+ s.source_files = 'ios/**/*.{h,m,mm,swift}'
16
+ s.swift_version = '5.0'
17
+
18
+ s.dependency 'SDWebImage', '~> 5.19'
19
+
20
+ if respond_to?(:install_modules_dependencies, true)
21
+ install_modules_dependencies(s)
22
+ else
23
+ s.dependency 'React-Core'
24
+ end
25
+ end
@@ -0,0 +1,43 @@
1
+ def safeExtGet(prop, fallback) {
2
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
3
+ }
4
+
5
+ apply plugin: "com.android.library"
6
+ apply plugin: "org.jetbrains.kotlin.android"
7
+
8
+ android {
9
+ namespace "com.rnvideofeed"
10
+ compileSdkVersion safeExtGet("compileSdkVersion", 35).toInteger()
11
+ defaultConfig {
12
+ minSdkVersion safeExtGet("minSdkVersion", 24).toInteger()
13
+ targetSdkVersion safeExtGet("targetSdkVersion", 35).toInteger()
14
+ }
15
+ compileOptions {
16
+ sourceCompatibility JavaVersion.VERSION_17
17
+ targetCompatibility JavaVersion.VERSION_17
18
+ }
19
+ kotlinOptions {
20
+ jvmTarget = "17"
21
+ }
22
+ lint {
23
+ abortOnError false
24
+ }
25
+ }
26
+
27
+ repositories {
28
+ mavenCentral()
29
+ google()
30
+ maven {
31
+ url "$rootDir/../../react-native/android"
32
+ }
33
+ }
34
+
35
+ dependencies {
36
+ implementation "com.facebook.react:react-native:+"
37
+ implementation "androidx.recyclerview:recyclerview:1.3.2"
38
+ implementation "androidx.lifecycle:lifecycle-process:2.7.0"
39
+ implementation "androidx.media3:media3-exoplayer:1.2.1"
40
+ implementation "androidx.media3:media3-ui:1.2.1"
41
+ implementation "androidx.media3:media3-common:1.2.1"
42
+ implementation "com.github.bumptech.glide:glide:4.16.0"
43
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,202 @@
1
+ package com.rnvideofeed
2
+
3
+ import android.content.Context
4
+ import android.util.AttributeSet
5
+ import android.util.Log
6
+ import android.util.Xml
7
+ import android.view.View.MeasureSpec
8
+ import android.widget.FrameLayout
9
+ import androidx.media3.common.PlaybackException
10
+ import androidx.media3.common.Player
11
+ import androidx.media3.common.VideoSize
12
+ import androidx.media3.exoplayer.ExoPlayer
13
+ import androidx.media3.ui.PlayerView
14
+ import androidx.media3.ui.AspectRatioFrameLayout
15
+
16
+ class FeedPlayer @JvmOverloads constructor(
17
+ context: Context,
18
+ attrs: AttributeSet? = null,
19
+ defStyleAttr: Int = 0
20
+ ) : FrameLayout(context, attrs, defStyleAttr) {
21
+
22
+ var player: ExoPlayer? = null
23
+ private set
24
+ private val playerView: PlayerView
25
+ private val TAG = "FeedPlayer"
26
+
27
+ var videoUrl: String? = null
28
+ private set
29
+
30
+ var videoId: String = ""
31
+ private set
32
+
33
+ var isVisible: Boolean = true
34
+ set(value) {
35
+ field = value
36
+ if (value) {
37
+ player?.play()
38
+ } else {
39
+ player?.pause()
40
+ hasNotifiedVideoStarted = false
41
+ if (!isManualPause) {
42
+ onVideoPaused?.invoke()
43
+ }
44
+ }
45
+ }
46
+
47
+ private var currentAppliedResizeMode: Int = AspectRatioFrameLayout.RESIZE_MODE_FIT
48
+
49
+ private val layoutRunnable = Runnable {
50
+ if (isAttachedToWindow) {
51
+ measure(
52
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
53
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
54
+ )
55
+ layout(left, top, right, bottom)
56
+ }
57
+ }
58
+
59
+ private var isManualPause: Boolean = false
60
+ private var hasNotifiedVideoStarted: Boolean = false
61
+ private var playerListener: Player.Listener? = null
62
+
63
+ fun setManualPause(manual: Boolean) {
64
+ isManualPause = manual
65
+ }
66
+
67
+ var onVideoStarted: (() -> Unit)? = null
68
+ var onVideoPaused: (() -> Unit)? = null
69
+
70
+ init {
71
+ playerView = createTexturePlayerView(context)
72
+ playerView.apply {
73
+ useController = false
74
+ setShutterBackgroundColor(android.R.color.black)
75
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
76
+ resizeMode = currentAppliedResizeMode
77
+ }
78
+
79
+ addView(playerView)
80
+ setBackgroundColor(context.getColor(android.R.color.black))
81
+ }
82
+
83
+ fun bindFromPool(pool: VideoFeedPlayerPool, id: String, url: String) {
84
+ if (videoId == id && player != null && pool.hasPlayer(id, url)) {
85
+ return
86
+ }
87
+
88
+ detachPlayer()
89
+
90
+ videoId = id
91
+ videoUrl = url
92
+ hasNotifiedVideoStarted = false
93
+
94
+ val exoPlayer = pool.acquire(id, url)
95
+ attachPlayer(exoPlayer)
96
+ }
97
+
98
+ fun hasPlaybackPosition(): Boolean {
99
+ return (player?.currentPosition ?: 0L) > 300L
100
+ }
101
+
102
+ private fun attachPlayer(exoPlayer: ExoPlayer) {
103
+ playerListener?.let { exoPlayer.removeListener(it) }
104
+
105
+ val listener = createPlayerListener()
106
+ playerListener = listener
107
+ exoPlayer.addListener(listener)
108
+
109
+ player = exoPlayer
110
+ playerView.player = exoPlayer
111
+ playerView.resizeMode = currentAppliedResizeMode
112
+ }
113
+
114
+ private fun createPlayerListener(): Player.Listener {
115
+ return object : Player.Listener {
116
+ override fun onVideoSizeChanged(videoSize: VideoSize) {
117
+ currentAppliedResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
118
+ playerView.resizeMode = currentAppliedResizeMode
119
+ playerView.requestLayout()
120
+ requestLayout()
121
+ player?.videoScalingMode = androidx.media3.common.C.VIDEO_SCALING_MODE_SCALE_TO_FIT
122
+ }
123
+
124
+ override fun onPlaybackStateChanged(playbackState: Int) {
125
+ if (playbackState == Player.STATE_READY) {
126
+ val videoFormat = player?.videoFormat
127
+ if (videoFormat != null && videoFormat.width > 0 && videoFormat.height > 0) {
128
+ onVideoSizeChanged(VideoSize(videoFormat.width, videoFormat.height))
129
+ }
130
+ notifyVideoStartedIfPlaying()
131
+ }
132
+ }
133
+
134
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
135
+ if (isPlaying) {
136
+ notifyVideoStartedIfPlaying()
137
+ }
138
+ }
139
+
140
+ override fun onPlayerError(error: PlaybackException) {
141
+ Log.e(TAG, "Playback error: ${error.message}")
142
+ hasNotifiedVideoStarted = false
143
+ onVideoPaused?.invoke()
144
+ }
145
+ }
146
+ }
147
+
148
+ private fun notifyVideoStartedIfPlaying() {
149
+ val p = player ?: return
150
+ if (isVisible && p.isPlaying && p.playbackState == Player.STATE_READY && !hasNotifiedVideoStarted) {
151
+ hasNotifiedVideoStarted = true
152
+ Log.d(TAG, "Video ready and playing β€” notifying listeners")
153
+ onVideoStarted?.invoke()
154
+ }
155
+ }
156
+
157
+ private fun createTexturePlayerView(context: Context): PlayerView {
158
+ val parser = context.resources.getXml(R.xml.player_view_texture)
159
+ parser.next()
160
+ parser.nextTag()
161
+ val attrs: AttributeSet = Xml.asAttributeSet(parser)
162
+ return PlayerView(context, attrs)
163
+ }
164
+
165
+ /** Detach from the view without releasing the pooled ExoPlayer instance. */
166
+ fun detachPlayer() {
167
+ playerListener?.let { listener ->
168
+ player?.removeListener(listener)
169
+ }
170
+ playerListener = null
171
+ player?.pause()
172
+ playerView.player = null
173
+ player = null
174
+ hasNotifiedVideoStarted = false
175
+ }
176
+
177
+ fun reset() {
178
+ detachPlayer()
179
+ videoId = ""
180
+ videoUrl = null
181
+ isVisible = false
182
+ }
183
+
184
+ override fun requestLayout() {
185
+ super.requestLayout()
186
+ removeCallbacks(layoutRunnable)
187
+ post(layoutRunnable)
188
+ }
189
+
190
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
191
+ super.onLayout(changed, left, top, right, bottom)
192
+ if (changed) {
193
+ playerView.resizeMode = currentAppliedResizeMode
194
+ playerView.requestLayout()
195
+ }
196
+ }
197
+
198
+ override fun onDetachedFromWindow() {
199
+ super.onDetachedFromWindow()
200
+ detachPlayer()
201
+ }
202
+ }
@@ -0,0 +1,8 @@
1
+ package com.rnvideofeed
2
+
3
+ data class VideoData(
4
+ val id: String,
5
+ val videoUrl: String,
6
+ val thumbnailUrl: String?,
7
+ val viewCount: Int?
8
+ )
@@ -0,0 +1,185 @@
1
+ package com.rnvideofeed
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Log
7
+ import android.view.View
8
+ import android.view.ViewGroup
9
+ import android.widget.FrameLayout
10
+ import android.widget.ImageView
11
+ import androidx.recyclerview.widget.RecyclerView
12
+ import com.bumptech.glide.Glide
13
+ import com.bumptech.glide.load.engine.DiskCacheStrategy
14
+
15
+ class VideoFeedCell(context: Context) : RecyclerView.ViewHolder(
16
+ FrameLayout(context).apply {
17
+ layoutParams = RecyclerView.LayoutParams(
18
+ ViewGroup.LayoutParams.MATCH_PARENT,
19
+ ViewGroup.LayoutParams.MATCH_PARENT
20
+ )
21
+ }
22
+ ) {
23
+
24
+ private val TAG = "VideoFeedCell"
25
+ private val container = itemView as FrameLayout
26
+
27
+ val feedPlayer: FeedPlayer = FeedPlayer(context)
28
+ private val thumbnailImageView: ImageView = ImageView(context)
29
+
30
+ private var hideThumbnailHandler: Handler? = null
31
+ private var hideThumbnailRunnable: Runnable? = null
32
+
33
+ init {
34
+ container.setBackgroundColor(context.getColor(android.R.color.black))
35
+
36
+ // Setup thumbnail image view
37
+ thumbnailImageView.apply {
38
+ layoutParams = FrameLayout.LayoutParams(
39
+ FrameLayout.LayoutParams.MATCH_PARENT,
40
+ FrameLayout.LayoutParams.MATCH_PARENT
41
+ )
42
+ // FIT_CENTER to match video behavior - shows full content with letterboxing
43
+ scaleType = ImageView.ScaleType.FIT_CENTER
44
+ adjustViewBounds = true
45
+ setBackgroundColor(android.graphics.Color.BLACK)
46
+ }
47
+
48
+ // Setup feed player
49
+ feedPlayer.apply {
50
+ layoutParams = FrameLayout.LayoutParams(
51
+ FrameLayout.LayoutParams.MATCH_PARENT,
52
+ FrameLayout.LayoutParams.MATCH_PARENT
53
+ )
54
+ }
55
+
56
+ // Add views to container (order matters - thumbnail should be on top initially)
57
+ container.addView(feedPlayer)
58
+ container.addView(thumbnailImageView)
59
+
60
+ hideThumbnailHandler = Handler(Looper.getMainLooper())
61
+
62
+ // Set up video start/pause callbacks
63
+ feedPlayer.onVideoStarted = {
64
+ Log.d(TAG, "🎬 Video actually started - hiding thumbnail with delay")
65
+ // Give video a moment to start, then hide thumbnail
66
+ hideThumbnailRunnable?.let { hideThumbnailHandler?.removeCallbacks(it) }
67
+ hideThumbnailRunnable = Runnable {
68
+ Log.d(TAG, "🚫 Hiding thumbnail due to video start")
69
+ thumbnailImageView.animate()
70
+ .alpha(0f)
71
+ .setDuration(300)
72
+ .withEndAction {
73
+ thumbnailImageView.visibility = View.GONE
74
+ thumbnailImageView.alpha = 1f
75
+ Log.d(TAG, "βœ… Thumbnail hidden after video start")
76
+ }
77
+ .start()
78
+ }
79
+ hideThumbnailHandler?.postDelayed(hideThumbnailRunnable!!, 300)
80
+ }
81
+
82
+ feedPlayer.onVideoPaused = {
83
+ Log.d(TAG, "⏸️ Video paused - checking if manual pause")
84
+ showThumbnail()
85
+ }
86
+ }
87
+
88
+ fun configure(video: VideoData, playerPool: VideoFeedPlayerPool) {
89
+ Log.d(TAG, "Configuring cell with video: ${video.id}")
90
+
91
+ val resuming = playerPool.hasPlayer(video.id, video.videoUrl) &&
92
+ playerPool.getPlaybackPosition(video.id) > 300L
93
+
94
+ if (resuming) {
95
+ hideThumbnailImmediately()
96
+ } else {
97
+ thumbnailImageView.visibility = View.VISIBLE
98
+ }
99
+
100
+ if (!video.thumbnailUrl.isNullOrEmpty()) {
101
+ Glide.with(container.context)
102
+ .load(video.thumbnailUrl)
103
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
104
+ .fitCenter()
105
+ .into(thumbnailImageView)
106
+ Log.d(TAG, "πŸ–ΌοΈ Loading thumbnail: ${video.thumbnailUrl}")
107
+ } else {
108
+ thumbnailImageView.scaleType = ImageView.ScaleType.FIT_CENTER
109
+ thumbnailImageView.setImageDrawable(null)
110
+ Log.d(TAG, "πŸ–ΌοΈ No thumbnail URL β€” showing black placeholder")
111
+ }
112
+
113
+ feedPlayer.bindFromPool(playerPool, video.id, video.videoUrl)
114
+ feedPlayer.isVisible = false
115
+ }
116
+
117
+ fun showVideoPlaying() {
118
+ Log.d(TAG, "🎬 Video playing requested - thumbnail will hide when video actually starts")
119
+ // The actual thumbnail hiding is now handled by the video start callback
120
+ // This method is kept for compatibility but the real work is done in the callback
121
+ }
122
+
123
+ fun showThumbnail() {
124
+ Log.d(TAG, "πŸ–ΌοΈ Showing thumbnail (video paused) - current visibility: ${thumbnailImageView.visibility}, alpha: ${thumbnailImageView.alpha}")
125
+
126
+ // Cancel any pending hide thumbnail task
127
+ hideThumbnailRunnable?.let {
128
+ hideThumbnailHandler?.removeCallbacks(it)
129
+ Log.d(TAG, "❌ Cancelled pending thumbnail hide task")
130
+ }
131
+ hideThumbnailRunnable = null
132
+
133
+ // Show thumbnail immediately when video is paused
134
+ thumbnailImageView.clearAnimation()
135
+ thumbnailImageView.alpha = 1f
136
+ thumbnailImageView.visibility = View.VISIBLE
137
+
138
+ Log.d(TAG, "βœ… Thumbnail now visible - final visibility: ${thumbnailImageView.visibility}, alpha: ${thumbnailImageView.alpha}")
139
+ }
140
+
141
+ fun hideThumbnailImmediately() {
142
+ Log.d(TAG, "🚫 Hiding thumbnail immediately")
143
+
144
+ // Cancel any pending hide thumbnail task
145
+ hideThumbnailRunnable?.let { hideThumbnailHandler?.removeCallbacks(it) }
146
+ hideThumbnailRunnable = null
147
+
148
+ // Hide thumbnail immediately
149
+ thumbnailImageView.clearAnimation()
150
+ thumbnailImageView.visibility = View.GONE
151
+ thumbnailImageView.alpha = 1f // Reset for next use
152
+
153
+ Log.d(TAG, "βœ… Thumbnail hidden immediately")
154
+ }
155
+
156
+ fun prepareForReuse(playerPool: VideoFeedPlayerPool) {
157
+ Log.d(TAG, "πŸ”„ Preparing for reuse")
158
+
159
+ hideThumbnailRunnable?.let { hideThumbnailHandler?.removeCallbacks(it) }
160
+ hideThumbnailRunnable = null
161
+
162
+ val id = feedPlayer.videoId
163
+ if (id.isNotEmpty()) {
164
+ playerPool.pause(id)
165
+ }
166
+ feedPlayer.detachPlayer()
167
+ feedPlayer.isVisible = false
168
+
169
+ thumbnailImageView.visibility = View.VISIBLE
170
+ thumbnailImageView.alpha = 1f
171
+ thumbnailImageView.clearAnimation()
172
+
173
+ Glide.with(container.context).clear(thumbnailImageView)
174
+
175
+ Log.d(TAG, "βœ… Cell detached β€” player kept in pool")
176
+ }
177
+
178
+ fun cleanup() {
179
+ hideThumbnailHandler?.removeCallbacksAndMessages(null)
180
+ hideThumbnailHandler = null
181
+ feedPlayer.onVideoStarted = null
182
+ feedPlayer.onVideoPaused = null
183
+ feedPlayer.detachPlayer()
184
+ }
185
+ }