@shortkitsdk/react-native 0.2.5 → 0.2.11

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.
Files changed (75) hide show
  1. package/ShortKitReactNative.podspec +1 -0
  2. package/android/build.gradle.kts +5 -1
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +319 -0
  4. package/android/src/main/java/com/shortkit/reactnative/ReactLoadingHost.kt +40 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +559 -0
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +984 -0
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +88 -220
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +12 -3
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +126 -706
  10. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +2 -2
  11. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +2 -2
  12. package/ios/ReactCarouselOverlayHost.swift +177 -0
  13. package/ios/ReactLoadingHost.swift +38 -0
  14. package/ios/ReactOverlayHost.swift +458 -0
  15. package/ios/SKFabricSurfaceWrapper.h +18 -0
  16. package/ios/SKFabricSurfaceWrapper.mm +57 -0
  17. package/ios/ShortKitBridge.swift +266 -65
  18. package/ios/ShortKitFeedView.swift +63 -207
  19. package/ios/ShortKitFeedViewManager.mm +3 -2
  20. package/ios/ShortKitModule.mm +86 -32
  21. package/ios/ShortKitPlayerNativeView.swift +39 -8
  22. package/ios/ShortKitReactNative-Bridging-Header.h +2 -0
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +2 -1
  24. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +3998 -962
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +85 -24
  26. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +85 -24
  28. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  29. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +2 -1
  30. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +3998 -962
  31. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +85 -24
  32. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  33. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +85 -24
  34. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  35. package/ios/ShortKitSDK.xcframework.bak/Info.plist +43 -0
  36. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  37. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
  38. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +28917 -0
  39. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +824 -0
  40. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  41. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +824 -0
  42. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  43. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  44. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  45. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
  46. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +28917 -0
  47. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +824 -0
  48. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  49. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +824 -0
  50. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  51. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  52. package/ios/ShortKitWidgetNativeView.swift +3 -3
  53. package/package.json +1 -1
  54. package/src/ShortKitCarouselOverlaySurface.tsx +55 -0
  55. package/src/ShortKitCommands.ts +31 -0
  56. package/src/ShortKitContext.ts +11 -25
  57. package/src/ShortKitFeed.tsx +110 -41
  58. package/src/ShortKitLoadingSurface.tsx +24 -0
  59. package/src/ShortKitOverlaySurface.tsx +205 -0
  60. package/src/ShortKitPlayer.tsx +6 -7
  61. package/src/ShortKitProvider.tsx +65 -250
  62. package/src/index.ts +9 -4
  63. package/src/serialization.ts +22 -42
  64. package/src/specs/NativeShortKitModule.ts +67 -53
  65. package/src/specs/ShortKitFeedViewNativeComponent.ts +3 -2
  66. package/src/types.ts +104 -19
  67. package/src/useShortKit.ts +1 -3
  68. package/src/useShortKitPlayer.ts +7 -8
  69. package/android/src/main/java/com/shortkit/reactnative/ShortKitCarouselOverlayBridge.kt +0 -48
  70. package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +0 -128
  71. package/ios/ShortKitCarouselOverlayBridge.swift +0 -54
  72. package/ios/ShortKitOverlayBridge.swift +0 -113
  73. package/src/CarouselOverlayManager.tsx +0 -71
  74. package/src/OverlayManager.tsx +0 -87
  75. package/src/useShortKitCarousel.ts +0 -29
