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,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
|
+
}
|
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
|
+
}
|