@shortkitsdk/react-native 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/ShortKitReactNative.podspec +19 -0
- package/android/build.gradle.kts +34 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +249 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +32 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +769 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +101 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitPackage.kt +40 -0
- package/app.plugin.js +1 -0
- package/ios/ShortKitBridge.swift +537 -0
- package/ios/ShortKitFeedView.swift +207 -0
- package/ios/ShortKitFeedViewManager.mm +29 -0
- package/ios/ShortKitModule.h +25 -0
- package/ios/ShortKitModule.mm +204 -0
- package/ios/ShortKitOverlayBridge.swift +91 -0
- package/ios/ShortKitReactNative-Bridging-Header.h +3 -0
- package/ios/ShortKitReactNative.podspec +19 -0
- package/package.json +50 -0
- package/plugin/build/index.d.ts +3 -0
- package/plugin/build/index.js +13 -0
- package/plugin/build/withShortKitAndroid.d.ts +8 -0
- package/plugin/build/withShortKitAndroid.js +32 -0
- package/plugin/build/withShortKitIOS.d.ts +8 -0
- package/plugin/build/withShortKitIOS.js +29 -0
- package/react-native.config.js +8 -0
- package/src/OverlayManager.tsx +87 -0
- package/src/ShortKitContext.ts +51 -0
- package/src/ShortKitFeed.tsx +203 -0
- package/src/ShortKitProvider.tsx +526 -0
- package/src/index.ts +26 -0
- package/src/serialization.ts +95 -0
- package/src/specs/NativeShortKitModule.ts +201 -0
- package/src/specs/ShortKitFeedViewNativeComponent.ts +13 -0
- package/src/types.ts +167 -0
- package/src/useShortKit.ts +20 -0
- package/src/useShortKitPlayer.ts +29 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
package com.shortkit.reactnative
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Color
|
|
5
|
+
import android.view.GestureDetector
|
|
6
|
+
import android.view.MotionEvent
|
|
7
|
+
import android.widget.FrameLayout
|
|
8
|
+
import com.facebook.react.bridge.Arguments
|
|
9
|
+
import com.shortkit.ContentItem
|
|
10
|
+
import com.shortkit.FeedOverlay
|
|
11
|
+
import com.shortkit.ShortKitPlayer
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A transparent [FrameLayout] that implements [FeedOverlay] and bridges
|
|
15
|
+
* overlay lifecycle calls to JS events via [ShortKitModule].
|
|
16
|
+
*
|
|
17
|
+
* The actual overlay UI is rendered by React Native on the JS side through
|
|
18
|
+
* the `OverlayManager` component. This view simply relays the SDK lifecycle
|
|
19
|
+
* events so the JS overlay knows when to configure, activate, reset, etc.
|
|
20
|
+
*
|
|
21
|
+
* Android equivalent of iOS `ShortKitOverlayBridge.swift`.
|
|
22
|
+
*/
|
|
23
|
+
class ShortKitOverlayBridge(context: Context) : FrameLayout(context), FeedOverlay {
|
|
24
|
+
|
|
25
|
+
// -----------------------------------------------------------------------
|
|
26
|
+
// State
|
|
27
|
+
// -----------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stores the last configured [ContentItem] so we can pass it with
|
|
31
|
+
* lifecycle events that don't receive the item as a parameter.
|
|
32
|
+
*/
|
|
33
|
+
private var currentItem: ContentItem? = null
|
|
34
|
+
|
|
35
|
+
// -----------------------------------------------------------------------
|
|
36
|
+
// Gestures
|
|
37
|
+
// -----------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
|
40
|
+
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
|
41
|
+
ShortKitModule.shared?.emitOverlayEvent("onOverlayTap", Arguments.createMap())
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override fun onDoubleTap(e: MotionEvent): Boolean {
|
|
46
|
+
val params = Arguments.createMap().apply {
|
|
47
|
+
putDouble("x", e.x.toDouble())
|
|
48
|
+
putDouble("y", e.y.toDouble())
|
|
49
|
+
}
|
|
50
|
+
ShortKitModule.shared?.emitOverlayEvent("onOverlayDoubleTap", params)
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// -----------------------------------------------------------------------
|
|
56
|
+
// Init
|
|
57
|
+
// -----------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
init {
|
|
60
|
+
setBackgroundColor(Color.TRANSPARENT)
|
|
61
|
+
isClickable = true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
65
|
+
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// -----------------------------------------------------------------------
|
|
69
|
+
// FeedOverlay
|
|
70
|
+
// -----------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
override fun attach(player: ShortKitPlayer) {
|
|
73
|
+
// No-op on the native side. The JS overlay subscribes to player
|
|
74
|
+
// state via the TurboModule's flow publishers.
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
override fun configure(item: ContentItem) {
|
|
78
|
+
currentItem = item
|
|
79
|
+
ShortKitModule.shared?.emitOverlayEvent("onOverlayConfigure", item)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override fun resetPlaybackProgress() {
|
|
83
|
+
val item = currentItem ?: return
|
|
84
|
+
ShortKitModule.shared?.emitOverlayEvent("onOverlayReset", item)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override fun activatePlayback() {
|
|
88
|
+
val item = currentItem ?: return
|
|
89
|
+
ShortKitModule.shared?.emitOverlayEvent("onOverlayActivate", item)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
override fun fadeOutForTransition() {
|
|
93
|
+
val item = currentItem ?: return
|
|
94
|
+
ShortKitModule.shared?.emitOverlayEvent("onOverlayFadeOut", item)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
override fun restoreFromTransition() {
|
|
98
|
+
val item = currentItem ?: return
|
|
99
|
+
ShortKitModule.shared?.emitOverlayEvent("onOverlayRestore", item)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
package com.shortkit.reactnative
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.TurboReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
import com.facebook.react.uimanager.ViewManager
|
|
9
|
+
|
|
10
|
+
class ShortKitPackage : TurboReactPackage() {
|
|
11
|
+
|
|
12
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
13
|
+
return if (name == ShortKitModule.NAME) {
|
|
14
|
+
ShortKitModule(reactContext)
|
|
15
|
+
} else {
|
|
16
|
+
null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
|
|
21
|
+
return ReactModuleInfoProvider {
|
|
22
|
+
mapOf(
|
|
23
|
+
ShortKitModule.NAME to ReactModuleInfo(
|
|
24
|
+
ShortKitModule.NAME, // name
|
|
25
|
+
ShortKitModule.NAME, // className
|
|
26
|
+
false, // canOverrideExistingModule
|
|
27
|
+
false, // needsEagerInit
|
|
28
|
+
false, // isCxxModule
|
|
29
|
+
true // isTurboModule
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
override fun createViewManagers(
|
|
36
|
+
reactContext: ReactApplicationContext
|
|
37
|
+
): List<ViewManager<*, *>> {
|
|
38
|
+
return listOf(ShortKitFeedViewManager())
|
|
39
|
+
}
|
|
40
|
+
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('./plugin/build');
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Combine
|
|
3
|
+
import ShortKit
|
|
4
|
+
|
|
5
|
+
/// Swift bridge between the ShortKit SDK and the Obj-C++ TurboModule.
|
|
6
|
+
///
|
|
7
|
+
/// Holds the `ShortKit` instance, subscribes to all Combine publishers on
|
|
8
|
+
/// `ShortKitPlayer`, and forwards events to JS via the delegate protocol.
|
|
9
|
+
@objc public class ShortKitBridge: NSObject, ShortKitDelegate {
|
|
10
|
+
|
|
11
|
+
// MARK: - Shared instance (accessed by Fabric view manager in Task 13)
|
|
12
|
+
|
|
13
|
+
@objc public static var shared: ShortKitBridge?
|
|
14
|
+
|
|
15
|
+
// MARK: - Properties
|
|
16
|
+
|
|
17
|
+
private var shortKit: ShortKit?
|
|
18
|
+
private var cancellables = Set<AnyCancellable>()
|
|
19
|
+
private weak var delegate: ShortKitBridgeDelegateProtocol?
|
|
20
|
+
|
|
21
|
+
// MARK: - Init
|
|
22
|
+
|
|
23
|
+
@objc public init(
|
|
24
|
+
apiKey: String,
|
|
25
|
+
config configJSON: String,
|
|
26
|
+
clientAppName: String?,
|
|
27
|
+
clientAppVersion: String?,
|
|
28
|
+
customDimensions customDimensionsJSON: String?,
|
|
29
|
+
delegate: ShortKitBridgeDelegateProtocol
|
|
30
|
+
) {
|
|
31
|
+
self.delegate = delegate
|
|
32
|
+
super.init()
|
|
33
|
+
|
|
34
|
+
let feedConfig = Self.parseFeedConfig(configJSON)
|
|
35
|
+
let dimensions = Self.parseCustomDimensions(customDimensionsJSON)
|
|
36
|
+
|
|
37
|
+
let sdk = ShortKit(
|
|
38
|
+
apiKey: apiKey,
|
|
39
|
+
config: feedConfig,
|
|
40
|
+
clientAppName: clientAppName,
|
|
41
|
+
clientAppVersion: clientAppVersion,
|
|
42
|
+
customDimensions: dimensions
|
|
43
|
+
)
|
|
44
|
+
sdk.delegate = self
|
|
45
|
+
self.shortKit = sdk
|
|
46
|
+
|
|
47
|
+
ShortKitBridge.shared = self
|
|
48
|
+
|
|
49
|
+
subscribeToPublishers(sdk.player)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// MARK: - Teardown
|
|
53
|
+
|
|
54
|
+
@objc public func teardown() {
|
|
55
|
+
cancellables.removeAll()
|
|
56
|
+
shortKit = nil
|
|
57
|
+
if ShortKitBridge.shared === self {
|
|
58
|
+
ShortKitBridge.shared = nil
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: - Public accessors for Fabric view
|
|
63
|
+
|
|
64
|
+
/// The underlying ShortKit instance. Used by the Fabric view manager.
|
|
65
|
+
public var sdk: ShortKit? { shortKit }
|
|
66
|
+
|
|
67
|
+
// MARK: - Lifecycle
|
|
68
|
+
|
|
69
|
+
@objc public func setUserId(_ userId: String) {
|
|
70
|
+
shortKit?.setUserId(userId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@objc public func clearUserId() {
|
|
74
|
+
shortKit?.clearUserId()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Called when the app backgrounds. The ShortKit SDK handles app lifecycle
|
|
78
|
+
/// internally (AudioSessionManager, PlayerManager), so we only pause here
|
|
79
|
+
/// as a safety net. The SDK's internal handling will also fire.
|
|
80
|
+
@objc public func onPause() {
|
|
81
|
+
shortKit?.player.pause()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Called when the app foregrounds. We do NOT auto-play here because:
|
|
85
|
+
/// 1. The user may have manually paused before backgrounding.
|
|
86
|
+
/// 2. The ShortKit SDK's internal lifecycle management already resumes
|
|
87
|
+
/// playback when appropriate.
|
|
88
|
+
@objc public func onResume() {
|
|
89
|
+
// No-op: let the SDK's internal lifecycle handle resume
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// MARK: - Player Commands
|
|
93
|
+
|
|
94
|
+
@objc public func play() {
|
|
95
|
+
shortKit?.player.play()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Named `doPause` to avoid collision with NSObject.
|
|
99
|
+
@objc public func doPause() {
|
|
100
|
+
shortKit?.player.pause()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@objc public func seekTo(_ seconds: Double) {
|
|
104
|
+
shortKit?.player.seek(to: seconds)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@objc public func seekAndPlayTo(_ seconds: Double) {
|
|
108
|
+
shortKit?.player.seekAndPlay(to: seconds)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@objc public func skipToNext() {
|
|
112
|
+
shortKit?.player.skipToNext()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@objc public func skipToPrevious() {
|
|
116
|
+
shortKit?.player.skipToPrevious()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@objc public func setMuted(_ muted: Bool) {
|
|
120
|
+
shortKit?.player.setMuted(muted)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@objc public func setPlaybackRate(_ rate: Double) {
|
|
124
|
+
shortKit?.player.setPlaybackRate(Float(rate))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@objc public func setCaptionsEnabled(_ enabled: Bool) {
|
|
128
|
+
shortKit?.player.setCaptionsEnabled(enabled)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@objc public func selectCaptionTrack(_ language: String) {
|
|
132
|
+
shortKit?.player.selectCaptionTrack(language: language)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@objc public func sendContentSignal(_ signal: String) {
|
|
136
|
+
let contentSignal: ContentSignal = signal == "positive" ? .positive : .negative
|
|
137
|
+
shortKit?.player.sendContentSignal(contentSignal)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@objc public func setMaxBitrate(_ bitrate: Double) {
|
|
141
|
+
shortKit?.player.setMaxBitrate(Int(bitrate))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// MARK: - ShortKitDelegate
|
|
145
|
+
|
|
146
|
+
public func shortKit(_ shortKit: ShortKit, didEncounterError error: ShortKitError) {
|
|
147
|
+
let body: [String: Any]
|
|
148
|
+
switch error {
|
|
149
|
+
case .networkError(let underlying):
|
|
150
|
+
body = ["code": "network_error", "message": underlying.localizedDescription]
|
|
151
|
+
case .playbackError(let code, let message):
|
|
152
|
+
body = ["code": code, "message": message]
|
|
153
|
+
case .authError:
|
|
154
|
+
body = ["code": "auth_error", "message": "Invalid API key"]
|
|
155
|
+
}
|
|
156
|
+
emitOnMain("onError", body: body)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
public func shortKit(_ shortKit: ShortKit, didTapShareFor item: ContentItem) {
|
|
160
|
+
emitOnMain("onShareTapped", body: ["item": serializeContentItemToJSON(item)])
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
public func shortKit(_ shortKit: ShortKit, didRespondToSurvey surveyId: String, with option: SurveyOption) {
|
|
164
|
+
emitOnMain("onSurveyResponse", body: [
|
|
165
|
+
"surveyId": surveyId,
|
|
166
|
+
"optionId": option.id,
|
|
167
|
+
"optionText": option.text
|
|
168
|
+
])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// MARK: - Combine Subscriptions
|
|
172
|
+
|
|
173
|
+
private func subscribeToPublishers(_ player: ShortKitPlayer) {
|
|
174
|
+
// Player state
|
|
175
|
+
player.playerState
|
|
176
|
+
.receive(on: DispatchQueue.main)
|
|
177
|
+
.sink { [weak self] state in
|
|
178
|
+
var body: [String: Any] = ["state": Self.playerStateString(state)]
|
|
179
|
+
if case .error(let msg) = state {
|
|
180
|
+
body["errorMessage"] = msg
|
|
181
|
+
}
|
|
182
|
+
self?.emit("onPlayerStateChanged", body: body)
|
|
183
|
+
}
|
|
184
|
+
.store(in: &cancellables)
|
|
185
|
+
|
|
186
|
+
// Current item
|
|
187
|
+
player.currentItem
|
|
188
|
+
.receive(on: DispatchQueue.main)
|
|
189
|
+
.sink { [weak self] item in
|
|
190
|
+
guard let item else { return }
|
|
191
|
+
self?.emit("onCurrentItemChanged", body: Self.contentItemDict(item))
|
|
192
|
+
}
|
|
193
|
+
.store(in: &cancellables)
|
|
194
|
+
|
|
195
|
+
// Time updates
|
|
196
|
+
player.time
|
|
197
|
+
.receive(on: DispatchQueue.main)
|
|
198
|
+
.sink { [weak self] time in
|
|
199
|
+
self?.emit("onTimeUpdate", body: [
|
|
200
|
+
"current": time.current,
|
|
201
|
+
"duration": time.duration,
|
|
202
|
+
"buffered": time.buffered
|
|
203
|
+
])
|
|
204
|
+
}
|
|
205
|
+
.store(in: &cancellables)
|
|
206
|
+
|
|
207
|
+
// Muted state
|
|
208
|
+
player.isMuted
|
|
209
|
+
.receive(on: DispatchQueue.main)
|
|
210
|
+
.sink { [weak self] muted in
|
|
211
|
+
self?.emit("onMutedChanged", body: ["isMuted": muted])
|
|
212
|
+
}
|
|
213
|
+
.store(in: &cancellables)
|
|
214
|
+
|
|
215
|
+
// Playback rate
|
|
216
|
+
player.playbackRate
|
|
217
|
+
.receive(on: DispatchQueue.main)
|
|
218
|
+
.sink { [weak self] rate in
|
|
219
|
+
self?.emit("onPlaybackRateChanged", body: ["rate": Double(rate)])
|
|
220
|
+
}
|
|
221
|
+
.store(in: &cancellables)
|
|
222
|
+
|
|
223
|
+
// Captions enabled
|
|
224
|
+
player.captionsEnabled
|
|
225
|
+
.receive(on: DispatchQueue.main)
|
|
226
|
+
.sink { [weak self] enabled in
|
|
227
|
+
self?.emit("onCaptionsEnabledChanged", body: ["enabled": enabled])
|
|
228
|
+
}
|
|
229
|
+
.store(in: &cancellables)
|
|
230
|
+
|
|
231
|
+
// Active caption track
|
|
232
|
+
player.activeCaptionTrack
|
|
233
|
+
.receive(on: DispatchQueue.main)
|
|
234
|
+
.sink { [weak self] track in
|
|
235
|
+
guard let track else { return }
|
|
236
|
+
self?.emit("onActiveCaptionTrackChanged", body: [
|
|
237
|
+
"language": track.language,
|
|
238
|
+
"label": track.label,
|
|
239
|
+
"sourceUrl": track.url ?? ""
|
|
240
|
+
])
|
|
241
|
+
}
|
|
242
|
+
.store(in: &cancellables)
|
|
243
|
+
|
|
244
|
+
// Active cue
|
|
245
|
+
player.activeCue
|
|
246
|
+
.receive(on: DispatchQueue.main)
|
|
247
|
+
.sink { [weak self] cue in
|
|
248
|
+
guard let cue else { return }
|
|
249
|
+
self?.emit("onActiveCueChanged", body: [
|
|
250
|
+
"text": cue.text,
|
|
251
|
+
"startTime": cue.startTime,
|
|
252
|
+
"endTime": cue.endTime
|
|
253
|
+
])
|
|
254
|
+
}
|
|
255
|
+
.store(in: &cancellables)
|
|
256
|
+
|
|
257
|
+
// Did loop
|
|
258
|
+
player.didLoop
|
|
259
|
+
.receive(on: DispatchQueue.main)
|
|
260
|
+
.sink { [weak self] event in
|
|
261
|
+
self?.emit("onDidLoop", body: [
|
|
262
|
+
"contentId": event.contentId,
|
|
263
|
+
"loopCount": event.loopCount
|
|
264
|
+
])
|
|
265
|
+
}
|
|
266
|
+
.store(in: &cancellables)
|
|
267
|
+
|
|
268
|
+
// Feed transition
|
|
269
|
+
player.feedTransition
|
|
270
|
+
.receive(on: DispatchQueue.main)
|
|
271
|
+
.sink { [weak self] event in
|
|
272
|
+
guard let self else { return }
|
|
273
|
+
var body: [String: Any] = [
|
|
274
|
+
"phase": Self.transitionPhaseString(event.phase),
|
|
275
|
+
"direction": Self.transitionDirectionString(event.direction)
|
|
276
|
+
]
|
|
277
|
+
if let from = event.from {
|
|
278
|
+
body["fromItem"] = self.serializeContentItemToJSON(from)
|
|
279
|
+
}
|
|
280
|
+
if let to = event.to {
|
|
281
|
+
body["toItem"] = self.serializeContentItemToJSON(to)
|
|
282
|
+
}
|
|
283
|
+
self.emit("onFeedTransition", body: body)
|
|
284
|
+
}
|
|
285
|
+
.store(in: &cancellables)
|
|
286
|
+
|
|
287
|
+
// Format change
|
|
288
|
+
player.formatChange
|
|
289
|
+
.receive(on: DispatchQueue.main)
|
|
290
|
+
.sink { [weak self] event in
|
|
291
|
+
self?.emit("onFormatChange", body: [
|
|
292
|
+
"contentId": event.contentId,
|
|
293
|
+
"fromBitrate": Double(event.fromBitrate),
|
|
294
|
+
"toBitrate": Double(event.toBitrate),
|
|
295
|
+
"fromResolution": event.fromResolution,
|
|
296
|
+
"toResolution": event.toResolution
|
|
297
|
+
])
|
|
298
|
+
}
|
|
299
|
+
.store(in: &cancellables)
|
|
300
|
+
|
|
301
|
+
// Prefetched ahead count
|
|
302
|
+
player.prefetchedAheadCount
|
|
303
|
+
.receive(on: DispatchQueue.main)
|
|
304
|
+
.sink { [weak self] count in
|
|
305
|
+
self?.emit("onPrefetchedAheadCountChanged", body: ["count": count])
|
|
306
|
+
}
|
|
307
|
+
.store(in: &cancellables)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// MARK: - Event Emission Helpers
|
|
311
|
+
|
|
312
|
+
private func emit(_ name: String, body: [String: Any]) {
|
|
313
|
+
delegate?.emitEvent(name, body: body)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private func emitOnMain(_ name: String, body: [String: Any]) {
|
|
317
|
+
if Thread.isMainThread {
|
|
318
|
+
emit(name, body: body)
|
|
319
|
+
} else {
|
|
320
|
+
DispatchQueue.main.async { [weak self] in
|
|
321
|
+
self?.emit(name, body: body)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// MARK: - Overlay Lifecycle Events (called by Fabric view in Task 13)
|
|
327
|
+
|
|
328
|
+
/// Emit overlay lifecycle events from the Fabric view's overlay container.
|
|
329
|
+
public func emitOverlayEvent(_ name: String, item: ContentItem) {
|
|
330
|
+
emitOnMain(name, body: ["item": serializeContentItemToJSON(item)])
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// Emit a raw overlay event with an arbitrary body.
|
|
334
|
+
public func emitOverlayEvent(_ name: String, body: [String: Any]) {
|
|
335
|
+
emitOnMain(name, body: body)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/// Emit an article tap event.
|
|
339
|
+
public func emitArticleTapped(_ item: ContentItem) {
|
|
340
|
+
emitOnMain("onArticleTapped", body: ["item": serializeContentItemToJSON(item)])
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// Emit a comment tap event.
|
|
344
|
+
public func emitCommentTapped(_ item: ContentItem) {
|
|
345
|
+
emitOnMain("onCommentTapped", body: ["item": serializeContentItemToJSON(item)])
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// Emit an overlay share tap event.
|
|
349
|
+
public func emitOverlayShareTapped(_ item: ContentItem) {
|
|
350
|
+
emitOnMain("onOverlayShareTapped", body: ["item": serializeContentItemToJSON(item)])
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/// Emit a save tap event.
|
|
354
|
+
public func emitSaveTapped(_ item: ContentItem) {
|
|
355
|
+
emitOnMain("onSaveTapped", body: ["item": serializeContentItemToJSON(item)])
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// Emit a like tap event.
|
|
359
|
+
public func emitLikeTapped(_ item: ContentItem) {
|
|
360
|
+
emitOnMain("onLikeTapped", body: ["item": serializeContentItemToJSON(item)])
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// MARK: - Content Item Serialization
|
|
364
|
+
|
|
365
|
+
/// Serialize a ContentItem to a JSON string for bridge transport.
|
|
366
|
+
private func serializeContentItemToJSON(_ item: ContentItem) -> String {
|
|
367
|
+
guard let data = try? JSONEncoder().encode(item),
|
|
368
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
369
|
+
return "{}"
|
|
370
|
+
}
|
|
371
|
+
return json
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/// Build an NSDictionary from a ContentItem with fields matching the JS spec.
|
|
375
|
+
/// `captionTracks` and `customMetadata` are JSON-serialized strings.
|
|
376
|
+
private static func contentItemDict(_ item: ContentItem) -> [String: Any] {
|
|
377
|
+
var dict: [String: Any] = [
|
|
378
|
+
"id": item.id,
|
|
379
|
+
"title": item.title,
|
|
380
|
+
"duration": item.duration,
|
|
381
|
+
"streamingUrl": item.streamingUrl,
|
|
382
|
+
"thumbnailUrl": item.thumbnailUrl,
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
if let description = item.description {
|
|
386
|
+
dict["description"] = description
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Caption tracks as JSON string
|
|
390
|
+
if let tracksData = try? JSONEncoder().encode(item.captionTracks),
|
|
391
|
+
let tracksJSON = String(data: tracksData, encoding: .utf8) {
|
|
392
|
+
dict["captionTracks"] = tracksJSON
|
|
393
|
+
} else {
|
|
394
|
+
dict["captionTracks"] = "[]"
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Custom metadata as JSON string
|
|
398
|
+
if let meta = item.customMetadata,
|
|
399
|
+
let metaData = try? JSONEncoder().encode(meta),
|
|
400
|
+
let metaJSON = String(data: metaData, encoding: .utf8) {
|
|
401
|
+
dict["customMetadata"] = metaJSON
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if let author = item.author {
|
|
405
|
+
dict["author"] = author
|
|
406
|
+
}
|
|
407
|
+
if let articleUrl = item.articleUrl {
|
|
408
|
+
dict["articleUrl"] = articleUrl
|
|
409
|
+
}
|
|
410
|
+
if let commentCount = item.commentCount {
|
|
411
|
+
dict["commentCount"] = commentCount
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return dict
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// MARK: - Player State Serialization
|
|
418
|
+
|
|
419
|
+
private static func playerStateString(_ state: PlayerState) -> String {
|
|
420
|
+
switch state {
|
|
421
|
+
case .idle: return "idle"
|
|
422
|
+
case .loading: return "loading"
|
|
423
|
+
case .ready: return "ready"
|
|
424
|
+
case .playing: return "playing"
|
|
425
|
+
case .paused: return "paused"
|
|
426
|
+
case .seeking: return "seeking"
|
|
427
|
+
case .buffering: return "buffering"
|
|
428
|
+
case .ended: return "ended"
|
|
429
|
+
case .error: return "error"
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private static func transitionPhaseString(_ phase: FeedTransitionEvent.Phase) -> String {
|
|
434
|
+
switch phase {
|
|
435
|
+
case .began: return "began"
|
|
436
|
+
case .ended: return "ended"
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private static func transitionDirectionString(_ direction: FeedTransitionEvent.Direction) -> String {
|
|
441
|
+
switch direction {
|
|
442
|
+
case .forward: return "forward"
|
|
443
|
+
case .backward: return "backward"
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// MARK: - Config Parsing
|
|
448
|
+
|
|
449
|
+
/// Parse the JSON config string from JS into a FeedConfig.
|
|
450
|
+
///
|
|
451
|
+
/// Expected JSON structure:
|
|
452
|
+
/// ```json
|
|
453
|
+
/// {"feedHeight":"{\"type\":\"fullscreen\"}","overlay":"\"none\"",
|
|
454
|
+
/// "carouselMode":"\"none\"","surveyMode":"\"none\"","muteOnStart":true}
|
|
455
|
+
/// ```
|
|
456
|
+
private static func parseFeedConfig(_ json: String) -> FeedConfig {
|
|
457
|
+
guard let data = json.data(using: .utf8),
|
|
458
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
459
|
+
return FeedConfig()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let feedHeight = parseFeedHeight(obj["feedHeight"] as? String)
|
|
463
|
+
let muteOnStart = obj["muteOnStart"] as? Bool ?? true
|
|
464
|
+
let videoOverlay = parseVideoOverlay(obj["overlay"] as? String)
|
|
465
|
+
|
|
466
|
+
return FeedConfig(
|
|
467
|
+
feedHeight: feedHeight,
|
|
468
|
+
videoOverlay: videoOverlay,
|
|
469
|
+
carouselOverlay: .none,
|
|
470
|
+
surveyOverlay: .none,
|
|
471
|
+
adOverlay: .none,
|
|
472
|
+
muteOnStart: muteOnStart
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/// Parse a double-stringified overlay JSON into a `VideoOverlayMode`.
|
|
477
|
+
///
|
|
478
|
+
/// Examples:
|
|
479
|
+
/// - `"\"none\""` → `.none`
|
|
480
|
+
/// - `"{\"type\":\"custom\"}"` → `.custom { ShortKitOverlayBridge() }`
|
|
481
|
+
private static func parseVideoOverlay(_ json: String?) -> VideoOverlayMode {
|
|
482
|
+
guard let json,
|
|
483
|
+
let data = json.data(using: .utf8),
|
|
484
|
+
let parsed = try? JSONSerialization.jsonObject(with: data) else {
|
|
485
|
+
return .none
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Case 1: Simple string "none"
|
|
489
|
+
if let str = parsed as? String, str == "none" {
|
|
490
|
+
return .none
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Case 2: Object with "type" key
|
|
494
|
+
if let obj = parsed as? [String: Any],
|
|
495
|
+
let type = obj["type"] as? String,
|
|
496
|
+
type == "custom" {
|
|
497
|
+
return .custom { @Sendable in
|
|
498
|
+
let overlay = ShortKitOverlayBridge()
|
|
499
|
+
overlay.bridge = ShortKitBridge.shared
|
|
500
|
+
return overlay
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return .none
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/// Parse a double-stringified feedHeight JSON.
|
|
508
|
+
/// e.g. `"{\"type\":\"fullscreen\"}"` or `"{\"type\":\"percentage\",\"value\":0.8}"`
|
|
509
|
+
private static func parseFeedHeight(_ json: String?) -> FeedHeight {
|
|
510
|
+
guard let json,
|
|
511
|
+
let data = json.data(using: .utf8),
|
|
512
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
513
|
+
let type = obj["type"] as? String else {
|
|
514
|
+
return .fullscreen
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
switch type {
|
|
518
|
+
case "percentage":
|
|
519
|
+
if let value = obj["value"] as? Double {
|
|
520
|
+
return .percentage(CGFloat(value))
|
|
521
|
+
}
|
|
522
|
+
return .fullscreen
|
|
523
|
+
default:
|
|
524
|
+
return .fullscreen
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/// Parse optional custom dimensions JSON string into dictionary.
|
|
529
|
+
private static func parseCustomDimensions(_ json: String?) -> [String: String]? {
|
|
530
|
+
guard let json,
|
|
531
|
+
let data = json.data(using: .utf8),
|
|
532
|
+
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
|
533
|
+
return nil
|
|
534
|
+
}
|
|
535
|
+
return dict
|
|
536
|
+
}
|
|
537
|
+
}
|