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,117 @@
1
+ //
2
+ // VideoFeedCell.swift
3
+ // App
4
+ //
5
+ // Created by Venkatesh Mandapati on 15/05/2025.
6
+ //
7
+
8
+ import UIKit
9
+ import SDWebImage
10
+
11
+ class VideoFeedCell: UICollectionViewCell {
12
+
13
+ let feedPlayer = FeedPlayer()
14
+
15
+ private let thumbnailImageView: UIImageView = {
16
+ let iv = UIImageView()
17
+ iv.contentMode = .scaleAspectFit
18
+ iv.clipsToBounds = true
19
+ return iv
20
+ }()
21
+
22
+ override init(frame: CGRect) {
23
+ super.init(frame: frame)
24
+ contentView.backgroundColor = .black
25
+
26
+ contentView.addSubview(thumbnailImageView)
27
+ thumbnailImageView.translatesAutoresizingMaskIntoConstraints = false
28
+ NSLayoutConstraint.activate([
29
+ thumbnailImageView.topAnchor.constraint(equalTo: contentView.topAnchor),
30
+ thumbnailImageView.bottomAnchor.constraint(
31
+ equalTo: contentView.bottomAnchor),
32
+ thumbnailImageView.leadingAnchor.constraint(
33
+ equalTo: contentView.leadingAnchor),
34
+ thumbnailImageView.trailingAnchor.constraint(
35
+ equalTo: contentView.trailingAnchor),
36
+ ])
37
+
38
+ contentView.addSubview(feedPlayer)
39
+ feedPlayer.translatesAutoresizingMaskIntoConstraints = false
40
+ NSLayoutConstraint.activate([
41
+ feedPlayer.topAnchor.constraint(equalTo: contentView.topAnchor),
42
+ feedPlayer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
43
+ feedPlayer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
44
+ feedPlayer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
45
+ ])
46
+ }
47
+
48
+ required init?(coder: NSCoder) {
49
+ fatalError("init(coder:) has not been implemented")
50
+ }
51
+
52
+ func configure(with video: VideoData) {
53
+ // Show and start animation while loading
54
+
55
+ thumbnailImageView.sd_setImage(
56
+ with: URL(string: video.thumbnailUrl ?? ""),
57
+ placeholderImage: nil,
58
+ options: [],
59
+ completed: nil
60
+ )
61
+
62
+ feedPlayer.videoUrl = video.videoUrl as NSString
63
+ feedPlayer.id = video.id as NSString
64
+ feedPlayer.isVisible = false
65
+
66
+ // Set callback to hide thumbnail when video actually starts playing
67
+ feedPlayer.onVideoStartedPlaying = { [weak self] in
68
+ self?.showVideoPlaying()
69
+ }
70
+
71
+ thumbnailImageView.isHidden = false
72
+ }
73
+
74
+ func showVideoPlaying() {
75
+ // Hide thumbnail when video is visible
76
+ thumbnailImageView.isHidden = true
77
+ }
78
+
79
+ override func prepareForReuse() {
80
+ super.prepareForReuse()
81
+
82
+ feedPlayer.reset()
83
+ feedPlayer.isVisible = false
84
+ feedPlayer.onVideoStartedPlaying = nil // Clear callback
85
+
86
+ thumbnailImageView.image = nil
87
+ thumbnailImageView.isHidden = false
88
+
89
+ }
90
+
91
+
92
+ // Implement delegate methods to handle taps and communicate with RN side or native controller
93
+ // func didTapOptions() {
94
+ // // Show action sheet for delete/save video here
95
+ // }
96
+ //
97
+ // func didTapMuteToggle() {
98
+ // // Mute/unmute toggle logic here
99
+ // }
100
+ //
101
+ // func didTapFlag() {
102
+ // // Show report bottom sheet logic here
103
+ // }
104
+ //
105
+ // func didTapViewCount() {
106
+ // // Show view count bottom sheet
107
+ // }
108
+ //
109
+ // func didTapTradingVolume() {
110
+ // // Show trading volume bottom sheet
111
+ // }
112
+ //
113
+ // func didTapShare() {
114
+ // // Trigger share logic
115
+ // }
116
+
117
+ }
@@ -0,0 +1,24 @@
1
+ //
2
+ // VideoFeedEventEmitter.swift
3
+ // App
4
+ //
5
+ // Created by Venkatesh Mandapati on 15/05/2025.
6
+ //
7
+
8
+ import Foundation
9
+ import React
10
+
11
+ @objc(VideoFeedEventEmitter)
12
+ class VideoFeedEventEmitter: RCTEventEmitter {
13
+ override func supportedEvents() -> [String]! {
14
+ return ["onEndReached", "onVideoChange", "onVideoTapped", "onPlayStateChanged"]
15
+ }
16
+
17
+ override static func requiresMainQueueSetup() -> Bool {
18
+ return true
19
+ }
20
+
21
+ @objc func sendEvent(_ event: String, body: Any?) {
22
+ sendEvent(withName: event, body: body)
23
+ }
24
+ }
@@ -0,0 +1,17 @@
1
+ //
2
+ // VideoFeedManagerBridge.h
3
+ // App
4
+ //
5
+ // Created by Venkatesh Mandapati on 24/07/2025.
6
+ //
7
+
8
+ #ifndef VideoFeedManagerBridge_h
9
+ #define VideoFeedManagerBridge_h
10
+
11
+ #import <React/RCTViewManager.h>
12
+ #import <React/RCTBridgeModule.h>
13
+
14
+ @interface VideoFeedViewManager : RCTViewManager
15
+ @end
16
+
17
+ #endif /* VideoFeedManagerBridge_h */
@@ -0,0 +1,31 @@
1
+ //
2
+ // VideoFeedManagerBridge.m
3
+ // App
4
+ //
5
+ // Created by Venkatesh Mandapati on 16/05/2025.
6
+ //
7
+
8
+ #import <React/RCTViewManager.h>
9
+ #import <React/RCTBridgeModule.h>
10
+ #import <React/RCTUIManager.h>
11
+ #import <React/RCTEventEmitter.h>
12
+
13
+ @interface RCT_EXTERN_MODULE(VideoFeedViewManager, RCTViewManager)
14
+
15
+ RCT_EXTERN_METHOD(setVideos:(nonnull NSNumber *)reactTag videos:(NSArray *)videos)
16
+ RCT_EXTERN_METHOD(appendVideos:(nonnull NSNumber *)reactTag videos:(NSArray *)videos)
17
+ RCT_EXTERN_METHOD(setFeedActive:(nonnull NSNumber *)reactTag isActive:(BOOL)active)
18
+
19
+ RCT_EXTERN_METHOD(pauseVideo:(nonnull NSNumber *)reactTag)
20
+ RCT_EXTERN_METHOD(playVideo:(nonnull NSNumber *)reactTag)
21
+ RCT_EXTERN_METHOD(togglePlayPause:(nonnull NSNumber *)reactTag)
22
+ RCT_EXTERN_METHOD(isVideoPlaying:(nonnull NSNumber *)reactTag)
23
+
24
+ RCT_EXTERN_METHOD(addEventListener:(NSString *)eventName)
25
+ RCT_EXTERN_METHOD(removeEventListener:(NSString *)eventName)
26
+
27
+ @end
28
+
29
+ // Event Emitter for video feed events
30
+ @interface RCT_EXTERN_MODULE(VideoFeedEventEmitter, RCTEventEmitter)
31
+ @end
@@ -0,0 +1,432 @@
1
+ //
2
+ // VideoFeedView.swift
3
+ // App
4
+ //
5
+ // Created by Venkatesh Mandapati on 15/05/2025.
6
+ //
7
+
8
+ import AVFoundation
9
+ import UIKit
10
+
11
+ class VideoFeedView: UIView {
12
+ private var collectionView: UICollectionView!
13
+ private var videos: [VideoData] = []
14
+ private var currentPlayer: AVPlayer?
15
+ private var preloadTasks: [String: AVAsset] = [:]
16
+ private var feedIsActive = true
17
+ private var shouldPlayFirstVideo = false
18
+ private var isManuallyPaused = false // Track if user manually paused
19
+ weak var eventEmitter: VideoFeedEventEmitter?
20
+
21
+ var onEndReached: (() -> Void)?
22
+ var onVideoChange: ((String) -> Void)?
23
+
24
+ override init(frame: CGRect) {
25
+ super.init(frame: frame)
26
+ setupAppLifecycleObservers()
27
+ setupCollectionView()
28
+ setupTapGesture()
29
+ }
30
+
31
+ //Not required, only for storyboard or xib included
32
+ required init?(coder: NSCoder) {
33
+ fatalError("init(coder:) has not been implemented")
34
+ }
35
+
36
+ private func setupAppLifecycleObservers() {
37
+ NotificationCenter.default.addObserver(
38
+ self,
39
+ selector: #selector(appDidEnterBackground),
40
+ name: UIApplication.didEnterBackgroundNotification,
41
+ object: nil
42
+ )
43
+
44
+ NotificationCenter.default.addObserver(
45
+ self,
46
+ selector: #selector(appDidBecomeActive),
47
+ name: UIApplication.didBecomeActiveNotification,
48
+ object: nil
49
+ )
50
+ }
51
+
52
+ @objc private func appDidEnterBackground() {
53
+ currentPlayer?.pause()
54
+ }
55
+
56
+ @objc private func appDidBecomeActive() {
57
+ if feedIsActive && !isManuallyPaused {
58
+ currentPlayer?.play()
59
+ } else if isManuallyPaused {
60
+ print("App became active but video was manually paused — staying paused")
61
+ }
62
+ }
63
+
64
+ private func setupCollectionView() {
65
+ let layout = UICollectionViewFlowLayout()
66
+ layout.scrollDirection = .vertical
67
+ layout.minimumLineSpacing = 0
68
+ layout.minimumInteritemSpacing = 0
69
+ // Initial size - will be updated in layoutSubviews with correct bounds
70
+ layout.itemSize = CGSize(
71
+ width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
72
+
73
+ collectionView = UICollectionView(
74
+ frame: .zero, collectionViewLayout: layout)
75
+ collectionView.isPagingEnabled = true
76
+ collectionView.showsVerticalScrollIndicator = false
77
+ collectionView.backgroundColor = .black
78
+ collectionView.contentInsetAdjustmentBehavior = .never // Prevent safe area adjustments
79
+ collectionView.register(
80
+ VideoFeedCell.self, forCellWithReuseIdentifier: "VideoFeedCell")
81
+ collectionView.dataSource = self
82
+ collectionView.delegate = self
83
+ collectionView.prefetchDataSource = self
84
+
85
+ addSubview(collectionView)
86
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
87
+ NSLayoutConstraint.activate([
88
+ collectionView.topAnchor.constraint(equalTo: topAnchor),
89
+ collectionView.bottomAnchor.constraint(equalTo: bottomAnchor),
90
+ collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
91
+ collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
92
+ ])
93
+
94
+ }
95
+
96
+ override func layoutSubviews() {
97
+ super.layoutSubviews()
98
+
99
+ // Update layout - use full screen bounds, not collectionView bounds which might include safe area
100
+ if let layout = collectionView.collectionViewLayout
101
+ as? UICollectionViewFlowLayout
102
+ {
103
+ // Use bounds.size to ensure full screen, accounting for any parent container constraints
104
+ let fullScreenSize = CGSize(
105
+ width: bounds.width,
106
+ height: bounds.height
107
+ )
108
+ layout.itemSize = fullScreenSize
109
+ }
110
+ }
111
+
112
+ func setVideos(_ videos: [[String: Any]]) {
113
+ self.videos = videos.compactMap { dict -> VideoData? in
114
+ guard let id = dict["id"] as? String,
115
+ let url = dict["videoUrl"] as? String
116
+ else {
117
+ print("Failed to map video data - missing id or url")
118
+ return nil
119
+ }
120
+ let thumbnail = dict["thumbnailUrl"] as? String
121
+ let viewCount: Int? = {
122
+ if let count = dict["viewCount"] as? Int {
123
+ return count
124
+ }
125
+ return nil
126
+ }()
127
+ return VideoData(
128
+ id: id, videoUrl: url, thumbnailUrl: thumbnail, viewCount: viewCount)
129
+ }
130
+ collectionView.reloadData()
131
+ preloadNextVideos()
132
+
133
+ // Set flag to play first video when cell becomes available
134
+ if !videos.isEmpty {
135
+ shouldPlayFirstVideo = true
136
+ print("Set shouldPlayFirstVideo flag to true")
137
+ }
138
+ }
139
+
140
+ func appendVideos(_ videos: [[String: Any]]) {
141
+ let newVideos = videos.compactMap { dict -> VideoData? in
142
+ guard let id = dict["id"] as? String,
143
+ let url = dict["videoUrl"] as? String
144
+ else { return nil }
145
+ let thumbnail = dict["thumbnailUrl"] as? String
146
+ let duration: Int? = {
147
+ if let duration = dict["viewCount"] as? Int {
148
+ return duration
149
+ }
150
+ return nil
151
+ }()
152
+ return VideoData(
153
+ id: id, videoUrl: url, thumbnailUrl: thumbnail, viewCount: duration)
154
+ }
155
+
156
+ let startIndex = self.videos.count
157
+ self.videos.append(contentsOf: newVideos)
158
+
159
+ // Insert new cells instead of reloading all
160
+ let indexPaths = (startIndex..<self.videos.count).map {
161
+ IndexPath(item: $0, section: 0)
162
+ }
163
+ collectionView.insertItems(at: indexPaths)
164
+ preloadNextVideos()
165
+ }
166
+
167
+ func setFeedActive(_ isActive: Bool) {
168
+ self.feedIsActive = isActive
169
+
170
+ if isActive {
171
+ // When becoming active, try to play the current video
172
+ if !isManuallyPaused {
173
+ if let player = currentPlayer {
174
+ player.play()
175
+ eventEmitter?.sendEvent(withName: "onPlayStateChanged", body: ["isPlaying": true])
176
+ } else {
177
+ let currentIndex = getCurrentIndex()
178
+ if currentIndex >= 0 && currentIndex < videos.count {
179
+ playVideo(at: currentIndex)
180
+ }
181
+ }
182
+ } else {
183
+ print("setFeedActive: Video was manually paused, not resuming")
184
+ }
185
+ } else {
186
+ if let player = currentPlayer {
187
+ player.pause()
188
+ } else {
189
+ print("setFeedActive: No current player to pause")
190
+ }
191
+ }
192
+ }
193
+
194
+ private func preloadNextVideos() {
195
+ let visibleIndex = getCurrentIndex()
196
+ let start = visibleIndex + 1
197
+ let end = min(visibleIndex + 2, videos.count - 1)
198
+
199
+ guard start <= end else { return } // avoid invalid range
200
+
201
+ let preloadRange = start...end
202
+ for index in preloadRange {
203
+ let video = videos[index]
204
+ guard preloadTasks[video.videoUrl] == nil,
205
+ let url = URL(string: video.videoUrl)
206
+ else { continue }
207
+
208
+ let asset = AVAsset(url: url)
209
+ preloadTasks[video.videoUrl] = asset
210
+
211
+ asset.loadValuesAsynchronously(forKeys: ["playable"]) { [weak self] in
212
+ DispatchQueue.main.async {
213
+ self?.preloadTasks[video.videoUrl] = nil
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ private func getCurrentIndex() -> Int {
220
+ let offset = collectionView.contentOffset.y
221
+ let height = collectionView.bounds.height
222
+
223
+ guard height > 0 else {
224
+ print("Warning: CollectionView height is 0")
225
+ return 0 // or some default/fallback index
226
+ }
227
+
228
+ return max(0, Int(round(offset / height)))
229
+ }
230
+
231
+ private func playVideo(at index: Int) {
232
+ print("Attempting to play video at index:", index)
233
+
234
+ guard feedIsActive else {
235
+ print("Feed inactive, skipping playVideo at index:", index)
236
+ return
237
+ }
238
+
239
+ guard index < videos.count else {
240
+ print("Index out of bounds:", index)
241
+ return
242
+ }
243
+ currentPlayer?.pause()
244
+ if let cell = collectionView.cellForItem(
245
+ at: IndexPath(item: index, section: 0)) as? VideoFeedCell
246
+ {
247
+ print("Found cell for index:", index)
248
+ currentPlayer = cell.feedPlayer.player
249
+ currentPlayer?.isMuted = false
250
+
251
+ // Only play if not manually paused
252
+ if !isManuallyPaused {
253
+ currentPlayer?.play()
254
+ cell.feedPlayer.isVisible = true
255
+ // Don't hide thumbnail here - it will be hidden when video actually starts playing via callback
256
+ print("▶️ Auto-playing new video at index: \(index)")
257
+ } else {
258
+ cell.feedPlayer.isVisible = false
259
+ print("⏸️ New video at index: \(index) but staying paused (manually paused)")
260
+ }
261
+
262
+ onVideoChange?(videos[index].id)
263
+ } else {
264
+ print("No cell found for index:", index)
265
+ }
266
+ }
267
+
268
+ // MARK: - Tap Gesture Setup
269
+
270
+ private func setupTapGesture() {
271
+ let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
272
+ tapGesture.numberOfTapsRequired = 1
273
+ // Allow tap gesture to work alongside collection view gestures
274
+ tapGesture.cancelsTouchesInView = false
275
+ addGestureRecognizer(tapGesture)
276
+ print("🔥 Tap gesture recognizer added to VideoFeedView")
277
+ }
278
+
279
+ @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
280
+ print("🔥 Tap detected in native VideoFeedView")
281
+
282
+ // Toggle play/pause and get the new state
283
+ let isNowPlaying = togglePlayPause()
284
+
285
+ // Emit event to React Native for UI feedback
286
+ eventEmitter?.sendEvent("onVideoTapped", body: ["isPlaying": isNowPlaying])
287
+ }
288
+
289
+ // MARK: - Play/Pause Control Methods
290
+
291
+ /// Gets the current visible index based on scroll position
292
+ private func getCurrentVisibleIndex() -> Int? {
293
+ let visibleIndexPaths = collectionView.indexPathsForVisibleItems
294
+ guard let firstVisibleIndexPath = visibleIndexPaths.first else { return nil }
295
+ return firstVisibleIndexPath.item
296
+ }
297
+
298
+ /// Gets the current visible cell
299
+ private func getCurrentCell(at index: Int) -> VideoFeedCell? {
300
+ let indexPath = IndexPath(item: index, section: 0)
301
+ return collectionView.cellForItem(at: indexPath) as? VideoFeedCell
302
+ }
303
+
304
+ /// Pauses the currently playing video
305
+ func pauseCurrentVideo() {
306
+ guard let currentIndex = getCurrentVisibleIndex(),
307
+ let cell = getCurrentCell(at: currentIndex) else {
308
+ print("Failed to get current video for pause")
309
+ return
310
+ }
311
+
312
+ isManuallyPaused = true
313
+ cell.feedPlayer.isVisible = false
314
+ }
315
+
316
+ /// Plays the currently visible video
317
+ func playCurrentVideo() {
318
+ guard let currentIndex = getCurrentVisibleIndex(),
319
+ let cell = getCurrentCell(at: currentIndex) else {
320
+ print("Failed to get current video for play")
321
+ return
322
+ }
323
+
324
+ isManuallyPaused = false
325
+ cell.feedPlayer.isVisible = true
326
+ }
327
+
328
+ /// Toggles play/pause state of current video
329
+ /// Returns the new playing state (true = playing, false = paused)
330
+ func togglePlayPause() -> Bool {
331
+ guard let currentIndex = getCurrentVisibleIndex(),
332
+ let cell = getCurrentCell(at: currentIndex) else {
333
+ print("Failed to get current video for toggle")
334
+ return false
335
+ }
336
+
337
+ let wasPlaying = cell.feedPlayer.isVisible
338
+ let newPlayingState = !wasPlaying
339
+ isManuallyPaused = !newPlayingState // Update manual pause state
340
+
341
+ cell.feedPlayer.isVisible = newPlayingState
342
+
343
+ return newPlayingState
344
+ }
345
+
346
+ /// Gets the current playing state
347
+ func isCurrentVideoPlaying() -> Bool {
348
+ guard let currentIndex = getCurrentVisibleIndex(),
349
+ let cell = getCurrentCell(at: currentIndex) else {
350
+ return false
351
+ }
352
+
353
+ return cell.feedPlayer.isVisible
354
+ }
355
+
356
+ // Handle removing notificationcenter listners for app states
357
+ deinit {
358
+ NotificationCenter.default.removeObserver(self)
359
+ }
360
+ }
361
+
362
+ extension VideoFeedView: UICollectionViewDataSource {
363
+ func collectionView(
364
+ _ collectionView: UICollectionView, numberOfItemsInSection section: Int
365
+ ) -> Int {
366
+ return videos.count
367
+ }
368
+
369
+ func collectionView(
370
+ _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath
371
+ ) -> UICollectionViewCell {
372
+ let cell =
373
+ collectionView.dequeueReusableCell(
374
+ withReuseIdentifier: "VideoFeedCell", for: indexPath) as! VideoFeedCell
375
+ let video = videos[indexPath.item]
376
+ cell.configure(with: video)
377
+ return cell
378
+ }
379
+ }
380
+
381
+ extension VideoFeedView: UICollectionViewDelegate {
382
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
383
+ let index = getCurrentIndex()
384
+ for cell in collectionView.visibleCells as! [VideoFeedCell] {
385
+ cell.feedPlayer.isVisible = false
386
+ }
387
+
388
+ // Reset manual pause state for new video
389
+ isManuallyPaused = false
390
+
391
+ playVideo(at: index)
392
+
393
+ // Emit video change event to React Native
394
+ if index < videos.count {
395
+ let videoId = videos[index].id
396
+ eventEmitter?.sendEvent("onVideoChange", body: ["videoId": videoId])
397
+ }
398
+ }
399
+
400
+ func collectionView(
401
+ _ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell,
402
+ forItemAt indexPath: IndexPath
403
+ ) {
404
+ // Play first video when it becomes available
405
+ if shouldPlayFirstVideo && indexPath.item == 0 {
406
+ shouldPlayFirstVideo = false
407
+ // Use a small delay to ensure the cell is fully configured
408
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
409
+ self?.playVideo(at: 0)
410
+ }
411
+ }
412
+
413
+ if indexPath.item >= videos.count - 3 {
414
+ onEndReached?()
415
+ }
416
+ }
417
+ }
418
+
419
+ extension VideoFeedView: UICollectionViewDataSourcePrefetching {
420
+ func collectionView(
421
+ _ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]
422
+ ) {
423
+ preloadNextVideos()
424
+ }
425
+ }
426
+
427
+ struct VideoData {
428
+ let id: String
429
+ let videoUrl: String
430
+ let thumbnailUrl: String?
431
+ let viewCount: Int?
432
+ }