@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,769 @@
|
|
|
1
|
+
package com.shortkit.reactnative
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
5
|
+
import com.facebook.react.bridge.ReactMethod
|
|
6
|
+
import com.facebook.react.bridge.WritableMap
|
|
7
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
8
|
+
import com.shortkit.ContentItem
|
|
9
|
+
import com.shortkit.ContentSignal
|
|
10
|
+
import com.shortkit.FeedConfig
|
|
11
|
+
import com.shortkit.FeedHeight
|
|
12
|
+
import com.shortkit.FeedTransitionPhase
|
|
13
|
+
import com.shortkit.JsonValue
|
|
14
|
+
import com.shortkit.OverlayActionDelegate
|
|
15
|
+
import com.shortkit.ShortKit
|
|
16
|
+
import com.shortkit.ShortKitDelegate
|
|
17
|
+
import com.shortkit.ShortKitError
|
|
18
|
+
import com.shortkit.ShortKitPlayer
|
|
19
|
+
import com.shortkit.SurveyOption
|
|
20
|
+
import com.shortkit.VideoOverlayMode
|
|
21
|
+
import com.shortkit.CarouselOverlayMode
|
|
22
|
+
import com.shortkit.SurveyOverlayMode
|
|
23
|
+
import com.shortkit.AdOverlayMode
|
|
24
|
+
import kotlinx.coroutines.CoroutineScope
|
|
25
|
+
import kotlinx.coroutines.Dispatchers
|
|
26
|
+
import kotlinx.coroutines.SupervisorJob
|
|
27
|
+
import kotlinx.coroutines.cancel
|
|
28
|
+
import kotlinx.coroutines.launch
|
|
29
|
+
import org.json.JSONArray
|
|
30
|
+
import org.json.JSONObject
|
|
31
|
+
|
|
32
|
+
class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
33
|
+
NativeShortKitModuleSpec(reactContext),
|
|
34
|
+
ShortKitDelegate {
|
|
35
|
+
|
|
36
|
+
companion object {
|
|
37
|
+
const val NAME = "ShortKitModule"
|
|
38
|
+
|
|
39
|
+
/** Static reference for Fabric view access (mirrors iOS ShortKitBridge.shared). */
|
|
40
|
+
@Volatile
|
|
41
|
+
var shared: ShortKitModule? = null
|
|
42
|
+
private set
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
// State
|
|
47
|
+
// -----------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
private var shortKit: ShortKit? = null
|
|
50
|
+
private var scope: CoroutineScope? = null
|
|
51
|
+
private var listenerCount = 0
|
|
52
|
+
@Volatile
|
|
53
|
+
private var hasListeners = false
|
|
54
|
+
|
|
55
|
+
/** Expose the underlying SDK for the Fabric feed view manager. */
|
|
56
|
+
val sdk: ShortKit? get() = shortKit
|
|
57
|
+
|
|
58
|
+
// -----------------------------------------------------------------------
|
|
59
|
+
// Module boilerplate
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
override fun getName(): String = NAME
|
|
63
|
+
|
|
64
|
+
override fun initialize() {
|
|
65
|
+
super.initialize()
|
|
66
|
+
shared = this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override fun onCatalystInstanceDestroy() {
|
|
70
|
+
teardown()
|
|
71
|
+
if (shared === this) shared = null
|
|
72
|
+
super.onCatalystInstanceDestroy()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// -----------------------------------------------------------------------
|
|
76
|
+
// Event listeners
|
|
77
|
+
// -----------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
override fun addListener(eventType: String?) {
|
|
80
|
+
listenerCount++
|
|
81
|
+
hasListeners = true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override fun removeListeners(count: Double) {
|
|
85
|
+
listenerCount = maxOf(0, listenerCount - count.toInt())
|
|
86
|
+
hasListeners = listenerCount > 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// -----------------------------------------------------------------------
|
|
90
|
+
// Lifecycle methods
|
|
91
|
+
// -----------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
@ReactMethod
|
|
94
|
+
override fun initialize(
|
|
95
|
+
apiKey: String,
|
|
96
|
+
config: String,
|
|
97
|
+
clientAppName: String?,
|
|
98
|
+
clientAppVersion: String?,
|
|
99
|
+
customDimensions: String?
|
|
100
|
+
) {
|
|
101
|
+
// Tear down any existing instance (re-init safety)
|
|
102
|
+
teardown()
|
|
103
|
+
|
|
104
|
+
val feedConfig = parseFeedConfig(config)
|
|
105
|
+
val dims = parseCustomDimensions(customDimensions)
|
|
106
|
+
|
|
107
|
+
val context = reactApplicationContext
|
|
108
|
+
|
|
109
|
+
val sdk = ShortKit(
|
|
110
|
+
context = context,
|
|
111
|
+
apiKey = apiKey,
|
|
112
|
+
config = feedConfig,
|
|
113
|
+
userId = null,
|
|
114
|
+
adProvider = null,
|
|
115
|
+
clientAppName = clientAppName,
|
|
116
|
+
clientAppVersion = clientAppVersion,
|
|
117
|
+
customDimensions = dims
|
|
118
|
+
)
|
|
119
|
+
sdk.delegate = this
|
|
120
|
+
sdk.overlayActionDelegate = overlayDelegate
|
|
121
|
+
|
|
122
|
+
this.shortKit = sdk
|
|
123
|
+
shared = this
|
|
124
|
+
|
|
125
|
+
subscribeToFlows(sdk.player)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@ReactMethod
|
|
129
|
+
override fun setUserId(userId: String) {
|
|
130
|
+
shortKit?.setUserId(userId)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@ReactMethod
|
|
134
|
+
override fun clearUserId() {
|
|
135
|
+
shortKit?.clearUserId()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@ReactMethod
|
|
139
|
+
override fun onPause() {
|
|
140
|
+
shortKit?.pause()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Called when the app foregrounds. We do NOT auto-resume here because:
|
|
144
|
+
/// 1. The user may have manually paused before backgrounding.
|
|
145
|
+
/// 2. The ShortKit SDK's internal lifecycle management already resumes
|
|
146
|
+
/// playback when appropriate via Activity lifecycle callbacks.
|
|
147
|
+
@ReactMethod
|
|
148
|
+
override fun onResume() {
|
|
149
|
+
// No-op: let the SDK's internal lifecycle handle resume
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@ReactMethod
|
|
153
|
+
override fun destroy() {
|
|
154
|
+
teardown()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// -----------------------------------------------------------------------
|
|
158
|
+
// Player controls
|
|
159
|
+
// -----------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
@ReactMethod
|
|
162
|
+
override fun play() {
|
|
163
|
+
shortKit?.player?.play()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@ReactMethod
|
|
167
|
+
override fun pause() {
|
|
168
|
+
shortKit?.player?.pause()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@ReactMethod
|
|
172
|
+
override fun seek(seconds: Double) {
|
|
173
|
+
shortKit?.player?.seek(seconds)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@ReactMethod
|
|
177
|
+
override fun seekAndPlay(seconds: Double) {
|
|
178
|
+
shortKit?.player?.seekAndPlay(seconds)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@ReactMethod
|
|
182
|
+
override fun skipToNext() {
|
|
183
|
+
shortKit?.player?.skipToNext()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@ReactMethod
|
|
187
|
+
override fun skipToPrevious() {
|
|
188
|
+
shortKit?.player?.skipToPrevious()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@ReactMethod
|
|
192
|
+
override fun setMuted(muted: Boolean) {
|
|
193
|
+
shortKit?.player?.setMuted(muted)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@ReactMethod
|
|
197
|
+
override fun setPlaybackRate(rate: Double) {
|
|
198
|
+
shortKit?.player?.setPlaybackRate(rate.toFloat())
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@ReactMethod
|
|
202
|
+
override fun setCaptionsEnabled(enabled: Boolean) {
|
|
203
|
+
shortKit?.player?.setCaptionsEnabled(enabled)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@ReactMethod
|
|
207
|
+
override fun selectCaptionTrack(language: String) {
|
|
208
|
+
shortKit?.player?.selectCaptionTrack(language)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@ReactMethod
|
|
212
|
+
override fun sendContentSignal(signal: String) {
|
|
213
|
+
val contentSignal = if (signal == "positive") ContentSignal.POSITIVE else ContentSignal.NEGATIVE
|
|
214
|
+
shortKit?.player?.sendContentSignal(contentSignal)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@ReactMethod
|
|
218
|
+
override fun setMaxBitrate(bitrate: Double) {
|
|
219
|
+
shortKit?.player?.setMaxBitrate(bitrate.toInt())
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// -----------------------------------------------------------------------
|
|
223
|
+
// ShortKitDelegate
|
|
224
|
+
// -----------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
override fun onError(error: ShortKitError) {
|
|
227
|
+
val params = Arguments.createMap().apply {
|
|
228
|
+
when (error) {
|
|
229
|
+
is ShortKitError.NetworkError -> {
|
|
230
|
+
putString("code", "network_error")
|
|
231
|
+
putString("message", error.cause?.localizedMessage ?: "Network error")
|
|
232
|
+
}
|
|
233
|
+
is ShortKitError.ApiError -> {
|
|
234
|
+
putString("code", "api_error")
|
|
235
|
+
putString("message", error.message ?: "API error")
|
|
236
|
+
}
|
|
237
|
+
is ShortKitError.PlayerError -> {
|
|
238
|
+
putString("code", error.code)
|
|
239
|
+
putString("message", error.message ?: "Player error")
|
|
240
|
+
}
|
|
241
|
+
is ShortKitError.ConfigError -> {
|
|
242
|
+
putString("code", "config_error")
|
|
243
|
+
putString("message", error.message ?: "Config error")
|
|
244
|
+
}
|
|
245
|
+
is ShortKitError.AuthError -> {
|
|
246
|
+
putString("code", "auth_error")
|
|
247
|
+
putString("message", "Invalid API key")
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
sendEvent("onError", params)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
override fun onShareTapped(item: ContentItem) {
|
|
255
|
+
val params = Arguments.createMap().apply {
|
|
256
|
+
putString("item", serializeContentItemToJSON(item))
|
|
257
|
+
}
|
|
258
|
+
sendEvent("onShareTapped", params)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
override fun onSurveyResponse(surveyId: String, option: SurveyOption) {
|
|
262
|
+
val params = Arguments.createMap().apply {
|
|
263
|
+
putString("surveyId", surveyId)
|
|
264
|
+
putString("optionId", option.id)
|
|
265
|
+
putString("optionText", option.text)
|
|
266
|
+
}
|
|
267
|
+
sendEvent("onSurveyResponse", params)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// We implement the optional delegate methods as no-ops; events are handled
|
|
271
|
+
// via StateFlow/SharedFlow subscriptions instead.
|
|
272
|
+
override fun onFeedReady() {}
|
|
273
|
+
override fun onContentChanged(item: ContentItem) {}
|
|
274
|
+
override fun onFeedEmpty() {}
|
|
275
|
+
|
|
276
|
+
// -----------------------------------------------------------------------
|
|
277
|
+
// OverlayActionDelegate (separate object to avoid signature clash with
|
|
278
|
+
// ShortKitDelegate.onShareTapped)
|
|
279
|
+
// -----------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
private val overlayDelegate = object : OverlayActionDelegate {
|
|
282
|
+
override fun onReadMoreTapped(item: ContentItem) {
|
|
283
|
+
val params = Arguments.createMap().apply {
|
|
284
|
+
putString("item", serializeContentItemToJSON(item))
|
|
285
|
+
}
|
|
286
|
+
sendEvent("onArticleTapped", params)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
override fun onShareTapped(item: ContentItem) {
|
|
290
|
+
val params = Arguments.createMap().apply {
|
|
291
|
+
putString("item", serializeContentItemToJSON(item))
|
|
292
|
+
}
|
|
293
|
+
sendEvent("onOverlayShareTapped", params)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
override fun onCommentTapped(item: ContentItem) {
|
|
297
|
+
val params = Arguments.createMap().apply {
|
|
298
|
+
putString("item", serializeContentItemToJSON(item))
|
|
299
|
+
}
|
|
300
|
+
sendEvent("onCommentTapped", params)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
override fun onSaveTapped(item: ContentItem) {
|
|
304
|
+
val params = Arguments.createMap().apply {
|
|
305
|
+
putString("item", serializeContentItemToJSON(item))
|
|
306
|
+
}
|
|
307
|
+
sendEvent("onSaveTapped", params)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
override fun onLikeTapped(item: ContentItem) {
|
|
311
|
+
val params = Arguments.createMap().apply {
|
|
312
|
+
putString("item", serializeContentItemToJSON(item))
|
|
313
|
+
}
|
|
314
|
+
sendEvent("onLikeTapped", params)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// -----------------------------------------------------------------------
|
|
319
|
+
// Overlay lifecycle events (called by Fabric view)
|
|
320
|
+
// -----------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
fun emitOverlayEvent(name: String, item: ContentItem) {
|
|
323
|
+
val params = Arguments.createMap().apply {
|
|
324
|
+
putString("item", serializeContentItemToJSON(item))
|
|
325
|
+
}
|
|
326
|
+
sendEvent(name, params)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
fun emitOverlayEvent(name: String, params: WritableMap) {
|
|
330
|
+
sendEvent(name, params)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// -----------------------------------------------------------------------
|
|
334
|
+
// Flow subscriptions
|
|
335
|
+
// -----------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
private fun subscribeToFlows(player: ShortKitPlayer) {
|
|
338
|
+
scope?.cancel()
|
|
339
|
+
val newScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
340
|
+
scope = newScope
|
|
341
|
+
|
|
342
|
+
// Player state
|
|
343
|
+
newScope.launch {
|
|
344
|
+
player.playerState.collect { state ->
|
|
345
|
+
val params = Arguments.createMap().apply {
|
|
346
|
+
putString("state", playerStateString(state))
|
|
347
|
+
if (state is com.shortkit.PlayerState.Error) {
|
|
348
|
+
putString("errorMessage", state.message)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
sendEvent("onPlayerStateChanged", params)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Current item
|
|
356
|
+
newScope.launch {
|
|
357
|
+
player.currentItem.collect { item ->
|
|
358
|
+
if (item != null) {
|
|
359
|
+
sendEvent("onCurrentItemChanged", contentItemMap(item))
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Time updates
|
|
365
|
+
newScope.launch {
|
|
366
|
+
player.time.collect { time ->
|
|
367
|
+
val params = Arguments.createMap().apply {
|
|
368
|
+
putDouble("current", time.currentMs / 1000.0)
|
|
369
|
+
putDouble("duration", time.durationMs / 1000.0)
|
|
370
|
+
putDouble("buffered", time.bufferedMs / 1000.0)
|
|
371
|
+
}
|
|
372
|
+
sendEvent("onTimeUpdate", params)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Muted state
|
|
377
|
+
newScope.launch {
|
|
378
|
+
player.isMuted.collect { muted ->
|
|
379
|
+
val params = Arguments.createMap().apply {
|
|
380
|
+
putBoolean("isMuted", muted)
|
|
381
|
+
}
|
|
382
|
+
sendEvent("onMutedChanged", params)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Playback rate
|
|
387
|
+
newScope.launch {
|
|
388
|
+
player.playbackRate.collect { rate ->
|
|
389
|
+
val params = Arguments.createMap().apply {
|
|
390
|
+
putDouble("rate", rate.toDouble())
|
|
391
|
+
}
|
|
392
|
+
sendEvent("onPlaybackRateChanged", params)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Captions enabled
|
|
397
|
+
newScope.launch {
|
|
398
|
+
player.captionsEnabled.collect { enabled ->
|
|
399
|
+
val params = Arguments.createMap().apply {
|
|
400
|
+
putBoolean("enabled", enabled)
|
|
401
|
+
}
|
|
402
|
+
sendEvent("onCaptionsEnabledChanged", params)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Active caption track
|
|
407
|
+
newScope.launch {
|
|
408
|
+
player.activeCaptionTrack.collect { track ->
|
|
409
|
+
if (track != null) {
|
|
410
|
+
val params = Arguments.createMap().apply {
|
|
411
|
+
putString("language", track.language)
|
|
412
|
+
putString("label", track.label)
|
|
413
|
+
putString("sourceUrl", track.url ?: "")
|
|
414
|
+
}
|
|
415
|
+
sendEvent("onActiveCaptionTrackChanged", params)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Active cue (ms -> seconds)
|
|
421
|
+
newScope.launch {
|
|
422
|
+
player.activeCue.collect { cue ->
|
|
423
|
+
if (cue != null) {
|
|
424
|
+
val params = Arguments.createMap().apply {
|
|
425
|
+
putString("text", cue.text)
|
|
426
|
+
putDouble("startTime", cue.startMs / 1000.0)
|
|
427
|
+
putDouble("endTime", cue.endMs / 1000.0)
|
|
428
|
+
}
|
|
429
|
+
sendEvent("onActiveCueChanged", params)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Did loop
|
|
435
|
+
newScope.launch {
|
|
436
|
+
player.didLoop.collect { event ->
|
|
437
|
+
val params = Arguments.createMap().apply {
|
|
438
|
+
putString("contentId", event.contentId)
|
|
439
|
+
putInt("loopCount", event.loopCount)
|
|
440
|
+
}
|
|
441
|
+
sendEvent("onDidLoop", params)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Feed transition
|
|
446
|
+
newScope.launch {
|
|
447
|
+
player.feedTransition.collect { event ->
|
|
448
|
+
val params = Arguments.createMap().apply {
|
|
449
|
+
putString("phase", when (event.phase) {
|
|
450
|
+
FeedTransitionPhase.BEGAN -> "began"
|
|
451
|
+
FeedTransitionPhase.ENDED -> "ended"
|
|
452
|
+
})
|
|
453
|
+
putString("direction", when (event.direction) {
|
|
454
|
+
com.shortkit.FeedDirection.FORWARD -> "forward"
|
|
455
|
+
com.shortkit.FeedDirection.BACKWARD -> "backward"
|
|
456
|
+
else -> "forward"
|
|
457
|
+
})
|
|
458
|
+
if (event.from != null) {
|
|
459
|
+
putString("fromItem", serializeContentItemToJSON(event.from!!))
|
|
460
|
+
}
|
|
461
|
+
if (event.to != null) {
|
|
462
|
+
putString("toItem", serializeContentItemToJSON(event.to!!))
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
sendEvent("onFeedTransition", params)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Format change (Long -> Double for bitrate)
|
|
470
|
+
newScope.launch {
|
|
471
|
+
player.formatChange.collect { event ->
|
|
472
|
+
val params = Arguments.createMap().apply {
|
|
473
|
+
putString("contentId", event.contentId)
|
|
474
|
+
putDouble("fromBitrate", event.fromBitrate.toDouble())
|
|
475
|
+
putDouble("toBitrate", event.toBitrate.toDouble())
|
|
476
|
+
putString("fromResolution", event.fromResolution ?: "")
|
|
477
|
+
putString("toResolution", event.toResolution ?: "")
|
|
478
|
+
}
|
|
479
|
+
sendEvent("onFormatChange", params)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Prefetched ahead count
|
|
484
|
+
newScope.launch {
|
|
485
|
+
player.prefetchedAheadCount.collect { count ->
|
|
486
|
+
val params = Arguments.createMap().apply {
|
|
487
|
+
putInt("count", count)
|
|
488
|
+
}
|
|
489
|
+
sendEvent("onPrefetchedAheadCountChanged", params)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// -----------------------------------------------------------------------
|
|
495
|
+
// Event emission
|
|
496
|
+
// -----------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
private fun sendEvent(name: String, params: WritableMap) {
|
|
499
|
+
if (!hasListeners) return
|
|
500
|
+
try {
|
|
501
|
+
reactApplicationContext
|
|
502
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
503
|
+
.emit(name, params)
|
|
504
|
+
} catch (_: Exception) {
|
|
505
|
+
// Context may not have an active catalyst instance during teardown
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// -----------------------------------------------------------------------
|
|
510
|
+
// Teardown
|
|
511
|
+
// -----------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
private fun teardown() {
|
|
514
|
+
scope?.cancel()
|
|
515
|
+
scope = null
|
|
516
|
+
shortKit?.release()
|
|
517
|
+
shortKit = null
|
|
518
|
+
if (shared === this) shared = null
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// -----------------------------------------------------------------------
|
|
522
|
+
// Content item serialization
|
|
523
|
+
// -----------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Build a flat WritableMap for `onCurrentItemChanged`.
|
|
527
|
+
* `captionTracks` and `customMetadata` are JSON-serialized strings.
|
|
528
|
+
*/
|
|
529
|
+
private fun contentItemMap(item: ContentItem): WritableMap {
|
|
530
|
+
return Arguments.createMap().apply {
|
|
531
|
+
putString("id", item.id)
|
|
532
|
+
putString("title", item.title)
|
|
533
|
+
item.description?.let { putString("description", it) }
|
|
534
|
+
putDouble("duration", item.duration)
|
|
535
|
+
putString("streamingUrl", item.streamingUrl)
|
|
536
|
+
putString("thumbnailUrl", item.thumbnailUrl)
|
|
537
|
+
|
|
538
|
+
// Caption tracks as JSON string
|
|
539
|
+
putString("captionTracks", serializeCaptionTracks(item.captionTracks))
|
|
540
|
+
|
|
541
|
+
// Custom metadata as JSON string
|
|
542
|
+
item.customMetadata?.let { meta ->
|
|
543
|
+
putString("customMetadata", serializeCustomMetadata(meta))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
item.author?.let { putString("author", it) }
|
|
547
|
+
item.articleUrl?.let { putString("articleUrl", it) }
|
|
548
|
+
item.commentCount?.let { putInt("commentCount", it) }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Serialize a full ContentItem to a JSON string for delegate/overlay events.
|
|
554
|
+
*/
|
|
555
|
+
private fun serializeContentItemToJSON(item: ContentItem): String {
|
|
556
|
+
return try {
|
|
557
|
+
val obj = JSONObject().apply {
|
|
558
|
+
put("id", item.id)
|
|
559
|
+
put("title", item.title)
|
|
560
|
+
item.description?.let { put("description", it) }
|
|
561
|
+
put("duration", item.duration)
|
|
562
|
+
put("streamingUrl", item.streamingUrl)
|
|
563
|
+
put("thumbnailUrl", item.thumbnailUrl)
|
|
564
|
+
put("captionTracks", buildCaptionTracksJSONArray(item.captionTracks))
|
|
565
|
+
item.customMetadata?.let { put("customMetadata", buildCustomMetadataJSONObject(it)) }
|
|
566
|
+
item.author?.let { put("author", it) }
|
|
567
|
+
item.articleUrl?.let { put("articleUrl", it) }
|
|
568
|
+
item.commentCount?.let { put("commentCount", it) }
|
|
569
|
+
}
|
|
570
|
+
obj.toString()
|
|
571
|
+
} catch (_: Exception) {
|
|
572
|
+
"{}"
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Serialize caption tracks list to a JSON string: `[{"language":"en","label":"English","sourceUrl":"..."}]`
|
|
578
|
+
*/
|
|
579
|
+
private fun serializeCaptionTracks(tracks: List<com.shortkit.CaptionTrack>): String {
|
|
580
|
+
return try {
|
|
581
|
+
buildCaptionTracksJSONArray(tracks).toString()
|
|
582
|
+
} catch (_: Exception) {
|
|
583
|
+
"[]"
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private fun buildCaptionTracksJSONArray(tracks: List<com.shortkit.CaptionTrack>): JSONArray {
|
|
588
|
+
val arr = JSONArray()
|
|
589
|
+
for (track in tracks) {
|
|
590
|
+
val obj = JSONObject().apply {
|
|
591
|
+
put("language", track.language)
|
|
592
|
+
put("label", track.label)
|
|
593
|
+
put("sourceUrl", track.url ?: "")
|
|
594
|
+
}
|
|
595
|
+
arr.put(obj)
|
|
596
|
+
}
|
|
597
|
+
return arr
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Serialize custom metadata map to a JSON string.
|
|
602
|
+
*/
|
|
603
|
+
private fun serializeCustomMetadata(meta: Map<String, JsonValue>): String {
|
|
604
|
+
return try {
|
|
605
|
+
buildCustomMetadataJSONObject(meta).toString()
|
|
606
|
+
} catch (_: Exception) {
|
|
607
|
+
"{}"
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private fun buildCustomMetadataJSONObject(meta: Map<String, JsonValue>): JSONObject {
|
|
612
|
+
val obj = JSONObject()
|
|
613
|
+
for ((key, value) in meta) {
|
|
614
|
+
obj.put(key, jsonValueToAny(value))
|
|
615
|
+
}
|
|
616
|
+
return obj
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Convert a ShortKit JsonValue sealed class to a native type suitable for JSONObject.
|
|
621
|
+
*/
|
|
622
|
+
private fun jsonValueToAny(value: JsonValue): Any {
|
|
623
|
+
return when (value) {
|
|
624
|
+
is JsonValue.StringValue -> value.value
|
|
625
|
+
is JsonValue.NumberValue -> value.value
|
|
626
|
+
is JsonValue.BoolValue -> value.value
|
|
627
|
+
is JsonValue.ObjectValue -> {
|
|
628
|
+
val obj = JSONObject()
|
|
629
|
+
for ((k, v) in value.value) {
|
|
630
|
+
obj.put(k, jsonValueToAny(v))
|
|
631
|
+
}
|
|
632
|
+
obj
|
|
633
|
+
}
|
|
634
|
+
is JsonValue.NullValue -> JSONObject.NULL
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// -----------------------------------------------------------------------
|
|
639
|
+
// Player state serialization
|
|
640
|
+
// -----------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
private fun playerStateString(state: com.shortkit.PlayerState): String {
|
|
643
|
+
return when (state) {
|
|
644
|
+
is com.shortkit.PlayerState.Idle -> "idle"
|
|
645
|
+
is com.shortkit.PlayerState.Loading -> "loading"
|
|
646
|
+
is com.shortkit.PlayerState.Ready -> "ready"
|
|
647
|
+
is com.shortkit.PlayerState.Playing -> "playing"
|
|
648
|
+
is com.shortkit.PlayerState.Paused -> "paused"
|
|
649
|
+
is com.shortkit.PlayerState.Seeking -> "seeking"
|
|
650
|
+
is com.shortkit.PlayerState.Buffering -> "buffering"
|
|
651
|
+
is com.shortkit.PlayerState.Ended -> "ended"
|
|
652
|
+
is com.shortkit.PlayerState.Error -> "error"
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// -----------------------------------------------------------------------
|
|
657
|
+
// Config parsing
|
|
658
|
+
// -----------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Parse the JSON config string from JS into a FeedConfig.
|
|
662
|
+
*
|
|
663
|
+
* Expected JSON shape:
|
|
664
|
+
* ```json
|
|
665
|
+
* {
|
|
666
|
+
* "feedHeight": "{\"type\":\"fullscreen\"}",
|
|
667
|
+
* "overlay": "\"none\"",
|
|
668
|
+
* "carouselMode": "\"none\"",
|
|
669
|
+
* "surveyMode": "\"none\"",
|
|
670
|
+
* "muteOnStart": true
|
|
671
|
+
* }
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
private fun parseFeedConfig(json: String): FeedConfig {
|
|
675
|
+
return try {
|
|
676
|
+
val obj = JSONObject(json)
|
|
677
|
+
|
|
678
|
+
val feedHeight = parseFeedHeight(obj.optString("feedHeight", null))
|
|
679
|
+
val muteOnStart = obj.optBoolean("muteOnStart", true)
|
|
680
|
+
val videoOverlay = parseVideoOverlay(obj.optString("overlay", null))
|
|
681
|
+
|
|
682
|
+
FeedConfig(
|
|
683
|
+
feedHeight = feedHeight,
|
|
684
|
+
videoOverlay = videoOverlay,
|
|
685
|
+
carouselOverlay = CarouselOverlayMode.None,
|
|
686
|
+
surveyOverlay = SurveyOverlayMode.None,
|
|
687
|
+
adOverlay = AdOverlayMode.None,
|
|
688
|
+
muteOnStart = muteOnStart
|
|
689
|
+
)
|
|
690
|
+
} catch (_: Exception) {
|
|
691
|
+
FeedConfig()
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Parse a double-stringified feedHeight JSON.
|
|
697
|
+
* e.g. `"{\"type\":\"fullscreen\"}"` or `"{\"type\":\"percentage\",\"value\":0.8}"`
|
|
698
|
+
*/
|
|
699
|
+
private fun parseFeedHeight(json: String?): FeedHeight {
|
|
700
|
+
if (json.isNullOrEmpty()) return FeedHeight.Fullscreen
|
|
701
|
+
return try {
|
|
702
|
+
val obj = JSONObject(json)
|
|
703
|
+
when (obj.optString("type")) {
|
|
704
|
+
"percentage" -> {
|
|
705
|
+
val value = obj.optDouble("value", 1.0)
|
|
706
|
+
FeedHeight.Percentage(value.toFloat())
|
|
707
|
+
}
|
|
708
|
+
else -> FeedHeight.Fullscreen
|
|
709
|
+
}
|
|
710
|
+
} catch (_: Exception) {
|
|
711
|
+
FeedHeight.Fullscreen
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Parse a double-stringified overlay JSON.
|
|
717
|
+
* - `"\"none\""` -> None
|
|
718
|
+
* - `"{\"type\":\"custom\"}"` -> Custom with bridge overlay factory
|
|
719
|
+
*/
|
|
720
|
+
private fun parseVideoOverlay(json: String?): VideoOverlayMode {
|
|
721
|
+
if (json.isNullOrEmpty()) return VideoOverlayMode.None
|
|
722
|
+
return try {
|
|
723
|
+
// Try parsing — might be a simple string "none" or an object
|
|
724
|
+
val parsed = json.trim()
|
|
725
|
+
|
|
726
|
+
// Strip outer quotes if double-stringified simple string
|
|
727
|
+
if (parsed == "\"none\"" || parsed == "none") {
|
|
728
|
+
return VideoOverlayMode.None
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Try as JSON object
|
|
732
|
+
val inner = if (parsed.startsWith("\"") && parsed.endsWith("\"")) {
|
|
733
|
+
// Double-stringified: strip outer quotes and unescape
|
|
734
|
+
JSONObject(parsed.substring(1, parsed.length - 1).replace("\\\"", "\""))
|
|
735
|
+
} else {
|
|
736
|
+
JSONObject(parsed)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (inner.optString("type") == "custom") {
|
|
740
|
+
// The Fabric view will handle the actual overlay view creation.
|
|
741
|
+
// For the module, we signal custom mode so the SDK allocates an overlay slot.
|
|
742
|
+
VideoOverlayMode.Custom { ShortKitOverlayBridge(reactApplicationContext) }
|
|
743
|
+
} else {
|
|
744
|
+
VideoOverlayMode.None
|
|
745
|
+
}
|
|
746
|
+
} catch (_: Exception) {
|
|
747
|
+
VideoOverlayMode.None
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Parse optional custom dimensions JSON string into map.
|
|
753
|
+
*/
|
|
754
|
+
private fun parseCustomDimensions(json: String?): Map<String, String>? {
|
|
755
|
+
if (json.isNullOrEmpty()) return null
|
|
756
|
+
return try {
|
|
757
|
+
val obj = JSONObject(json)
|
|
758
|
+
val map = mutableMapOf<String, String>()
|
|
759
|
+
val keys = obj.keys()
|
|
760
|
+
while (keys.hasNext()) {
|
|
761
|
+
val key = keys.next()
|
|
762
|
+
map[key] = obj.getString(key)
|
|
763
|
+
}
|
|
764
|
+
map
|
|
765
|
+
} catch (_: Exception) {
|
|
766
|
+
null
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|