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,129 @@
1
+ package com.rnvideofeed
2
+
3
+ import android.util.Log
4
+ import com.facebook.react.bridge.ReadableArray
5
+ import com.facebook.react.bridge.WritableMap
6
+ import com.facebook.react.bridge.WritableNativeMap
7
+ import com.facebook.react.common.MapBuilder
8
+ import com.facebook.react.modules.core.DeviceEventManagerModule
9
+ import com.facebook.react.uimanager.SimpleViewManager
10
+ import com.facebook.react.uimanager.ThemedReactContext
11
+
12
+ class VideoFeedViewManager : SimpleViewManager<VideoFeedView>() {
13
+
14
+ private val TAG = "VideoFeedViewManager"
15
+
16
+ override fun getName(): String {
17
+ return "VideoFeedView"
18
+ }
19
+
20
+ override fun createViewInstance(reactContext: ThemedReactContext): VideoFeedView {
21
+ Log.d(TAG, "Creating new VideoFeedView")
22
+
23
+ val view = VideoFeedView(reactContext)
24
+ // Set the React context for event emission
25
+ view.setReactContext(reactContext)
26
+
27
+ // Set up event handlers using DeviceEventManagerModule for global events
28
+ view.onEndReached = {
29
+ Log.d(TAG, "End reached")
30
+ reactContext
31
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
32
+ .emit("onEndReached", null)
33
+ }
34
+
35
+ view.onVideoChange = { videoId ->
36
+ Log.d(TAG, "Video changed: $videoId")
37
+ val event: WritableMap = WritableNativeMap().apply {
38
+ putString("videoId", videoId)
39
+ }
40
+ reactContext
41
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
42
+ .emit("onVideoChange", event)
43
+ }
44
+
45
+ // Set up tap event handler
46
+ view.onVideoTapped = { isPlaying ->
47
+ Log.d(TAG, "Video tapped, isPlaying: $isPlaying")
48
+ val event: WritableMap = WritableNativeMap().apply {
49
+ putBoolean("isPlaying", isPlaying)
50
+ }
51
+ reactContext
52
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
53
+ .emit("onVideoTapped", event)
54
+ }
55
+
56
+ Log.d(TAG, "VideoFeedView created")
57
+ return view
58
+ }
59
+
60
+
61
+
62
+ // Method to be called from React Native
63
+ fun setVideos(view: VideoFeedView, videos: ReadableArray) {
64
+ Log.d(TAG, "Setting videos for view")
65
+
66
+ val videoList = mutableListOf<Map<String, Any>>()
67
+
68
+ for (i in 0 until videos.size()) {
69
+ val videoMap = videos.getMap(i)
70
+ if (videoMap != null) {
71
+ val videoData = mutableMapOf<String, Any>()
72
+
73
+ if (videoMap.hasKey("id")) {
74
+ videoData["id"] = videoMap.getString("id") ?: ""
75
+ }
76
+ if (videoMap.hasKey("videoUrl")) {
77
+ videoData["videoUrl"] = videoMap.getString("videoUrl") ?: ""
78
+ }
79
+ if (videoMap.hasKey("thumbnailUrl")) {
80
+ videoData["thumbnailUrl"] = videoMap.getString("thumbnailUrl") ?: ""
81
+ }
82
+ if (videoMap.hasKey("viewCount")) {
83
+ videoData["viewCount"] = videoMap.getInt("viewCount")
84
+ }
85
+
86
+ videoList.add(videoData)
87
+ }
88
+ }
89
+
90
+ Log.d(TAG, "Found view, setting videos: ${videoList.size}")
91
+ view.setVideos(videoList)
92
+ }
93
+
94
+ fun appendVideos(view: VideoFeedView, videos: ReadableArray) {
95
+ Log.d(TAG, "Appending videos for view")
96
+
97
+ val videoList = mutableListOf<Map<String, Any>>()
98
+
99
+ for (i in 0 until videos.size()) {
100
+ val videoMap = videos.getMap(i)
101
+ if (videoMap != null) {
102
+ val videoData = mutableMapOf<String, Any>()
103
+
104
+ if (videoMap.hasKey("id")) {
105
+ videoData["id"] = videoMap.getString("id") ?: ""
106
+ }
107
+ if (videoMap.hasKey("videoUrl")) {
108
+ videoData["videoUrl"] = videoMap.getString("videoUrl") ?: ""
109
+ }
110
+ if (videoMap.hasKey("thumbnailUrl")) {
111
+ videoData["thumbnailUrl"] = videoMap.getString("thumbnailUrl") ?: ""
112
+ }
113
+ if (videoMap.hasKey("viewCount")) {
114
+ videoData["viewCount"] = videoMap.getInt("viewCount")
115
+ }
116
+
117
+ videoList.add(videoData)
118
+ }
119
+ }
120
+
121
+ Log.d(TAG, "Found view, appending videos: ${videoList.size}")
122
+ view.appendVideos(videoList)
123
+ }
124
+
125
+ fun setFeedActive(view: VideoFeedView, isActive: Boolean) {
126
+ Log.d(TAG, "setFeedActive: isActive = $isActive")
127
+ view.setFeedActive(isActive)
128
+ }
129
+ }
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <androidx.media3.ui.PlayerView
3
+ xmlns:android="http://schemas.android.com/apk/res/android"
4
+ xmlns:app="http://schemas.android.com/apk/res-auto"
5
+ app:surface_type="texture_view" />
package/index.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { ComponentType, RefAttributes } from 'react'
2
+ import type { LayoutChangeEvent, NativeEventEmitter, NativeModule, ViewStyle } from 'react-native'
3
+
4
+ export type FeedPlayerNativeProps = {
5
+ id?: string
6
+ videoUrl?: string
7
+ thumbnailUrl?: string
8
+ createdAt?: boolean
9
+ viewCount?: number
10
+ style?: ViewStyle
11
+ onLayout?: (event: LayoutChangeEvent) => void
12
+ }
13
+
14
+ export declare const VideoFeedView: ComponentType<FeedPlayerNativeProps & RefAttributes<unknown>>
15
+ export default VideoFeedView
16
+
17
+ export interface VideoFeedManager extends NativeModule {
18
+ setVideos: (reactTag: number, videos: FeedPlayerNativeProps[]) => void
19
+ appendVideos: (reactTag: number, videos: FeedPlayerNativeProps[]) => void
20
+ setFeedActive: (reactTag: number, isActive: boolean) => void
21
+ addEventListener: (eventName: string) => void
22
+ removeEventListener: (eventName: string) => void
23
+ pauseVideo: (reactTag: number) => void
24
+ playVideo: (reactTag: number) => void
25
+ togglePlayPause: (reactTag: number) => void
26
+ isVideoPlaying: (reactTag: number) => void
27
+ }
28
+
29
+ export declare const VideoFeedManagerNative: VideoFeedManager
30
+ export declare const VideoFeedEmitter: NativeEventEmitter
@@ -0,0 +1,312 @@
1
+ //
2
+ // ContentCardInfoView.swift
3
+ // App
4
+ //
5
+ // Created by Venkatesh Mandapati on 08/06/2025.
6
+ //
7
+
8
+ import Foundation
9
+ import UIKit
10
+
11
+ protocol ContentCardInfoViewDelegate: AnyObject {
12
+ func didTapOptions()
13
+ func didTapMuteToggle()
14
+ func didTapFlag()
15
+ func didTapViewCount()
16
+ func didTapTradingVolume()
17
+ func didTapShare()
18
+ }
19
+
20
+ class ContentCardInfoView: UIView {
21
+
22
+ weak var delegate: ContentCardInfoViewDelegate?
23
+
24
+ private let isOwnProfile: Bool
25
+ private let shouldShowFullScreen: Bool
26
+ private let isMutedInitial: Bool
27
+ private let showShareButton: Bool
28
+
29
+ // MARK: UI Components
30
+
31
+ func applyShadow(to view: UIView) {
32
+ view.layer.shadowColor = UIColor.black.cgColor
33
+ view.layer.shadowOpacity = 0.6
34
+ view.layer.shadowOffset = CGSize(width: 0, height: 1)
35
+ view.layer.shadowRadius = 2
36
+ view.layer.masksToBounds = false
37
+ }
38
+
39
+ private lazy var optionsButton: UIButton = {
40
+ let button = UIButton(type: .system)
41
+ button.setImage(UIImage(systemName: "exclamationmark.bubble")?.withRenderingMode(.alwaysTemplate), for: .normal)
42
+ button.tintColor = .white
43
+ button.addTarget(self, action: #selector(optionsTapped), for: .touchUpInside)
44
+ button.isHidden = !isOwnProfile
45
+ applyShadow(to: button)
46
+
47
+ return button
48
+ }()
49
+
50
+ private lazy var muteButton: UIButton = {
51
+ let button = UIButton(type: .system)
52
+ updateMuteButtonIcon(isMuted: isMutedInitial, button: button)
53
+ let imageName = isMutedInitial ? "speaker.slash" : "speaker.wave.2"
54
+ button.setImage(UIImage(systemName: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
55
+ button.tintColor = .white
56
+ button.addTarget(self, action: #selector(muteTapped), for: .touchUpInside)
57
+ applyShadow(to: button)
58
+ return button
59
+ }()
60
+
61
+ private lazy var flagButton: UIButton = {
62
+ let button = UIButton(type: .system)
63
+ button.setImage(UIImage(systemName: "exclamationmark.bubble")?.withRenderingMode(.alwaysTemplate), for: .normal)
64
+ button.tintColor = .white
65
+ button.addTarget(self, action: #selector(flagTapped), for: .touchUpInside)
66
+ applyShadow(to: button)
67
+ return button
68
+ }()
69
+
70
+ private lazy var viewCountButton: UIButton = {
71
+ let button = UIButton(type: .system)
72
+ // button.setImage(UIImage(named: "PlayRegular24")?.withRenderingMode(.alwaysTemplate), for: .normal)
73
+ button.setImage(UIImage(systemName: "play")?.withRenderingMode(.alwaysTemplate), for: .normal)
74
+ button.tintColor = .white
75
+ button.addTarget(self, action: #selector(viewCountTapped), for: .touchUpInside)
76
+ applyShadow(to: button)
77
+ return button
78
+ }()
79
+
80
+ private lazy var viewCountLabel: UILabel = {
81
+ let label = UILabel()
82
+ label.font = .systemFont(ofSize: 14, weight: .medium)
83
+ label.textColor = .white
84
+ label.textAlignment = .center
85
+ label.adjustsFontSizeToFitWidth = true
86
+ label.numberOfLines = 1
87
+ label.text = "10"
88
+ applyShadow(to: label)
89
+ return label
90
+ }()
91
+
92
+ private lazy var tradingVolumeButton: UIButton = {
93
+ let button = UIButton(type: .system)
94
+ // button.setImage(UIImage(named: "DollarCircleRegular24")?.withRenderingMode(.alwaysTemplate), for: .normal)
95
+ button.setImage(UIImage(systemName: "dollarsign.circle")?.withRenderingMode(.alwaysTemplate), for: .normal)
96
+ button.tintColor = .white
97
+ button.addTarget(self, action: #selector(tradingVolumeTapped), for: .touchUpInside)
98
+ applyShadow(to: button)
99
+ return button
100
+ }()
101
+
102
+ private lazy var tradingVolumeLabel: UILabel = {
103
+ let label = UILabel()
104
+ label.font = .systemFont(ofSize: 14, weight: .medium)
105
+ label.textColor = .white
106
+ label.textAlignment = .center
107
+ label.adjustsFontSizeToFitWidth = true
108
+ label.numberOfLines = 1
109
+ label.text = "$0"
110
+ applyShadow(to: label)
111
+ return label
112
+ }()
113
+
114
+ private lazy var shareButton: UIButton = {
115
+ let button = UIButton(type: .system)
116
+ // button.setImage(UIImage(named: "ShareContent24")?.withRenderingMode(.alwaysTemplate), for: .normal)
117
+ button.setImage(UIImage(systemName: "arrowshape.turn.up.right")?.withRenderingMode(.alwaysTemplate), for: .normal)
118
+ button.tintColor = .white
119
+ button.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
120
+ button.isHidden = !showShareButton
121
+ applyShadow(to: button)
122
+ return button
123
+ }()
124
+
125
+ private lazy var shareLabel: UILabel = {
126
+ let label = UILabel()
127
+ label.font = .systemFont(ofSize: 14, weight: .medium)
128
+ label.textColor = .white
129
+ label.textAlignment = .center
130
+ label.text = NSLocalizedString("Share", comment: "")
131
+ label.isHidden = !showShareButton
132
+ applyShadow(to: label)
133
+ return label
134
+ }()
135
+
136
+ // MARK: Initialization
137
+
138
+ init(
139
+ isOwnProfile: Bool,
140
+ isMuted: Bool,
141
+ viewCount: Int?,
142
+ tradingVolume: String?,
143
+ shouldShowFullScreen: Bool,
144
+ showShareButton: Bool
145
+ ) {
146
+ self.isOwnProfile = isOwnProfile
147
+ self.shouldShowFullScreen = shouldShowFullScreen
148
+ self.isMutedInitial = isMuted
149
+ self.showShareButton = showShareButton
150
+
151
+ super.init(frame: .zero)
152
+
153
+ backgroundColor = .clear
154
+
155
+ setupSubviews()
156
+ setupConstraints()
157
+
158
+ // Set labels if data available
159
+ if let viewCount = viewCount {
160
+ viewCountLabel.text = formatContentViews(Decimal(viewCount))
161
+ }
162
+ if let tradingVolume = tradingVolume {
163
+ tradingVolumeLabel.text = "$" + formatContentDataValue(Decimal(string: tradingVolume) ?? 0)
164
+ }
165
+ }
166
+
167
+ required init?(coder: NSCoder) {
168
+ fatalError("init(coder:) has not been implemented")
169
+ }
170
+
171
+ // MARK: Setup UI
172
+
173
+ private func setupSubviews() {
174
+ addSubview(optionsButton)
175
+ addSubview(muteButton)
176
+ addSubview(flagButton)
177
+
178
+ addSubview(viewCountButton)
179
+ addSubview(viewCountLabel)
180
+
181
+ addSubview(tradingVolumeButton)
182
+ addSubview(tradingVolumeLabel)
183
+
184
+ addSubview(shareButton)
185
+ addSubview(shareLabel)
186
+ }
187
+
188
+ private func setupConstraints() {
189
+ // We'll position vertically on the right side with spacing,
190
+ // similar to your RN layout: top group (options, mute, flag), bottom group (view count, trading, share)
191
+ // And add safe area padding top if shouldShowFullScreen is true.
192
+
193
+ optionsButton.translatesAutoresizingMaskIntoConstraints = false
194
+ muteButton.translatesAutoresizingMaskIntoConstraints = false
195
+ flagButton.translatesAutoresizingMaskIntoConstraints = false
196
+ viewCountButton.translatesAutoresizingMaskIntoConstraints = false
197
+ viewCountLabel.translatesAutoresizingMaskIntoConstraints = false
198
+ tradingVolumeButton.translatesAutoresizingMaskIntoConstraints = false
199
+ tradingVolumeLabel.translatesAutoresizingMaskIntoConstraints = false
200
+ shareButton.translatesAutoresizingMaskIntoConstraints = false
201
+ shareLabel.translatesAutoresizingMaskIntoConstraints = false
202
+
203
+ let safeTopInset = UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0
204
+ let topPadding = shouldShowFullScreen ? safeTopInset + 8 : 16
205
+
206
+ NSLayoutConstraint.activate([
207
+
208
+ // TOP SECTION
209
+
210
+ optionsButton.topAnchor.constraint(equalTo: topAnchor, constant: topPadding),
211
+ optionsButton.centerXAnchor.constraint(equalTo: centerXAnchor),
212
+
213
+ muteButton.topAnchor.constraint(equalTo: optionsButton.bottomAnchor, constant: 8),
214
+ muteButton.centerXAnchor.constraint(equalTo: centerXAnchor),
215
+
216
+ flagButton.topAnchor.constraint(equalTo: muteButton.bottomAnchor, constant: 8),
217
+ flagButton.centerXAnchor.constraint(equalTo: centerXAnchor),
218
+
219
+ // View count (play) at bottom
220
+ // Share button at bottom
221
+ shareButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -204),
222
+ shareButton.centerXAnchor.constraint(equalTo: centerXAnchor),
223
+
224
+ shareLabel.topAnchor.constraint(equalTo: shareButton.bottomAnchor, constant: 2),
225
+ shareLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
226
+ shareLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 40),
227
+
228
+ // Trading volume ABOVE share
229
+ tradingVolumeButton.bottomAnchor.constraint(equalTo: shareButton.topAnchor, constant: -46),
230
+ tradingVolumeButton.centerXAnchor.constraint(equalTo: centerXAnchor),
231
+
232
+ tradingVolumeLabel.topAnchor.constraint(equalTo: tradingVolumeButton.bottomAnchor, constant: 2),
233
+ tradingVolumeLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
234
+ tradingVolumeLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 50),
235
+
236
+ // View count ABOVE trading volume
237
+ viewCountButton.bottomAnchor.constraint(equalTo: tradingVolumeButton.topAnchor, constant: -46),
238
+ viewCountButton.centerXAnchor.constraint(equalTo: centerXAnchor),
239
+
240
+ viewCountLabel.topAnchor.constraint(equalTo: viewCountButton.bottomAnchor, constant: 2),
241
+ viewCountLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
242
+ viewCountLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 40),
243
+
244
+ ])
245
+
246
+ }
247
+
248
+ // MARK: Helpers
249
+
250
+ private func updateMuteButtonIcon(isMuted: Bool, button: UIButton) {
251
+ let imageName = isMuted ? "speaker.slash" : "speaker.wave.2"
252
+ button.setImage(UIImage(systemName: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
253
+ }
254
+
255
+ func updateMuteState(isMuted: Bool) {
256
+ updateMuteButtonIcon(isMuted: isMuted, button: muteButton)
257
+ }
258
+
259
+ func updateViewCount(_ count: Int) {
260
+ viewCountLabel.text = formatContentViews(Decimal(count))
261
+ }
262
+
263
+ func updateTradingVolume(_ volume: String) {
264
+ tradingVolumeLabel.text = "$" + formatContentDataValue(Decimal(string: volume) ?? 0)
265
+ }
266
+
267
+ // MARK: Button Actions
268
+
269
+ @objc private func optionsTapped() {
270
+ delegate?.didTapOptions()
271
+ }
272
+
273
+ @objc private func muteTapped() {
274
+ delegate?.didTapMuteToggle()
275
+ }
276
+
277
+ @objc private func flagTapped() {
278
+ delegate?.didTapFlag()
279
+ }
280
+
281
+ @objc private func viewCountTapped() {
282
+ delegate?.didTapViewCount()
283
+ }
284
+
285
+ @objc private func tradingVolumeTapped() {
286
+ delegate?.didTapTradingVolume()
287
+ }
288
+
289
+ @objc private func shareTapped() {
290
+ delegate?.didTapShare()
291
+ }
292
+ }
293
+
294
+ // Helpers to mimic your formatting from RN utils:
295
+
296
+ import Foundation
297
+
298
+ func formatContentViews(_ value: Decimal) -> String {
299
+ // Simplified format, e.g. 1.2K, 3.5M
300
+ if value >= 1_000_000 {
301
+ return String(format: "%.1fM", NSDecimalNumber(decimal: value).doubleValue / 1_000_000)
302
+ }
303
+ if value >= 1_000 {
304
+ return String(format: "%.1fK", NSDecimalNumber(decimal: value).doubleValue / 1_000)
305
+ }
306
+ return NSDecimalNumber(decimal: value).stringValue
307
+ }
308
+
309
+ func formatContentDataValue(_ value: Decimal) -> String {
310
+ // Format trading volume, e.g. 12.3K, 1.5M
311
+ return formatContentViews(value)
312
+ }
@@ -0,0 +1,174 @@
1
+ //
2
+ // FeedPlayer.swift
3
+ // App
4
+ //
5
+ // Created by Venkatesh Mandapati on 15/05/2025.
6
+ //
7
+
8
+
9
+
10
+ import Foundation
11
+ import AVFoundation
12
+
13
+ class FeedPlayer: UIView {
14
+ private(set) var player: AVPlayer?
15
+ private(set) var playerLayer: AVPlayerLayer?
16
+ var onVideoStartedPlaying: (() -> Void)?
17
+ private var timeControlStatusObserver: NSKeyValueObservation?
18
+
19
+ @objc var videoUrl: NSString? {
20
+ didSet {
21
+ print("FeedPlayer - Setting video URL:", videoUrl ?? "nil")
22
+ if let videoUrl = videoUrl as String? {
23
+ setupPlayer(with: videoUrl)
24
+ }
25
+ }
26
+ }
27
+
28
+ @objc var id: NSString = "" {
29
+ didSet {
30
+ print("FeedPlayer - Setting ID:", id)
31
+ }
32
+ }
33
+
34
+ @objc var isVisible: Bool = true {
35
+ didSet {
36
+ print("FeedPlayer - Setting visibility:", isVisible)
37
+ if isVisible {
38
+ player?.play()
39
+ } else {
40
+ player?.pause()
41
+ }
42
+ }
43
+ }
44
+
45
+ override init(frame: CGRect) {
46
+ super.init(frame: frame)
47
+ self.backgroundColor = .clear
48
+ }
49
+
50
+ required init?(coder: NSCoder) {
51
+ fatalError("init(coder:) has not been implemented")
52
+ }
53
+
54
+ private func setupPlayer(with urlString: String) {
55
+ print("FeedPlayer - Setting up player with URL:", urlString)
56
+ guard let url = URL(string: urlString) else {
57
+ print("⚠️ Invalid URL: \(urlString)")
58
+ return
59
+ }
60
+
61
+ // Ensure audio plays even on silent
62
+ do {
63
+ try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
64
+ try AVAudioSession.sharedInstance().setActive(true)
65
+ } catch {
66
+ print("Failed to set AVAudioSession:", error)
67
+ }
68
+
69
+ // Clean up
70
+ playerLayer?.removeFromSuperlayer()
71
+ player?.pause()
72
+ if let oldItem = player?.currentItem {
73
+ oldItem.removeObserver(self, forKeyPath: "presentationSize")
74
+ }
75
+
76
+ let asset = AVAsset(url: url)
77
+ let playerItem = AVPlayerItem(asset: asset)
78
+
79
+ playerItem.addObserver(self, forKeyPath: "status", options: [.old, .new], context: nil)
80
+
81
+ player = AVPlayer(playerItem: playerItem)
82
+ player?.isMuted = false
83
+
84
+ playerLayer = AVPlayerLayer(player: player)
85
+ playerLayer?.frame = bounds
86
+ playerLayer?.videoGravity = .resizeAspect
87
+
88
+ if let playerLayer = playerLayer {
89
+ layer.addSublayer(playerLayer)
90
+ }
91
+
92
+ // Observe timeControlStatus to detect when video actually starts playing
93
+ timeControlStatusObserver = player?.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
94
+ if player.timeControlStatus == .playing {
95
+ DispatchQueue.main.async {
96
+ self?.onVideoStartedPlaying?()
97
+ }
98
+ }
99
+ }
100
+
101
+ // Loop
102
+ NotificationCenter.default.addObserver(
103
+ forName: .AVPlayerItemDidPlayToEndTime,
104
+ object: playerItem,
105
+ queue: .main
106
+ ) { [weak self] _ in
107
+ self?.player?.seek(to: .zero)
108
+ self?.player?.play()
109
+ }
110
+
111
+ if isVisible {
112
+ player?.play()
113
+ }
114
+ }
115
+
116
+ func reset() {
117
+ timeControlStatusObserver?.invalidate()
118
+ timeControlStatusObserver = nil
119
+ player?.pause()
120
+ player = nil
121
+ playerLayer?.removeFromSuperlayer()
122
+ playerLayer = nil
123
+ onVideoStartedPlaying = nil
124
+ }
125
+
126
+ override func layoutSubviews() {
127
+ super.layoutSubviews()
128
+ playerLayer?.frame = bounds
129
+ }
130
+
131
+ deinit {
132
+ reset()
133
+ NotificationCenter.default.removeObserver(self)
134
+ }
135
+
136
+ // MARK: - KVO Observer
137
+ override func observeValue(forKeyPath keyPath: String?, of object: Any?,
138
+ change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
139
+ if keyPath == "status" {
140
+ if let item = object as? AVPlayerItem {
141
+ switch item.status {
142
+ case .readyToPlay:
143
+ print("✅ FeedPlayer - Ready to play")
144
+ case .failed:
145
+ if let error = item.error {
146
+ print("❌ FeedPlayer - Player item failed: \(error.localizedDescription)")
147
+
148
+ // Check for specific HTTP errors (404, etc.)
149
+ if let nsError = error as NSError? {
150
+ if nsError.domain == NSURLErrorDomain {
151
+ switch nsError.code {
152
+ case NSURLErrorFileDoesNotExist, NSURLErrorCannotFindHost:
153
+ print("🚨 FeedPlayer - 404 Error: Video file not found")
154
+ case NSURLErrorNotConnectedToInternet:
155
+ print("🚨 FeedPlayer - Network Error: No internet connection")
156
+ case NSURLErrorTimedOut:
157
+ print("🚨 FeedPlayer - Timeout Error: Request timed out")
158
+ case NSURLErrorCannotConnectToHost:
159
+ print("🚨 FeedPlayer - Connection Error: Cannot connect to host")
160
+ default:
161
+ print("🚨 FeedPlayer - Network Error: \(nsError.localizedDescription)")
162
+ }
163
+ }
164
+ }
165
+ }
166
+ case .unknown:
167
+ print("⚠️ FeedPlayer - Unknown status")
168
+ @unknown default:
169
+ break
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }