@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.
Files changed (35) hide show
  1. package/ShortKitReactNative.podspec +19 -0
  2. package/android/build.gradle.kts +34 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +249 -0
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +32 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +769 -0
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +101 -0
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitPackage.kt +40 -0
  8. package/app.plugin.js +1 -0
  9. package/ios/ShortKitBridge.swift +537 -0
  10. package/ios/ShortKitFeedView.swift +207 -0
  11. package/ios/ShortKitFeedViewManager.mm +29 -0
  12. package/ios/ShortKitModule.h +25 -0
  13. package/ios/ShortKitModule.mm +204 -0
  14. package/ios/ShortKitOverlayBridge.swift +91 -0
  15. package/ios/ShortKitReactNative-Bridging-Header.h +3 -0
  16. package/ios/ShortKitReactNative.podspec +19 -0
  17. package/package.json +50 -0
  18. package/plugin/build/index.d.ts +3 -0
  19. package/plugin/build/index.js +13 -0
  20. package/plugin/build/withShortKitAndroid.d.ts +8 -0
  21. package/plugin/build/withShortKitAndroid.js +32 -0
  22. package/plugin/build/withShortKitIOS.d.ts +8 -0
  23. package/plugin/build/withShortKitIOS.js +29 -0
  24. package/react-native.config.js +8 -0
  25. package/src/OverlayManager.tsx +87 -0
  26. package/src/ShortKitContext.ts +51 -0
  27. package/src/ShortKitFeed.tsx +203 -0
  28. package/src/ShortKitProvider.tsx +526 -0
  29. package/src/index.ts +26 -0
  30. package/src/serialization.ts +95 -0
  31. package/src/specs/NativeShortKitModule.ts +201 -0
  32. package/src/specs/ShortKitFeedViewNativeComponent.ts +13 -0
  33. package/src/types.ts +167 -0
  34. package/src/useShortKit.ts +20 -0
  35. 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
+ }