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 +21 -0
- package/README.md +92 -0
- package/RNVideoFeed.podspec +25 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/rnvideofeed/FeedPlayer.kt +202 -0
- package/android/src/main/java/com/rnvideofeed/VideoData.kt +8 -0
- package/android/src/main/java/com/rnvideofeed/VideoFeedCell.kt +185 -0
- package/android/src/main/java/com/rnvideofeed/VideoFeedModule.kt +176 -0
- package/android/src/main/java/com/rnvideofeed/VideoFeedPackage.kt +17 -0
- package/android/src/main/java/com/rnvideofeed/VideoFeedPlayerPool.kt +78 -0
- package/android/src/main/java/com/rnvideofeed/VideoFeedView.kt +706 -0
- package/android/src/main/java/com/rnvideofeed/VideoFeedViewManager.kt +129 -0
- package/android/src/main/res/xml/player_view_texture.xml +5 -0
- package/index.d.ts +30 -0
- package/ios/FeedOptions/ContentCardInfoView.swift +312 -0
- package/ios/FeedPlayer.swift +174 -0
- package/ios/VideoFeedCell.swift +117 -0
- package/ios/VideoFeedEventEmitter.swift +24 -0
- package/ios/VideoFeedManagerBridge.h +17 -0
- package/ios/VideoFeedManagerBridge.m +31 -0
- package/ios/VideoFeedView.swift +432 -0
- package/ios/VideoFeedViewManager.swift +131 -0
- package/package.json +37 -0
- package/react-native.config.js +3 -0
- package/src/VideoFeedManagerBridge.ts +24 -0
- package/src/VideoFeedView.native.tsx +17 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
package com.rnvideofeed
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.util.AttributeSet
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import android.view.GestureDetector
|
|
8
|
+
import android.view.MotionEvent
|
|
9
|
+
import android.view.View
|
|
10
|
+
import android.view.View.MeasureSpec
|
|
11
|
+
import android.view.ViewGroup
|
|
12
|
+
import android.view.WindowManager
|
|
13
|
+
import androidx.lifecycle.DefaultLifecycleObserver
|
|
14
|
+
import androidx.lifecycle.LifecycleOwner
|
|
15
|
+
import androidx.lifecycle.ProcessLifecycleOwner
|
|
16
|
+
import androidx.recyclerview.widget.LinearLayoutManager
|
|
17
|
+
import androidx.recyclerview.widget.PagerSnapHelper
|
|
18
|
+
import androidx.recyclerview.widget.RecyclerView
|
|
19
|
+
import androidx.core.content.ContextCompat
|
|
20
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
21
|
+
import com.facebook.react.bridge.WritableNativeMap
|
|
22
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
23
|
+
|
|
24
|
+
class VideoFeedView @JvmOverloads constructor(
|
|
25
|
+
context: Context,
|
|
26
|
+
attrs: AttributeSet? = null,
|
|
27
|
+
defStyleAttr: Int = 0
|
|
28
|
+
) : RecyclerView(context, attrs, defStyleAttr), DefaultLifecycleObserver {
|
|
29
|
+
|
|
30
|
+
private val TAG = "VideoFeedView"
|
|
31
|
+
private val playerPool = VideoFeedPlayerPool(context, 8)
|
|
32
|
+
private val feedAdapter: VideoFeedAdapter
|
|
33
|
+
private val feedLayoutManager: FullPageLinearLayoutManager
|
|
34
|
+
private val snapHelper: PagerSnapHelper
|
|
35
|
+
|
|
36
|
+
private var videos: MutableList<VideoData> = mutableListOf()
|
|
37
|
+
private var currentPlayer: ExoPlayer? = null
|
|
38
|
+
private var feedIsActive = true
|
|
39
|
+
private var keepScreenOn = false
|
|
40
|
+
private var appIsInBackground = false
|
|
41
|
+
private var isManuallyPaused = false // Track if user manually paused
|
|
42
|
+
|
|
43
|
+
// Gesture detector for tap handling
|
|
44
|
+
private lateinit var gestureDetector: GestureDetector
|
|
45
|
+
|
|
46
|
+
// React context for event emission
|
|
47
|
+
private var reactContext: Context? = null
|
|
48
|
+
|
|
49
|
+
// Callbacks
|
|
50
|
+
var onEndReached: (() -> Unit)? = null
|
|
51
|
+
var onVideoChange: ((String) -> Unit)? = null
|
|
52
|
+
var onVideoTapped: ((Boolean) -> Unit)? = null
|
|
53
|
+
|
|
54
|
+
init {
|
|
55
|
+
Log.d(TAG, "Initializing VideoFeedView")
|
|
56
|
+
|
|
57
|
+
// setBackgroundColor(ContextCompat.getColor(android.R.color.black))
|
|
58
|
+
setBackgroundColor(ContextCompat.getColor(context, android.R.color.black))
|
|
59
|
+
|
|
60
|
+
feedLayoutManager = FullPageLinearLayoutManager(context)
|
|
61
|
+
feedAdapter = VideoFeedAdapter(context, playerPool) { position ->
|
|
62
|
+
preloadNextVideos(position)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
layoutManager = feedLayoutManager
|
|
66
|
+
adapter = feedAdapter
|
|
67
|
+
itemAnimator = null
|
|
68
|
+
setHasFixedSize(false)
|
|
69
|
+
setItemViewCacheSize(8)
|
|
70
|
+
recycledViewPool.setMaxRecycledViews(0, 8)
|
|
71
|
+
|
|
72
|
+
feedAdapter.registerAdapterDataObserver(object : AdapterDataObserver() {
|
|
73
|
+
override fun onChanged() {
|
|
74
|
+
Log.d(TAG, "Adapter onChanged — requesting layout")
|
|
75
|
+
post { requestLayout() }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
|
79
|
+
Log.d(TAG, "Adapter inserted $itemCount at $positionStart — requesting layout")
|
|
80
|
+
post { requestLayout() }
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
snapHelper = PagerSnapHelper()
|
|
85
|
+
snapHelper.attachToRecyclerView(this)
|
|
86
|
+
|
|
87
|
+
addOnScrollListener(object : OnScrollListener() {
|
|
88
|
+
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
89
|
+
super.onScrollStateChanged(recyclerView, newState)
|
|
90
|
+
|
|
91
|
+
if (newState == SCROLL_STATE_IDLE) {
|
|
92
|
+
handleScrollEnd()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
|
98
|
+
setupTapGesture()
|
|
99
|
+
|
|
100
|
+
Log.d(TAG, "VideoFeedView initialized")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
104
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
105
|
+
val width = right - left
|
|
106
|
+
val height = bottom - top
|
|
107
|
+
Log.d(
|
|
108
|
+
TAG,
|
|
109
|
+
"VideoFeedView onLayout: ${width}x${height}, childCount=$childCount, itemCount=${feedAdapter.itemCount}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if (width == 0 || height == 0) {
|
|
113
|
+
Log.w(TAG, "⚠️ VideoFeedView has zero dimensions!")
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private fun handleScrollEnd() {
|
|
118
|
+
val currentIndex = getCurrentIndex()
|
|
119
|
+
Log.d(TAG, "📍 Scroll ended at index: $currentIndex")
|
|
120
|
+
|
|
121
|
+
if (currentIndex < 0 || currentIndex >= videos.size) {
|
|
122
|
+
Log.w(TAG, "⚠️ Invalid scroll position: $currentIndex (total: ${videos.size})")
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Pause all videos first
|
|
127
|
+
pauseAllVideos()
|
|
128
|
+
|
|
129
|
+
// Small delay to ensure RecyclerView has settled
|
|
130
|
+
postDelayed({
|
|
131
|
+
if (feedIsActive && !appIsInBackground) {
|
|
132
|
+
Log.d(TAG, "▶️ Starting video playback for settled position: $currentIndex")
|
|
133
|
+
|
|
134
|
+
// Reset manual pause state for new video
|
|
135
|
+
isManuallyPaused = false
|
|
136
|
+
Log.d(TAG, "🔄 Video changed to index: $currentIndex, resetting manual pause state")
|
|
137
|
+
|
|
138
|
+
playVideo(currentIndex)
|
|
139
|
+
|
|
140
|
+
// Emit video change event
|
|
141
|
+
val videoId = videos[currentIndex].id
|
|
142
|
+
onVideoChange?.invoke(videoId)
|
|
143
|
+
|
|
144
|
+
// Check if we're near the end for pagination
|
|
145
|
+
val remainingVideos = videos.size - currentIndex - 1
|
|
146
|
+
Log.d(TAG, "📊 Stats: Index=$currentIndex, Total=${videos.size}, Remaining=$remainingVideos")
|
|
147
|
+
|
|
148
|
+
if (currentIndex >= videos.size - 3) {
|
|
149
|
+
Log.d(TAG, "🔥 Triggering onEndReached! (threshold reached)")
|
|
150
|
+
onEndReached?.invoke()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}, 150) // Give RecyclerView time to settle
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private fun getCurrentIndex(): Int {
|
|
157
|
+
val snapView = snapHelper.findSnapView(feedLayoutManager)
|
|
158
|
+
if (snapView != null) {
|
|
159
|
+
val position = feedLayoutManager.getPosition(snapView)
|
|
160
|
+
Log.d(TAG, "Snap helper found position: $position")
|
|
161
|
+
return position
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
val fallbackPosition = feedLayoutManager.findFirstVisibleItemPosition()
|
|
165
|
+
if (fallbackPosition != NO_POSITION) {
|
|
166
|
+
Log.d(TAG, "Using fallback position: $fallbackPosition")
|
|
167
|
+
return fallbackPosition
|
|
168
|
+
}
|
|
169
|
+
return if (videos.isNotEmpty()) 0 else -1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private fun playVideo(index: Int) {
|
|
173
|
+
Log.d(TAG, "🎬 Attempting to play video at index: $index")
|
|
174
|
+
|
|
175
|
+
if (!feedIsActive) {
|
|
176
|
+
Log.d(TAG, "Feed inactive, skipping playVideo at index: $index")
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (appIsInBackground) {
|
|
181
|
+
Log.d(TAG, "App in background, skipping playVideo at index: $index")
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (index < 0 || index >= videos.size) {
|
|
186
|
+
Log.d(TAG, "Index out of bounds: $index")
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Try to find ViewHolder, with retry mechanism
|
|
191
|
+
var viewHolder = findViewHolderForAdapterPosition(index) as? VideoFeedCell
|
|
192
|
+
|
|
193
|
+
if (viewHolder == null) {
|
|
194
|
+
Log.d(TAG, "ViewHolder not found immediately, posting delayed retry...")
|
|
195
|
+
post {
|
|
196
|
+
playVideoWithRetry(index, 0)
|
|
197
|
+
}
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
Log.d(TAG, "✅ Found ViewHolder for index: $index")
|
|
202
|
+
setupVideoForPlayback(viewHolder, index)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private fun playVideoWithRetry(index: Int, retryCount: Int) {
|
|
206
|
+
if (retryCount >= 3) {
|
|
207
|
+
Log.e(TAG, "❌ Failed to find ViewHolder after 3 retries for index: $index")
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!feedIsActive || index < 0 || index >= videos.size) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
val viewHolder = findViewHolderForAdapterPosition(index) as? VideoFeedCell
|
|
216
|
+
if (viewHolder != null) {
|
|
217
|
+
Log.d(TAG, "✅ Found ViewHolder on retry $retryCount for index: $index")
|
|
218
|
+
setupVideoForPlayback(viewHolder, index)
|
|
219
|
+
} else {
|
|
220
|
+
Log.d(TAG, "Retry $retryCount failed, scheduling another attempt...")
|
|
221
|
+
postDelayed({
|
|
222
|
+
playVideoWithRetry(index, retryCount + 1)
|
|
223
|
+
}, 100)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private fun setupVideoForPlayback(viewHolder: VideoFeedCell, index: Int) {
|
|
228
|
+
Log.d(TAG, "🎯 Setting up video playback for index: $index, videoId: ${videos[index].id}")
|
|
229
|
+
|
|
230
|
+
val video = videos[index]
|
|
231
|
+
if (viewHolder.feedPlayer.videoId != video.id || viewHolder.feedPlayer.player == null) {
|
|
232
|
+
viewHolder.configure(video, playerPool)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
currentPlayer = viewHolder.feedPlayer.player
|
|
236
|
+
|
|
237
|
+
val resuming = playerPool.hasPlayer(video.id, video.videoUrl) &&
|
|
238
|
+
playerPool.getPlaybackPosition(video.id) > 300L
|
|
239
|
+
|
|
240
|
+
if (resuming || viewHolder.feedPlayer.hasPlaybackPosition()) {
|
|
241
|
+
Log.d(TAG, "▶️ Resuming from ${playerPool.getPlaybackPosition(video.id)}ms")
|
|
242
|
+
viewHolder.hideThumbnailImmediately()
|
|
243
|
+
} else {
|
|
244
|
+
Log.d(TAG, "🖼️ Ensuring thumbnail is visible before starting video")
|
|
245
|
+
viewHolder.showThumbnail()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
Log.d(TAG, "📹 Starting video - callbacks will handle thumbnail")
|
|
249
|
+
|
|
250
|
+
if (!isManuallyPaused) {
|
|
251
|
+
viewHolder.feedPlayer.isVisible = true
|
|
252
|
+
Log.d(TAG, "▶️ Auto-playing new video at index: $index")
|
|
253
|
+
} else {
|
|
254
|
+
viewHolder.feedPlayer.isVisible = false
|
|
255
|
+
Log.d(TAG, "⏸️ New video at index: $index but staying paused (manually paused)")
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
setVideoKeepScreenOn(true)
|
|
259
|
+
|
|
260
|
+
Log.d(TAG, "✅ Video setup complete for index: $index")
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private fun pauseAllVideos() {
|
|
264
|
+
Log.d(TAG, "Pausing all videos")
|
|
265
|
+
for (i in 0 until feedAdapter.itemCount) {
|
|
266
|
+
val viewHolder = findViewHolderForAdapterPosition(i) as? VideoFeedCell
|
|
267
|
+
if (viewHolder != null) {
|
|
268
|
+
// This will trigger the onVideoPaused callback which will show the thumbnail
|
|
269
|
+
viewHolder.feedPlayer.isVisible = false
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Allow screen to sleep when all videos are paused
|
|
274
|
+
setVideoKeepScreenOn(false)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private fun preloadNextVideos(currentIndex: Int) {
|
|
278
|
+
val start = currentIndex + 1
|
|
279
|
+
val end = minOf(currentIndex + 2, videos.size - 1)
|
|
280
|
+
|
|
281
|
+
if (start <= end) {
|
|
282
|
+
Log.d(TAG, "Preloading videos from index $start to $end")
|
|
283
|
+
// Preloading logic can be implemented here if needed
|
|
284
|
+
// For now, ExoPlayer handles some caching automatically
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fun setVideos(videoList: List<Map<String, Any>>) {
|
|
289
|
+
Log.d(TAG, "=== Setting videos: ${videoList.size} ===")
|
|
290
|
+
|
|
291
|
+
// Debug: Print first few videos
|
|
292
|
+
videoList.take(2).forEachIndexed { index, video ->
|
|
293
|
+
Log.d(TAG, "Video $index: id=${video["id"]}, url=${video["videoUrl"]}")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
videos.clear()
|
|
297
|
+
videos.addAll(videoList.mapNotNull { dict ->
|
|
298
|
+
val id = dict["id"] as? String
|
|
299
|
+
val url = dict["videoUrl"] as? String
|
|
300
|
+
|
|
301
|
+
if (id != null && url != null) {
|
|
302
|
+
VideoData(
|
|
303
|
+
id = id,
|
|
304
|
+
videoUrl = url,
|
|
305
|
+
thumbnailUrl = dict["thumbnailUrl"] as? String,
|
|
306
|
+
viewCount = dict["viewCount"] as? Int
|
|
307
|
+
)
|
|
308
|
+
} else {
|
|
309
|
+
Log.w(TAG, "Failed to map video data - missing id or url: $dict")
|
|
310
|
+
null
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
Log.d(TAG, "Mapped videos count: ${videos.size}")
|
|
315
|
+
feedAdapter.updateVideos(videos)
|
|
316
|
+
adapter = feedAdapter
|
|
317
|
+
post {
|
|
318
|
+
Log.d(
|
|
319
|
+
TAG,
|
|
320
|
+
"Post-update: ${width}x${height}, lm=${feedLayoutManager.width}x${feedLayoutManager.height}, " +
|
|
321
|
+
"itemCount=${feedAdapter.itemCount}, childCount=$childCount, " +
|
|
322
|
+
"attached=$isAttachedToWindow, layoutSuppressed=$isLayoutSuppressed"
|
|
323
|
+
)
|
|
324
|
+
feedLayoutManager.scrollToPositionWithOffset(0, 0)
|
|
325
|
+
if (width > 0 && height > 0) {
|
|
326
|
+
measure(
|
|
327
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
328
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
|
329
|
+
)
|
|
330
|
+
layout(left, top, right, bottom)
|
|
331
|
+
}
|
|
332
|
+
requestLayout()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Play first video if we have videos
|
|
336
|
+
if (videos.isNotEmpty()) {
|
|
337
|
+
Log.d(TAG, "Scheduling first video playback")
|
|
338
|
+
post {
|
|
339
|
+
Log.d(TAG, "Ensuring RecyclerView is positioned at index 0")
|
|
340
|
+
scrollToPosition(0)
|
|
341
|
+
postDelayed({
|
|
342
|
+
playVideo(0)
|
|
343
|
+
}, 100)
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
Log.w(TAG, "No videos to play!")
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
fun appendVideos(videoList: List<Map<String, Any>>) {
|
|
351
|
+
Log.d(TAG, "Appending videos: ${videoList.size}")
|
|
352
|
+
|
|
353
|
+
val newVideos = videoList.mapNotNull { dict ->
|
|
354
|
+
val id = dict["id"] as? String
|
|
355
|
+
val url = dict["videoUrl"] as? String
|
|
356
|
+
|
|
357
|
+
if (id != null && url != null) {
|
|
358
|
+
VideoData(
|
|
359
|
+
id = id,
|
|
360
|
+
videoUrl = url,
|
|
361
|
+
thumbnailUrl = dict["thumbnailUrl"] as? String,
|
|
362
|
+
viewCount = dict["viewCount"] as? Int
|
|
363
|
+
)
|
|
364
|
+
} else {
|
|
365
|
+
null
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
videos.addAll(newVideos)
|
|
370
|
+
feedAdapter.updateVideos(videos)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fun setFeedActive(isActive: Boolean) {
|
|
374
|
+
Log.d(TAG, "📱 setFeedActive called with isActive: $isActive, current feedIsActive: $feedIsActive, appIsInBackground: $appIsInBackground")
|
|
375
|
+
feedIsActive = isActive
|
|
376
|
+
|
|
377
|
+
if (isActive) {
|
|
378
|
+
Log.d(TAG, "🔥 setFeedActive: Resuming video")
|
|
379
|
+
val currentIndex = getCurrentIndex()
|
|
380
|
+
Log.d(TAG, "🎯 Current index for resume: $currentIndex")
|
|
381
|
+
// Small delay to ensure view is ready before playing
|
|
382
|
+
postDelayed({
|
|
383
|
+
if (feedIsActive && !appIsInBackground) {
|
|
384
|
+
Log.d(TAG, "▶️ Actually playing video after delay")
|
|
385
|
+
playVideo(currentIndex)
|
|
386
|
+
} else {
|
|
387
|
+
Log.d(TAG, "⏸️ Not playing video - feedIsActive: $feedIsActive, appIsInBackground: $appIsInBackground")
|
|
388
|
+
}
|
|
389
|
+
}, 100)
|
|
390
|
+
} else {
|
|
391
|
+
Log.d(TAG, "⏸️ setFeedActive: Pausing video")
|
|
392
|
+
pauseAllVideos()
|
|
393
|
+
setVideoKeepScreenOn(false) // Allow screen to sleep when not active
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private fun setVideoKeepScreenOn(keepOn: Boolean) {
|
|
398
|
+
if (keepScreenOn == keepOn) return
|
|
399
|
+
|
|
400
|
+
keepScreenOn = keepOn
|
|
401
|
+
Log.d(TAG, "Setting keep screen on: $keepOn")
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
val activity = context as? Activity
|
|
405
|
+
activity?.runOnUiThread {
|
|
406
|
+
if (keepOn) {
|
|
407
|
+
activity.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
408
|
+
Log.d(TAG, "✅ Screen will stay awake during video playback")
|
|
409
|
+
} else {
|
|
410
|
+
activity.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
411
|
+
Log.d(TAG, "💤 Screen can now sleep")
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} catch (e: Exception) {
|
|
415
|
+
Log.e(TAG, "Failed to set keep screen on: ${e.message}")
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// App lifecycle callbacks
|
|
420
|
+
override fun onStart(owner: LifecycleOwner) {
|
|
421
|
+
super.onStart(owner)
|
|
422
|
+
Log.d(TAG, "📱 App became active")
|
|
423
|
+
appIsInBackground = false
|
|
424
|
+
|
|
425
|
+
// Resume video if feed is active and not manually paused
|
|
426
|
+
if (feedIsActive && !isManuallyPaused) {
|
|
427
|
+
Log.d(TAG, "App became active & feed is active & not manually paused — resuming current video")
|
|
428
|
+
val currentIndex = getCurrentIndex()
|
|
429
|
+
playVideo(currentIndex)
|
|
430
|
+
} else if (isManuallyPaused) {
|
|
431
|
+
Log.d(TAG, "App became active but video was manually paused — staying paused")
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
override fun onStop(owner: LifecycleOwner) {
|
|
436
|
+
super.onStop(owner)
|
|
437
|
+
Log.d(TAG, "📱 App entered background — pausing ALL videos")
|
|
438
|
+
appIsInBackground = true
|
|
439
|
+
|
|
440
|
+
// Pause all videos when app goes to background
|
|
441
|
+
pauseAllVideos()
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
override fun onDetachedFromWindow() {
|
|
445
|
+
super.onDetachedFromWindow()
|
|
446
|
+
Log.d(TAG, "VideoFeedView detached from window")
|
|
447
|
+
|
|
448
|
+
// Unregister lifecycle observer
|
|
449
|
+
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
|
450
|
+
|
|
451
|
+
// Clean up all players
|
|
452
|
+
playerPool.releaseAll()
|
|
453
|
+
for (i in 0 until feedAdapter.itemCount) {
|
|
454
|
+
val viewHolder = findViewHolderForAdapterPosition(i) as? VideoFeedCell
|
|
455
|
+
viewHolder?.cleanup()
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Release screen wake lock
|
|
459
|
+
setVideoKeepScreenOn(false)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Set React context for event emission
|
|
463
|
+
fun setReactContext(context: Context) {
|
|
464
|
+
this.reactContext = context
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// MARK: - Tap Gesture Setup
|
|
468
|
+
|
|
469
|
+
private fun setupTapGesture() {
|
|
470
|
+
gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
|
471
|
+
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
|
472
|
+
Log.d(TAG, "🔥 Tap detected in native VideoFeedView")
|
|
473
|
+
handleTap()
|
|
474
|
+
return true
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
override fun onDown(e: MotionEvent): Boolean {
|
|
478
|
+
Log.d(TAG, "🔥 Touch down detected")
|
|
479
|
+
return false // Let other gestures handle it
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Don't interfere with scroll gestures
|
|
483
|
+
override fun onScroll(
|
|
484
|
+
e1: MotionEvent?,
|
|
485
|
+
e2: MotionEvent,
|
|
486
|
+
distanceX: Float,
|
|
487
|
+
distanceY: Float
|
|
488
|
+
): Boolean {
|
|
489
|
+
Log.d(TAG, "🔥 Scroll detected - letting RecyclerView handle")
|
|
490
|
+
// Let RecyclerView handle scroll
|
|
491
|
+
return false
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
Log.d(TAG, "🔥 Tap gesture detector setup complete")
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
|
498
|
+
// Let gesture detector handle taps
|
|
499
|
+
gestureDetector.onTouchEvent(event)
|
|
500
|
+
// Don't intercept - let RecyclerView handle touches (for scrolling)
|
|
501
|
+
return false
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private fun handleTap() {
|
|
505
|
+
Log.d(TAG, "🔥 Processing tap - toggling play/pause")
|
|
506
|
+
|
|
507
|
+
// Toggle play/pause and get the new state
|
|
508
|
+
val isNowPlaying = togglePlayPause()
|
|
509
|
+
|
|
510
|
+
// Use callback to emit event to React Native for UI feedback
|
|
511
|
+
onVideoTapped?.invoke(isNowPlaying)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// MARK: - Play/Pause Control Methods
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Gets the current visible index based on scroll position
|
|
518
|
+
*/
|
|
519
|
+
private fun getCurrentVisibleIndex(): Int? {
|
|
520
|
+
return feedLayoutManager.findFirstCompletelyVisibleItemPosition()
|
|
521
|
+
.takeIf { it != NO_POSITION }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Gets the current visible ViewHolder
|
|
526
|
+
*/
|
|
527
|
+
private fun getCurrentCell(index: Int): VideoFeedCell? {
|
|
528
|
+
return findViewHolderForAdapterPosition(index) as? VideoFeedCell
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Pauses the currently playing video
|
|
533
|
+
*/
|
|
534
|
+
fun pauseCurrentVideo() {
|
|
535
|
+
val currentIndex = getCurrentVisibleIndex()
|
|
536
|
+
if (currentIndex == null) {
|
|
537
|
+
Log.d(TAG, "Failed to get current video for pause")
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
val cell = getCurrentCell(currentIndex)
|
|
542
|
+
if (cell == null) {
|
|
543
|
+
Log.d(TAG, "Failed to get current cell for pause")
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
Log.d(TAG, "⏸️ Manually pausing video at index: $currentIndex")
|
|
548
|
+
isManuallyPaused = true
|
|
549
|
+
cell.feedPlayer.isVisible = false
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Plays the currently visible video
|
|
554
|
+
*/
|
|
555
|
+
fun playCurrentVideo() {
|
|
556
|
+
val currentIndex = getCurrentVisibleIndex()
|
|
557
|
+
if (currentIndex == null) {
|
|
558
|
+
Log.d(TAG, "Failed to get current video for play")
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
val cell = getCurrentCell(currentIndex)
|
|
563
|
+
if (cell == null) {
|
|
564
|
+
Log.d(TAG, "Failed to get current cell for play")
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
Log.d(TAG, "▶️ Manually playing video at index: $currentIndex")
|
|
569
|
+
isManuallyPaused = false
|
|
570
|
+
cell.feedPlayer.isVisible = true
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Toggles play/pause state of current video
|
|
575
|
+
* Returns the new playing state (true = playing, false = paused)
|
|
576
|
+
*/
|
|
577
|
+
fun togglePlayPause(): Boolean {
|
|
578
|
+
val currentIndex = getCurrentVisibleIndex()
|
|
579
|
+
if (currentIndex == null) {
|
|
580
|
+
Log.d(TAG, "Failed to get current video for toggle")
|
|
581
|
+
return false
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
val cell = getCurrentCell(currentIndex)
|
|
585
|
+
if (cell == null) {
|
|
586
|
+
Log.d(TAG, "Failed to get current cell for toggle")
|
|
587
|
+
return false
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
val wasPlaying = cell.feedPlayer.isVisible
|
|
591
|
+
val newPlayingState = !wasPlaying
|
|
592
|
+
isManuallyPaused = !newPlayingState // Update manual pause state
|
|
593
|
+
|
|
594
|
+
Log.d(TAG, "🔄 Toggling video at index: $currentIndex, was playing: $wasPlaying, now playing: $newPlayingState, manually paused: $isManuallyPaused")
|
|
595
|
+
Log.d(TAG, "🔄 Setting feedPlayer.isVisible to: $newPlayingState")
|
|
596
|
+
|
|
597
|
+
// Set the manual pause flag on the FeedPlayer
|
|
598
|
+
cell.feedPlayer.setManualPause(!newPlayingState)
|
|
599
|
+
|
|
600
|
+
// Set the visibility which will trigger play/pause
|
|
601
|
+
cell.feedPlayer.isVisible = newPlayingState
|
|
602
|
+
|
|
603
|
+
// Verify the state was set correctly
|
|
604
|
+
Log.d(TAG, "🔄 After setting, feedPlayer.isVisible is: ${cell.feedPlayer.isVisible}")
|
|
605
|
+
|
|
606
|
+
return newPlayingState
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Gets the current playing state
|
|
611
|
+
*/
|
|
612
|
+
fun isCurrentVideoPlaying(): Boolean {
|
|
613
|
+
val currentIndex = getCurrentVisibleIndex() ?: return false
|
|
614
|
+
val cell = getCurrentCell(currentIndex) ?: return false
|
|
615
|
+
return cell.feedPlayer.isVisible
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Gets the manual pause state
|
|
620
|
+
*/
|
|
621
|
+
fun isManuallyPaused(): Boolean {
|
|
622
|
+
return isManuallyPaused
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Each feed page fills the RecyclerView viewport (required for vertical paging).
|
|
627
|
+
private class FullPageLinearLayoutManager(context: Context) :
|
|
628
|
+
LinearLayoutManager(context, VERTICAL, false) {
|
|
629
|
+
|
|
630
|
+
override fun measureChildWithMargins(child: View, widthUsed: Int, heightUsed: Int) {
|
|
631
|
+
val widthSpec = MeasureSpec.makeMeasureSpec(
|
|
632
|
+
width - paddingLeft - paddingRight,
|
|
633
|
+
MeasureSpec.EXACTLY
|
|
634
|
+
)
|
|
635
|
+
val heightSpec = MeasureSpec.makeMeasureSpec(
|
|
636
|
+
height - paddingTop - paddingBottom,
|
|
637
|
+
MeasureSpec.EXACTLY
|
|
638
|
+
)
|
|
639
|
+
child.measure(widthSpec, heightSpec)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// RecyclerView Adapter
|
|
644
|
+
class VideoFeedAdapter(
|
|
645
|
+
private val context: Context,
|
|
646
|
+
private val playerPool: VideoFeedPlayerPool,
|
|
647
|
+
private val onItemVisible: (Int) -> Unit
|
|
648
|
+
) : RecyclerView.Adapter<VideoFeedCell>() {
|
|
649
|
+
|
|
650
|
+
private val TAG = "VideoFeedAdapter"
|
|
651
|
+
private var videos: List<VideoData> = emptyList()
|
|
652
|
+
|
|
653
|
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VideoFeedCell {
|
|
654
|
+
val itemHeight = when {
|
|
655
|
+
parent.height > 0 -> parent.height
|
|
656
|
+
parent.measuredHeight > 0 -> parent.measuredHeight
|
|
657
|
+
else -> parent.context.resources.displayMetrics.heightPixels
|
|
658
|
+
}
|
|
659
|
+
Log.d(TAG, "Creating ViewHolder, itemHeight=$itemHeight")
|
|
660
|
+
val cell = VideoFeedCell(parent.context)
|
|
661
|
+
cell.itemView.layoutParams = RecyclerView.LayoutParams(
|
|
662
|
+
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
663
|
+
itemHeight
|
|
664
|
+
)
|
|
665
|
+
return cell
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
override fun onBindViewHolder(holder: VideoFeedCell, position: Int) {
|
|
669
|
+
Log.d(TAG, "Binding ViewHolder at position: $position")
|
|
670
|
+
holder.configure(videos[position], playerPool)
|
|
671
|
+
onItemVisible(position)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
override fun onViewRecycled(holder: VideoFeedCell) {
|
|
675
|
+
super.onViewRecycled(holder)
|
|
676
|
+
Log.d(TAG, "♻️ Recycling ViewHolder")
|
|
677
|
+
holder.prepareForReuse(playerPool)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
override fun onViewAttachedToWindow(holder: VideoFeedCell) {
|
|
681
|
+
super.onViewAttachedToWindow(holder)
|
|
682
|
+
Log.d(TAG, "📎 ViewHolder attached to window")
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
override fun onViewDetachedFromWindow(holder: VideoFeedCell) {
|
|
686
|
+
super.onViewDetachedFromWindow(holder)
|
|
687
|
+
Log.d(TAG, "📎 ViewHolder detached from window")
|
|
688
|
+
holder.feedPlayer.isVisible = false // Ensure video stops playing
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
override fun getItemCount(): Int {
|
|
692
|
+
Log.d(TAG, "Item count: ${videos.size}")
|
|
693
|
+
return videos.size
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
fun updateVideos(newVideos: List<VideoData>) {
|
|
697
|
+
val previousCount = videos.size
|
|
698
|
+
videos = newVideos
|
|
699
|
+
Log.d(TAG, "updateVideos: ${newVideos.size} (was $previousCount)")
|
|
700
|
+
when {
|
|
701
|
+
previousCount == 0 && newVideos.isNotEmpty() -> notifyItemRangeInserted(0, newVideos.size)
|
|
702
|
+
newVideos.isEmpty() && previousCount > 0 -> notifyItemRangeRemoved(0, previousCount)
|
|
703
|
+
else -> notifyDataSetChanged()
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|