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.
@@ -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
+ }