@shortkitsdk/react-native 0.2.6 → 0.2.12

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