@@ -0,0 +1,984 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.WritableMap
7
+ import com.shortkit.sdk.ShortKit
8
+ import com.shortkit.sdk.ShortKitDelegate
9
+ import com.shortkit.sdk.ShortKitPlayer
10
+ import com.shortkit.sdk.model.CaptionTrack
11
+ import com.shortkit.sdk.model.CarouselImage
12
+ import com.shortkit.sdk.model.ContentItem
13
+ import com.shortkit.sdk.model.ContentSignal
14
+ import com.shortkit.sdk.model.FeedDirection
15
+ import com.shortkit.sdk.model.FeedFilter
16
+ import com.shortkit.sdk.model.FeedInput
17
+ import com.shortkit.sdk.model.FeedScrollPhase
18
+ import com.shortkit.sdk.model.FeedTransitionPhase
19
+ import com.shortkit.sdk.model.ImageCarouselItem
20
+ import com.shortkit.sdk.model.JsonValue
21
+ import com.shortkit.sdk.model.PlayerState
22
+ import com.shortkit.sdk.config.AdOverlayMode
23
+ import com.shortkit.sdk.config.CarouselOverlayMode
24
+ import com.shortkit.sdk.config.FeedConfig
25
+ import com.shortkit.sdk.config.FeedHeight
26
+ import com.shortkit.sdk.config.FeedSource
27
+ import com.shortkit.sdk.config.SurveyOverlayMode
28
+ import com.shortkit.sdk.config.VideoOverlayMode
29
+ import com.shortkit.sdk.feed.ShortKitFeedFragment
30
+ import kotlinx.coroutines.CoroutineScope
31
+ import kotlinx.coroutines.Dispatchers
32
+ import kotlinx.coroutines.SupervisorJob
33
+ import kotlinx.coroutines.cancel
34
+ import kotlinx.coroutines.delay
35
+ import kotlinx.coroutines.launch
36
+ import org.json.JSONArray
37
+ import org.json.JSONObject
38
+ import java.lang.ref.WeakReference
39
+
40
+ /**
41
+ * Android bridge between the ShortKit SDK and the thin TurboModule.
42
+ *
43
+ * Holds the [ShortKit] instance, subscribes to all Kotlin Flows on
44
+ * [ShortKitPlayer], and forwards events to JS via the [emitEvent] lambda.
45
+ *
46
+ * Android equivalent of `react_native_sdk/ios/ShortKitBridge.swift`.
47
+ */
48
+ class ShortKitBridge(
49
+ apiKey: String,
50
+ context: android.content.Context,
51
+ hasLoadingView: Boolean,
52
+ clientAppName: String?,
53
+ clientAppVersion: String?,
54
+ customDimensionsJSON: String?,
55
+ private val emitEvent: (String, WritableMap) -> Unit
56
+ ) {
57
+
58
+ companion object {
59
+ @Volatile
60
+ var shared: ShortKitBridge? = null
61
+ private set
62
+
63
+ /** Feed views waiting for the SDK to be initialized. */
64
+ internal val staticPendingFeedViews = mutableListOf<ShortKitFeedView>()
65
+
66
+ // ------------------------------------------------------------------
67
+ // Static serialization helpers (called by overlay hosts)
68
+ // ------------------------------------------------------------------
69
+
70
+ /**
71
+ * Serialize a [ContentItem] to a JSON string for bridge transport.
72
+ */
73
+ fun serializeContentItemToJSON(item: ContentItem): String {
74
+ return try {
75
+ val obj = JSONObject().apply {
76
+ put("id", item.id)
77
+ put("title", item.title)
78
+ item.description?.let { put("description", it) }
79
+ put("duration", item.duration)
80
+ put("streamingUrl", item.streamingUrl)
81
+ put("thumbnailUrl", item.thumbnailUrl)
82
+ put("captionTracks", buildCaptionTracksJSONArray(item.captionTracks))
83
+ item.customMetadata?.let { put("customMetadata", buildCustomMetadataJSONObject(it)) }
84
+ item.author?.let { put("author", it) }
85
+ item.articleUrl?.let { put("articleUrl", it) }
86
+ item.commentCount?.let { put("commentCount", it) }
87
+ item.fallbackUrl?.let { put("fallbackUrl", it) }
88
+ }
89
+ obj.toString()
90
+ } catch (_: Exception) {
91
+ "{}"
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Convert a [PlayerState] sealed class to its string representation.
97
+ */
98
+ fun playerStateString(state: PlayerState): String {
99
+ return when (state) {
100
+ is PlayerState.Idle -> "idle"
101
+ is PlayerState.Loading -> "loading"
102
+ is PlayerState.Ready -> "ready"
103
+ is PlayerState.Playing -> "playing"
104
+ is PlayerState.Paused -> "paused"
105
+ is PlayerState.Seeking -> "seeking"
106
+ is PlayerState.Buffering -> "buffering"
107
+ is PlayerState.Ended -> "ended"
108
+ is PlayerState.Error -> "error"
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Parse optional custom dimensions JSON string into a map.
114
+ */
115
+ fun parseCustomDimensions(json: String?): Map<String, String>? {
116
+ if (json.isNullOrEmpty()) return null
117
+ return try {
118
+ val obj = JSONObject(json)
119
+ val map = mutableMapOf<String, String>()
120
+ val keys = obj.keys()
121
+ while (keys.hasNext()) {
122
+ val key = keys.next()
123
+ map[key] = obj.getString(key)
124
+ }
125
+ map
126
+ } catch (_: Exception) {
127
+ null
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Parse the JSON config string from JS into a [FeedConfig].
133
+ *
134
+ * @param json The JSON config string from JS.
135
+ * @param context Android context needed to instantiate overlay views.
136
+ */
137
+ fun parseFeedConfig(json: String, context: android.content.Context? = null): FeedConfig {
138
+ return try {
139
+ val obj = JSONObject(json)
140
+
141
+ val feedHeight = parseFeedHeight(obj.optString("feedHeight", null))
142
+ val muteOnStart = obj.optBoolean("muteOnStart", true)
143
+ val videoOverlay = parseVideoOverlay(obj.optString("overlay", null), context)
144
+
145
+ val feedSourceStr = obj.optString("feedSource", "algorithmic")
146
+ val feedSource = if (feedSourceStr == "custom") FeedSource.CUSTOM else FeedSource.ALGORITHMIC
147
+
148
+ val carouselOverlay = parseCarouselOverlay(obj.optString("carouselOverlay", null), context)
149
+ val autoplay = obj.optBoolean("autoplay", true)
150
+ val filter = obj.optJSONObject("filter")?.let { parseFeedFilterToModel(it.toString()) }
151
+
152
+ FeedConfig(
153
+ feedHeight = feedHeight,
154
+ videoOverlay = videoOverlay,
155
+ carouselOverlay = carouselOverlay,
156
+ surveyOverlay = SurveyOverlayMode.None,
157
+ adOverlay = AdOverlayMode.None,
158
+ muteOnStart = muteOnStart,
159
+ autoplay = autoplay,
160
+ feedSource = feedSource,
161
+ filter = filter,
162
+ )
163
+ } catch (_: Exception) {
164
+ FeedConfig()
165
+ }
166
+ }
167
+
168
+ // ------------------------------------------------------------------
169
+ // Private static parsing helpers
170
+ // ------------------------------------------------------------------
171
+
172
+ private fun parseFeedHeight(json: String?): FeedHeight {
173
+ if (json.isNullOrEmpty()) return FeedHeight.Fullscreen
174
+ return try {
175
+ val obj = JSONObject(json)
176
+ when (obj.optString("type")) {
177
+ "percentage" -> {
178
+ val value = obj.optDouble("value", 1.0)
179
+ FeedHeight.Percentage(value.toFloat())
180
+ }
181
+ else -> FeedHeight.Fullscreen
182
+ }
183
+ } catch (_: Exception) {
184
+ FeedHeight.Fullscreen
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Parse a double-stringified overlay JSON.
190
+ * - `"\"none\""` -> None
191
+ * - `"{\"type\":\"custom\"}"` -> Custom with ReactOverlayHost factory
192
+ */
193
+ private fun parseVideoOverlay(json: String?, context: android.content.Context?): VideoOverlayMode {
194
+ if (json.isNullOrEmpty()) return VideoOverlayMode.None
195
+ return try {
196
+ val parsed = json.trim()
197
+
198
+ if (parsed == "\"none\"" || parsed == "none") {
199
+ return VideoOverlayMode.None
200
+ }
201
+
202
+ val inner = if (parsed.startsWith("\"") && parsed.endsWith("\"")) {
203
+ JSONObject(parsed.substring(1, parsed.length - 1).replace("\\\"", "\""))
204
+ } else {
205
+ JSONObject(parsed)
206
+ }
207
+
208
+ if (inner.optString("type") == "custom" && context != null) {
209
+ val name = inner.optString("name", "Default")
210
+ val ctx = context.applicationContext
211
+ VideoOverlayMode.Custom { ReactOverlayHost(ctx).apply { overlayName = name } }
212
+ } else {
213
+ VideoOverlayMode.None
214
+ }
215
+ } catch (_: Exception) {
216
+ VideoOverlayMode.None
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Parse a double-stringified carousel overlay JSON.
222
+ * - `"\"none\""` -> None
223
+ * - `"{\"type\":\"custom\"}"` -> Custom with ReactCarouselOverlayHost factory
224
+ */
225
+ private fun parseCarouselOverlay(json: String?, context: android.content.Context?): CarouselOverlayMode {
226
+ if (json.isNullOrEmpty()) return CarouselOverlayMode.None
227
+ return try {
228
+ val parsed = json.trim()
229
+
230
+ if (parsed == "\"none\"" || parsed == "none") {
231
+ return CarouselOverlayMode.None
232
+ }
233
+
234
+ val inner = if (parsed.startsWith("\"") && parsed.endsWith("\"")) {
235
+ JSONObject(parsed.substring(1, parsed.length - 1).replace("\\\"", "\""))
236
+ } else {
237
+ JSONObject(parsed)
238
+ }
239
+
240
+ if (inner.optString("type") == "custom" && context != null) {
241
+ val name = inner.optString("name", "Default")
242
+ val ctx = context.applicationContext
243
+ CarouselOverlayMode.Custom {
244
+ ReactCarouselOverlayHost(ctx).apply {
245
+ carouselOverlayName = name
246
+ // Eagerly create the RN surface so it's mounted and ready
247
+ // before the cell scrolls into view, matching iOS behaviour.
248
+ prepareSurface()
249
+ }
250
+ }
251
+ } else {
252
+ CarouselOverlayMode.None
253
+ }
254
+ } catch (_: Exception) {
255
+ CarouselOverlayMode.None
256
+ }
257
+ }
258
+
259
+ private fun parseFeedFilter(json: String?): String? {
260
+ // On Android, the SDK accepts the raw JSON string for filtering.
261
+ // Return as-is if non-null/non-empty.
262
+ if (json.isNullOrEmpty()) return null
263
+ return json
264
+ }
265
+
266
+ /** Parse a JSON filter string into a FeedFilter model for SDK methods that accept it. */
267
+ fun parseFeedFilterToModel(json: String): FeedFilter? {
268
+ if (json.isBlank()) return null
269
+ return try {
270
+ val obj = JSONObject(json)
271
+ FeedFilter(
272
+ tags = obj.optJSONArray("tags")?.let { arr ->
273
+ (0 until arr.length()).map { arr.getString(it) }
274
+ },
275
+ section = obj.optString("section").ifBlank { null },
276
+ author = obj.optString("author").ifBlank { null },
277
+ contentType = obj.optString("contentType").ifBlank { null },
278
+ metadata = obj.optJSONObject("metadata")?.let { m ->
279
+ m.keys().asSequence().associateWith { m.getString(it) }
280
+ }
281
+ )
282
+ } catch (_: Exception) { null }
283
+ }
284
+
285
+ private fun parseFeedInputs(json: String): List<FeedInput>? {
286
+ return try {
287
+ val arr = JSONArray(json)
288
+ val result = mutableListOf<FeedInput>()
289
+ for (i in 0 until arr.length()) {
290
+ val obj = arr.getJSONObject(i)
291
+ when (obj.optString("type")) {
292
+ "video" -> {
293
+ val playbackId = obj.optString("playbackId", null) ?: continue
294
+ val fallbackUrl = obj.optString("fallbackUrl", null)
295
+ result.add(FeedInput.Video(playbackId, fallbackUrl))
296
+ }
297
+ "imageCarousel" -> {
298
+ val itemObj = obj.optJSONObject("item") ?: continue
299
+ val carouselItem = parseImageCarouselItem(itemObj) ?: continue
300
+ result.add(FeedInput.ImageCarousel(carouselItem))
301
+ }
302
+ }
303
+ }
304
+ result.ifEmpty { null }
305
+ } catch (_: Exception) {
306
+ null
307
+ }
308
+ }
309
+
310
+ private fun parseImageCarouselItem(obj: JSONObject): ImageCarouselItem? {
311
+ val id = obj.optString("id", null) ?: return null
312
+ val imagesArr = obj.optJSONArray("images") ?: return null
313
+ val images = mutableListOf<CarouselImage>()
314
+ for (i in 0 until imagesArr.length()) {
315
+ val imgObj = imagesArr.getJSONObject(i)
316
+ images.add(
317
+ CarouselImage(
318
+ url = imgObj.getString("url"),
319
+ alt = imgObj.optString("alt", null)
320
+ )
321
+ )
322
+ }
323
+ return ImageCarouselItem(
324
+ id = id,
325
+ images = images,
326
+ caption = obj.optString("caption", null),
327
+ title = obj.optString("title", null),
328
+ description = obj.optString("description", null),
329
+ author = obj.optString("author", null),
330
+ section = obj.optString("section", null),
331
+ articleUrl = obj.optString("articleUrl", null)
332
+ )
333
+ }
334
+
335
+ private fun buildCaptionTracksJSONArray(tracks: List<CaptionTrack>): JSONArray {
336
+ val arr = JSONArray()
337
+ for (track in tracks) {
338
+ val obj = JSONObject().apply {
339
+ put("language", track.language)
340
+ put("label", track.label)
341
+ put("sourceUrl", track.url ?: "")
342
+ }
343
+ arr.put(obj)
344
+ }
345
+ return arr
346
+ }
347
+
348
+ private fun buildCustomMetadataJSONObject(meta: Map<String, JsonValue>): JSONObject {
349
+ val obj = JSONObject()
350
+ for ((key, value) in meta) {
351
+ obj.put(key, jsonValueToAny(value))
352
+ }
353
+ return obj
354
+ }
355
+
356
+ private fun jsonValueToAny(value: JsonValue): Any {
357
+ return when (value) {
358
+ is JsonValue.StringValue -> value.value
359
+ is JsonValue.NumberValue -> value.value
360
+ is JsonValue.BoolValue -> value.value
361
+ is JsonValue.ObjectValue -> {
362
+ val obj = JSONObject()
363
+ for ((k, v) in value.value) {
364
+ obj.put(k, jsonValueToAny(v))
365
+ }
366
+ obj
367
+ }
368
+ is JsonValue.NullValue -> JSONObject.NULL
369
+ }
370
+ }
371
+ }
372
+
373
+ // ------------------------------------------------------------------
374
+ // State
375
+ // ------------------------------------------------------------------
376
+
377
+ private var shortKit: ShortKit? = null
378
+ private var scope: CoroutineScope? = null
379
+
380
+ /** Preload handles keyed by UUID, consumed by feed views via preloadId prop. */
381
+ var preloadHandles = mutableMapOf<String, Any>()
382
+ private set
383
+
384
+ // ------------------------------------------------------------------
385
+ // Feed instance registry
386
+ // ------------------------------------------------------------------
387
+
388
+ private val feedRegistry = mutableMapOf<String, WeakReference<ShortKitFeedFragment>>()
389
+ private val pendingOps = mutableMapOf<String, MutableList<(ShortKitFeedFragment) -> Unit>>()
390
+ private val pendingOpsLock = Any()
391
+
392
+ /** Expose the underlying SDK for the Fabric feed view manager. */
393
+ val sdk: ShortKit? get() = shortKit
394
+
395
+ // ------------------------------------------------------------------
396
+ // Init
397
+ // ------------------------------------------------------------------
398
+
399
+ init {
400
+ val dims = parseCustomDimensions(customDimensionsJSON)
401
+
402
+ val sdk = ShortKit(
403
+ context = context,
404
+ apiKey = apiKey,
405
+ userId = null,
406
+ adProvider = null,
407
+ clientAppName = clientAppName,
408
+ clientAppVersion = clientAppVersion,
409
+ customDimensions = dims
410
+ )
411
+ this.shortKit = sdk
412
+ shared = this
413
+
414
+ // Wire custom loading view provider
415
+ if (hasLoadingView) {
416
+ sdk.loadingViewProvider = { ctx -> ReactLoadingHost(ctx) }
417
+ }
418
+
419
+ subscribeToFlows(sdk.player)
420
+
421
+ // Wire delegate
422
+ sdk.delegate = object : ShortKitDelegate {
423
+ override fun onContentTapped(contentId: String, index: Int) {
424
+ val params = Arguments.createMap().apply {
425
+ putString("contentId", contentId)
426
+ putInt("index", index)
427
+ }
428
+ emitEventOnMain("onContentTapped", params)
429
+ }
430
+
431
+ override fun onRefreshRequested() {
432
+ emitEventOnMain("onRefreshRequested", Arguments.createMap())
433
+ }
434
+
435
+ override fun onFeedContentFetched(items: List<ContentItem>) {
436
+ val arr = org.json.JSONArray()
437
+ for (item in items) {
438
+ arr.put(org.json.JSONObject(serializeContentItemToJSON(item)))
439
+ }
440
+ val params = Arguments.createMap().apply {
441
+ putString("items", arr.toString())
442
+ }
443
+ emitEventOnMain("onDidFetchContentItems", params)
444
+ }
445
+ }
446
+
447
+ // Drain any feed views that were waiting for the SDK to be ready
448
+ val pending: List<ShortKitFeedView>
449
+ synchronized(staticPendingFeedViews) {
450
+ pending = staticPendingFeedViews.toList()
451
+ staticPendingFeedViews.clear()
452
+ }
453
+ for (view in pending) {
454
+ view.embedFeedFragmentIfNeeded()
455
+ }
456
+ }
457
+
458
+ // ------------------------------------------------------------------
459
+ // Teardown
460
+ // ------------------------------------------------------------------
461
+
462
+ fun teardown() {
463
+ scope?.cancel()
464
+ scope = null
465
+ preloadHandles.clear()
466
+ feedRegistry.clear()
467
+ // Note: pendingOps are NOT cleared — they survive re-init
468
+ shortKit?.release()
469
+ shortKit = null
470
+ if (shared === this) {
471
+ shared = null
472
+ }
473
+ }
474
+
475
+ // ------------------------------------------------------------------
476
+ // Feed registry
477
+ // ------------------------------------------------------------------
478
+
479
+ fun registerFeedFragment(id: String, fragment: ShortKitFeedFragment) {
480
+ feedRegistry[id] = WeakReference(fragment)
481
+
482
+ // Wire per-feed callbacks
483
+ fragment.onDismiss = {
484
+ emitEventOnMain("onDismiss", Arguments.createMap())
485
+ }
486
+ fragment.onRemainingContentCountChange = { count ->
487
+ val params = Arguments.createMap().apply {
488
+ putString("feedId", id)
489
+ putInt("count", count)
490
+ }
491
+ emitEventOnMain("onRemainingContentCountChanged", params)
492
+ }
493
+
494
+ // Replay buffered operations on the next main-thread tick
495
+ val ops: List<(ShortKitFeedFragment) -> Unit>?
496
+ synchronized(pendingOpsLock) {
497
+ ops = pendingOps.remove(id)?.toList()
498
+ }
499
+ if (ops != null) {
500
+ Handler(Looper.getMainLooper()).post {
501
+ val frag = feedRegistry[id]?.get() ?: return@post
502
+ for (op in ops) {
503
+ op(frag)
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ fun unregisterFeedFragment(id: String) {
510
+ feedRegistry.remove(id)
511
+ // pendingOps preserved for detach/reattach cycles
512
+ }
513
+
514
+ private fun feedFragment(id: String): ShortKitFeedFragment? {
515
+ return feedRegistry[id]?.get()
516
+ }
517
+
518
+ fun registerFeed(id: String) {
519
+ // No-op — registerFeedFragment handles drain
520
+ }
521
+
522
+ fun unregisterFeed(id: String) {
523
+ // pendingOps preserved for detach/reattach cycles
524
+ }
525
+
526
+ /** Register a feed view that is waiting for the SDK to be initialized. */
527
+ fun registerPendingFeedView(view: ShortKitFeedView) {
528
+ if (shortKit != null) {
529
+ view.embedFeedFragmentIfNeeded()
530
+ } else {
531
+ synchronized(staticPendingFeedViews) {
532
+ staticPendingFeedViews.add(view)
533
+ }
534
+ }
535
+ }
536
+
537
+ fun unregisterPendingFeedView(view: ShortKitFeedView) {
538
+ synchronized(staticPendingFeedViews) {
539
+ staticPendingFeedViews.remove(view)
540
+ }
541
+ }
542
+
543
+ // ------------------------------------------------------------------
544
+ // Preload handle management
545
+ // ------------------------------------------------------------------
546
+
547
+ fun consumePreload(id: String): Any? {
548
+ return preloadHandles.remove(id)
549
+ }
550
+
551
+ // ------------------------------------------------------------------
552
+ // Event emission helpers (public for overlay hosts)
553
+ // ------------------------------------------------------------------
554
+
555
+ fun emitEvent(name: String, params: WritableMap) {
556
+ emitEvent.invoke(name, params)
557
+ }
558
+
559
+ fun emitEventOnMain(name: String, params: WritableMap) {
560
+ if (Looper.myLooper() == Looper.getMainLooper()) {
561
+ emitEvent.invoke(name, params)
562
+ } else {
563
+ Handler(Looper.getMainLooper()).post {
564
+ emitEvent.invoke(name, params)
565
+ }
566
+ }
567
+ }
568
+
569
+ fun emitOverlayEvent(name: String, item: ContentItem) {
570
+ val params = Arguments.createMap().apply {
571
+ putString("item", serializeContentItemToJSON(item))
572
+ }
573
+ emitEvent.invoke(name, params)
574
+ }
575
+
576
+ fun emitOverlayEvent(name: String, params: WritableMap) {
577
+ emitEvent.invoke(name, params)
578
+ }
579
+
580
+ fun emitCarouselOverlayEvent(name: String, params: WritableMap) {
581
+ emitEvent.invoke(name, params)
582
+ }
583
+
584
+ // ------------------------------------------------------------------
585
+ // Player commands
586
+ // ------------------------------------------------------------------
587
+
588
+ // All player commands dispatch to main thread — ExoPlayer is bound to
589
+ // the main looper but @ReactMethod calls arrive on the NativeModules thread.
590
+
591
+ private fun runOnMain(block: () -> Unit) {
592
+ Handler(Looper.getMainLooper()).post(block)
593
+ }
594
+
595
+ fun play() { runOnMain { shortKit?.player?.play() } }
596
+ fun pause() { runOnMain { shortKit?.player?.pause() } }
597
+ fun seek(seconds: Double) { runOnMain { shortKit?.player?.seek(seconds) } }
598
+ fun seekAndPlay(seconds: Double) { runOnMain { shortKit?.player?.seekAndPlay(seconds) } }
599
+ fun skipToNext() { runOnMain { shortKit?.player?.skipToNext() } }
600
+ fun skipToPrevious() { runOnMain { shortKit?.player?.skipToPrevious() } }
601
+ fun setMuted(muted: Boolean) { runOnMain { shortKit?.player?.setMuted(muted) } }
602
+ fun setPlaybackRate(rate: Double) { runOnMain { shortKit?.player?.setPlaybackRate(rate.toFloat()) } }
603
+ fun setCaptionsEnabled(enabled: Boolean) { runOnMain { shortKit?.player?.setCaptionsEnabled(enabled) } }
604
+ fun selectCaptionTrack(language: String) { runOnMain { shortKit?.player?.selectCaptionTrack(language) } }
605
+ fun sendContentSignal(signal: String) {
606
+ val contentSignal = if (signal == "positive") ContentSignal.POSITIVE else ContentSignal.NEGATIVE
607
+ runOnMain { shortKit?.player?.sendContentSignal(contentSignal) }
608
+ }
609
+ fun setMaxBitrate(bitrate: Double) { runOnMain { shortKit?.player?.setMaxBitrate(bitrate.toInt()) } }
610
+
611
+ fun setUserId(userId: String) {
612
+ shortKit?.setUserId(userId)
613
+ }
614
+
615
+ fun clearUserId() {
616
+ shortKit?.clearUserId()
617
+ }
618
+
619
+ fun onPause() {
620
+ Handler(Looper.getMainLooper()).post { shortKit?.pause() }
621
+ }
622
+
623
+ fun onResume() {
624
+ // No-op: let the SDK's internal lifecycle handle resume
625
+ }
626
+
627
+ // ------------------------------------------------------------------
628
+ // Custom feed operations
629
+ // ------------------------------------------------------------------
630
+
631
+ fun setFeedItems(feedId: String, itemsJSON: String) {
632
+ val fragment = feedFragment(feedId)
633
+ if (fragment != null) {
634
+ val parsed = parseFeedInputs(itemsJSON) ?: return
635
+ Handler(Looper.getMainLooper()).post {
636
+ fragment.setFeedItems(parsed)
637
+ }
638
+ } else {
639
+ synchronized(pendingOpsLock) {
640
+ pendingOps.getOrPut(feedId) { mutableListOf() }.add { frag ->
641
+ val parsed = parseFeedInputs(itemsJSON) ?: return@add
642
+ if (Looper.myLooper() == Looper.getMainLooper()) {
643
+ frag.setFeedItems(parsed)
644
+ } else {
645
+ Handler(Looper.getMainLooper()).post {
646
+ frag.setFeedItems(parsed)
647
+ }
648
+ }
649
+ }
650
+ }
651
+ }
652
+ }
653
+
654
+ fun appendFeedItems(feedId: String, itemsJSON: String) {
655
+ val fragment = feedFragment(feedId)
656
+ if (fragment != null) {
657
+ val parsed = parseFeedInputs(itemsJSON) ?: return
658
+ Handler(Looper.getMainLooper()).post {
659
+ fragment.appendFeedItems(parsed)
660
+ }
661
+ } else {
662
+ synchronized(pendingOpsLock) {
663
+ pendingOps.getOrPut(feedId) { mutableListOf() }.add { frag ->
664
+ val parsed = parseFeedInputs(itemsJSON) ?: return@add
665
+ Handler(Looper.getMainLooper()).post {
666
+ frag.appendFeedItems(parsed)
667
+ }
668
+ }
669
+ }
670
+ }
671
+ }
672
+
673
+ fun applyFilter(feedId: String, filterJSON: String?) {
674
+ val fragment = feedRegistry[feedId]?.get()
675
+ if (fragment != null) {
676
+ val filter = filterJSON?.let { parseFeedFilterToModel(it) }
677
+ Handler(Looper.getMainLooper()).post { fragment.applyFilter(filter) }
678
+ } else {
679
+ synchronized(pendingOpsLock) {
680
+ pendingOps.getOrPut(feedId) { mutableListOf() }.add { frag ->
681
+ val filter = filterJSON?.let { parseFeedFilterToModel(it) }
682
+ Handler(Looper.getMainLooper()).post { frag.applyFilter(filter) }
683
+ }
684
+ }
685
+ }
686
+ }
687
+
688
+ // ------------------------------------------------------------------
689
+ // Fetch content
690
+ // ------------------------------------------------------------------
691
+
692
+ fun fetchContent(limit: Int, filterJSON: String?, callback: (String) -> Unit) {
693
+ val sdk = shortKit
694
+ if (sdk == null) {
695
+ callback("[]")
696
+ return
697
+ }
698
+ scope?.launch {
699
+ try {
700
+ val filter = filterJSON?.let { parseFeedFilterToModel(it) }
701
+ val items = sdk.fetchContent(limit, filter)
702
+ val arr = JSONArray()
703
+ for (item in items) {
704
+ arr.put(JSONObject(serializeContentItemToJSON(item)))
705
+ }
706
+ callback(arr.toString())
707
+ } catch (_: Exception) {
708
+ callback("[]")
709
+ }
710
+ }
711
+ }
712
+
713
+ // ------------------------------------------------------------------
714
+ // Preload feed
715
+ // ------------------------------------------------------------------
716
+
717
+ fun preloadFeed(configJSON: String, callback: (String) -> Unit) {
718
+ val sdk = shortKit
719
+ if (sdk == null) {
720
+ callback("")
721
+ return
722
+ }
723
+ val preload = sdk.preloadFeed(filter = configJSON)
724
+ val uuid = java.util.UUID.randomUUID().toString()
725
+ preloadHandles[uuid] = preload
726
+ callback(uuid)
727
+ }
728
+
729
+ // ------------------------------------------------------------------
730
+ // Storyboard / Seek Thumbnails
731
+ // ------------------------------------------------------------------
732
+
733
+ fun prefetchStoryboard(playbackId: String) {
734
+ shortKit?.player?.prefetchStoryboard(playbackId)
735
+ }
736
+
737
+ fun getStoryboardData(playbackId: String, callback: (String?) -> Unit) {
738
+ val player = shortKit?.player
739
+ if (player == null) {
740
+ callback(null)
741
+ return
742
+ }
743
+ // Try cached data first
744
+ val json = player.getStoryboardData(playbackId)
745
+ if (json != null) {
746
+ callback(json)
747
+ return
748
+ }
749
+ // Trigger prefetch and retry with coroutine delay (not Thread.sleep)
750
+ player.prefetchStoryboard(playbackId)
751
+ scope?.launch {
752
+ var retries = 0
753
+ while (retries < 30) { // 3 seconds max
754
+ delay(100)
755
+ val data = player.getStoryboardData(playbackId)
756
+ if (data != null) {
757
+ callback(data)
758
+ return@launch
759
+ }
760
+ retries++
761
+ }
762
+ callback(null)
763
+ }
764
+ }
765
+
766
+ // ------------------------------------------------------------------
767
+ // Content item map (for onCurrentItemChanged)
768
+ // ------------------------------------------------------------------
769
+
770
+ private fun contentItemMap(item: ContentItem): WritableMap {
771
+ return Arguments.createMap().apply {
772
+ putString("id", item.id)
773
+ item.playbackId?.let { putString("playbackId", it) }
774
+ putString("title", item.title)
775
+ item.description?.let { putString("description", it) }
776
+ putDouble("duration", item.duration)
777
+ putString("streamingUrl", item.streamingUrl)
778
+ putString("thumbnailUrl", item.thumbnailUrl)
779
+
780
+ // Caption tracks as JSON string
781
+ putString("captionTracks", buildCaptionTracksJSONArray(item.captionTracks).toString())
782
+
783
+ // Custom metadata as JSON string
784
+ item.customMetadata?.let { meta ->
785
+ putString("customMetadata", buildCustomMetadataJSONObject(meta).toString())
786
+ }
787
+
788
+ item.author?.let { putString("author", it) }
789
+ item.articleUrl?.let { putString("articleUrl", it) }
790
+ item.commentCount?.let { putInt("commentCount", it) }
791
+ item.fallbackUrl?.let { putString("fallbackUrl", it) }
792
+ }
793
+ }
794
+
795
+ // ------------------------------------------------------------------
796
+ // Flow subscriptions
797
+ // ------------------------------------------------------------------
798
+
799
+ private fun subscribeToFlows(player: ShortKitPlayer) {
800
+ scope?.cancel()
801
+ val newScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
802
+ scope = newScope
803
+
804
+ // Player state
805
+ newScope.launch {
806
+ player.playerState.collect { state ->
807
+ val params = Arguments.createMap().apply {
808
+ putString("state", playerStateString(state))
809
+ if (state is PlayerState.Error) {
810
+ putString("errorMessage", state.message)
811
+ }
812
+ }
813
+ emitEvent.invoke("onPlayerStateChanged", params)
814
+ }
815
+ }
816
+
817
+ // Current item
818
+ newScope.launch {
819
+ player.currentItem.collect { item ->
820
+ if (item != null) {
821
+ emitEvent.invoke("onCurrentItemChanged", contentItemMap(item))
822
+ }
823
+ }
824
+ }
825
+
826
+ // Time updates (ms -> seconds)
827
+ newScope.launch {
828
+ player.time.collect { time ->
829
+ val params = Arguments.createMap().apply {
830
+ putDouble("current", time.currentMs / 1000.0)
831
+ putDouble("duration", time.durationMs / 1000.0)
832
+ putDouble("buffered", time.bufferedMs / 1000.0)
833
+ }
834
+ emitEvent.invoke("onTimeUpdate", params)
835
+ }
836
+ }
837
+
838
+ // Muted state
839
+ newScope.launch {
840
+ player.isMuted.collect { muted ->
841
+ val params = Arguments.createMap().apply {
842
+ putBoolean("isMuted", muted)
843
+ }
844
+ emitEvent.invoke("onMutedChanged", params)
845
+ }
846
+ }
847
+
848
+ // Playback rate
849
+ newScope.launch {
850
+ player.playbackRate.collect { rate ->
851
+ val params = Arguments.createMap().apply {
852
+ putDouble("rate", rate.toDouble())
853
+ }
854
+ emitEvent.invoke("onPlaybackRateChanged", params)
855
+ }
856
+ }
857
+
858
+ // Captions enabled
859
+ newScope.launch {
860
+ player.captionsEnabled.collect { enabled ->
861
+ val params = Arguments.createMap().apply {
862
+ putBoolean("enabled", enabled)
863
+ }
864
+ emitEvent.invoke("onCaptionsEnabledChanged", params)
865
+ }
866
+ }
867
+
868
+ // Active caption track
869
+ newScope.launch {
870
+ player.activeCaptionTrack.collect { track ->
871
+ if (track != null) {
872
+ val params = Arguments.createMap().apply {
873
+ putString("language", track.language)
874
+ putString("label", track.label)
875
+ putString("sourceUrl", track.url ?: "")
876
+ }
877
+ emitEvent.invoke("onActiveCaptionTrackChanged", params)
878
+ }
879
+ }
880
+ }
881
+
882
+ // Active cue (ms -> seconds)
883
+ newScope.launch {
884
+ player.activeCue.collect { cue ->
885
+ if (cue != null) {
886
+ val params = Arguments.createMap().apply {
887
+ putString("text", cue.text)
888
+ putDouble("startTime", cue.startMs / 1000.0)
889
+ putDouble("endTime", cue.endMs / 1000.0)
890
+ }
891
+ emitEvent.invoke("onActiveCueChanged", params)
892
+ }
893
+ }
894
+ }
895
+
896
+ // Did loop
897
+ newScope.launch {
898
+ player.didLoop.collect { event ->
899
+ val params = Arguments.createMap().apply {
900
+ putString("contentId", event.contentId)
901
+ putInt("loopCount", event.loopCount)
902
+ }
903
+ emitEvent.invoke("onDidLoop", params)
904
+ }
905
+ }
906
+
907
+ // Feed transition
908
+ newScope.launch {
909
+ player.feedTransition.collect { event ->
910
+ val params = Arguments.createMap().apply {
911
+ putString("phase", when (event.phase) {
912
+ FeedTransitionPhase.BEGAN -> "began"
913
+ FeedTransitionPhase.ENDED -> "ended"
914
+ })
915
+ putString("direction", when (event.direction) {
916
+ FeedDirection.FORWARD -> "forward"
917
+ FeedDirection.BACKWARD -> "backward"
918
+ else -> "forward"
919
+ })
920
+ if (event.from != null) {
921
+ putString("fromItem", serializeContentItemToJSON(event.from!!))
922
+ }
923
+ if (event.to != null) {
924
+ putString("toItem", serializeContentItemToJSON(event.to!!))
925
+ }
926
+ }
927
+ emitEvent.invoke("onFeedTransition", params)
928
+ }
929
+ }
930
+
931
+ // Format change
932
+ newScope.launch {
933
+ player.formatChange.collect { event ->
934
+ val params = Arguments.createMap().apply {
935
+ putString("contentId", event.contentId)
936
+ putDouble("fromBitrate", event.fromBitrate.toDouble())
937
+ putDouble("toBitrate", event.toBitrate.toDouble())
938
+ putString("fromResolution", event.fromResolution ?: "")
939
+ putString("toResolution", event.toResolution ?: "")
940
+ }
941
+ emitEvent.invoke("onFormatChange", params)
942
+ }
943
+ }
944
+
945
+ // Prefetched ahead count
946
+ newScope.launch {
947
+ player.prefetchedAheadCount.collect { count ->
948
+ val params = Arguments.createMap().apply {
949
+ putInt("count", count)
950
+ }
951
+ emitEvent.invoke("onPrefetchedAheadCountChanged", params)
952
+ }
953
+ }
954
+
955
+ // Remaining content count
956
+ newScope.launch {
957
+ player.remainingContentCount.collect { count ->
958
+ val params = Arguments.createMap().apply {
959
+ putString("feedId", "") // Global fallback — per-feed routing via fragment callback
960
+ putInt("count", count)
961
+ }
962
+ emitEvent.invoke("onRemainingContentCountChanged", params)
963
+ }
964
+ }
965
+
966
+ // Feed scroll phase
967
+ newScope.launch {
968
+ player.feedScrollPhase.collect { phase ->
969
+ val params = Arguments.createMap().apply {
970
+ when (phase) {
971
+ is FeedScrollPhase.Dragging -> {
972
+ putString("phase", "dragging")
973
+ putString("fromId", phase.fromId)
974
+ }
975
+ is FeedScrollPhase.Settled -> {
976
+ putString("phase", "settled")
977
+ }
978
+ }
979
+ }
980
+ emitEvent.invoke("onFeedScrollPhase", params)
981
+ }
982
+ }
983
+ }
984
+ }