@shortkitsdk/react-native 0.2.35 → 0.2.37

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 (43) hide show
  1. package/android/build.gradle.kts +8 -0
  2. package/android/libs/shortkit-release.aar +0 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +94 -46
  4. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +46 -7
  5. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +233 -27
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +252 -27
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +135 -6
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +15 -0
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +21 -11
  10. package/android/src/test/java/com/shortkit/reactnative/ReactCarouselOverlayHostEmitTest.kt +134 -0
  11. package/android/src/test/java/com/shortkit/reactnative/ReactOverlayHostDragTest.kt +45 -0
  12. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostDragTest.kt +69 -0
  13. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostEmitTest.kt +144 -0
  14. package/android/src/test/java/com/shortkit/reactnative/ShortKitFeedViewActivePropTest.kt +57 -0
  15. package/ios/ReactOverlayHost.swift +10 -8
  16. package/ios/ReactVideoCarouselOverlayHost.swift +14 -11
  17. package/ios/ShortKitBridge.swift +18 -0
  18. package/ios/ShortKitModule.mm +5 -0
  19. package/ios/ShortKitPlayerNativeView.swift +36 -0
  20. package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  22. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +932 -84
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +26 -2
  24. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +26 -2
  26. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +932 -84
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +26 -2
  31. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  32. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +26 -2
  33. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +932 -84
  34. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +26 -2
  35. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  36. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +26 -2
  37. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  38. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  39. package/package.json +1 -1
  40. package/src/ShortKitCommands.ts +20 -0
  41. package/src/ShortKitFeed.tsx +21 -0
  42. package/src/specs/NativeShortKitModule.ts +10 -0
  43. package/src/types.ts +35 -0
@@ -76,6 +76,70 @@ class ShortKitBridge(
76
76
  // Static serialization helpers (called by overlay hosts)
77
77
  // ------------------------------------------------------------------
78
78
 
79
+ /**
80
+ * Serialize a [VideoCarouselItem] to a JSON string for bridge transport.
81
+ * SHO-15: used by ReactVideoCarouselOverlayHost when emitting
82
+ * onCarouselActiveVideoCompleted. Mirrors how
83
+ * ReactVideoCarouselOverlayHost.configure already serializes
84
+ * VideoCarouselItem via kotlinx.serialization.
85
+ */
86
+ fun serializeVideoCarouselItemToJSON(item: com.shortkit.sdk.model.VideoCarouselItem?): String {
87
+ if (item == null) return "null"
88
+ return try {
89
+ kotlinx.serialization.json.Json.encodeToString(
90
+ com.shortkit.sdk.model.VideoCarouselItem.serializer(),
91
+ item,
92
+ )
93
+ } catch (_: Exception) {
94
+ "null"
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Serialize a [FeedItem] to a ContentItem-shaped JSON string for the
100
+ * `onFeedTransition` event. For [FeedItem.Content] cells this is the
101
+ * real ContentItem. For every other cell kind (AdSlotItem,
102
+ * ImageCarousel, Survey, VideoCarousel) there is no playable
103
+ * ContentItem, so we synthesize a minimal one whose ONLY meaningful
104
+ * field is `id`.
105
+ *
106
+ * Why: JS hosts use `event.to` as an identity cursor for resume-on-
107
+ * tab-return (`setFeedItems(startAt:)`). Before this synthesis, non-
108
+ * content cells came through as `to=null` and the host's stored
109
+ * resume id stayed pinned to the last regular video — causing the
110
+ * SDK to re-seed the feed at the wrong index on tab return.
111
+ *
112
+ * Scope: this is a bridge-only synthesis. It never flows back into
113
+ * the Android SDK (pool, cache, FeedDataSource, etc. all still see
114
+ * the real FeedItem and treat non-content cells honestly). JS-side
115
+ * consumers should ONLY read `.id` / `.playbackId` from
116
+ * `onFeedTransition` items — deeper fields are placeholders for
117
+ * non-content cells.
118
+ *
119
+ * Mirrors iOS PR #170 ShortKitBridge.swift:770-790.
120
+ */
121
+ fun serializeFeedItemIdentityJSON(feedItem: com.shortkit.sdk.model.FeedItem): String {
122
+ if (feedItem is com.shortkit.sdk.model.FeedItem.Content) {
123
+ return serializeContentItemToJSON(feedItem.item)
124
+ }
125
+ val synthetic = ContentItem(
126
+ id = feedItem.id,
127
+ playbackId = null,
128
+ title = "",
129
+ description = null,
130
+ duration = 0.0,
131
+ streamingUrl = "",
132
+ thumbnailUrl = "",
133
+ captionTracks = emptyList(),
134
+ customMetadata = null,
135
+ author = null,
136
+ articleUrl = null,
137
+ commentCount = null,
138
+ fallbackUrl = null,
139
+ )
140
+ return serializeContentItemToJSON(synthetic)
141
+ }
142
+
79
143
  /**
80
144
  * Serialize a [ContentItem] to a JSON string for bridge transport.
81
145
  */
@@ -349,7 +413,7 @@ class ShortKitBridge(
349
413
  } catch (_: Exception) { null }
350
414
  }
351
415
 
352
- private fun parseFeedInputs(json: String): List<FeedInput>? {
416
+ internal fun parseFeedInputs(json: String): List<FeedInput>? {
353
417
  return try {
354
418
  val arr = JSONArray(json)
355
419
  val result = mutableListOf<FeedInput>()
@@ -535,7 +599,12 @@ class ShortKitBridge(
535
599
  // Wire delegate
536
600
  sdk.delegate = object : ShortKitDelegate {
537
601
  override fun onContentTapped(contentId: String, index: Int) {
602
+ // Capture feedId synchronously — after the post-to-main hop
603
+ // the active surface may have shifted (user swiped tabs).
604
+ // Mirrors iOS ShortKitBridge.swift:1174 pattern.
605
+ val feedId = activeSurfaceFeedId()
538
606
  val params = Arguments.createMap().apply {
607
+ putString("feedId", feedId)
539
608
  putString("contentId", contentId)
540
609
  putInt("index", index)
541
610
  }
@@ -550,7 +619,9 @@ class ShortKitBridge(
550
619
  lastProgressEmitTime = now
551
620
  }
552
621
 
622
+ val feedId = activeSurfaceFeedId()
553
623
  val params = Arguments.createMap().apply {
624
+ putString("feedId", feedId)
554
625
  when (state) {
555
626
  is ShortKitRefreshState.Idle -> {
556
627
  putString("status", "idle")
@@ -574,11 +645,13 @@ class ShortKitBridge(
574
645
  }
575
646
 
576
647
  override fun onFeedContentFetched(items: List<ContentItem>) {
648
+ val feedId = activeSurfaceFeedId()
577
649
  val arr = org.json.JSONArray()
578
650
  for (item in items) {
579
651
  arr.put(org.json.JSONObject(serializeContentItemToJSON(item)))
580
652
  }
581
653
  val params = Arguments.createMap().apply {
654
+ putString("feedId", feedId)
582
655
  putString("items", arr.toString())
583
656
  }
584
657
  emitEventOnMain("onDidFetchContentItems", params)
@@ -640,6 +713,57 @@ class ShortKitBridge(
640
713
  }
641
714
  emitEventOnMain("onFeedReady", params)
642
715
  }
716
+ // Per-feed pull-to-refresh — the JS-side ShortKitFeed subscribes
717
+ // to `onRefreshStateChangedPerFeed` (not the unscoped `onRefreshStateChanged`),
718
+ // so this is the event that actually drives the host's refresh
719
+ // handler. Mirrors iOS ShortKitBridge.swift:74-90 emit pattern.
720
+ fragment.onRefreshStateChanged = { state ->
721
+ // Throttle pulling events to max 1 per 16ms to avoid bridge saturation
722
+ if (state is ShortKitRefreshState.Pulling) {
723
+ val now = android.os.SystemClock.uptimeMillis()
724
+ if (now - lastProgressEmitTime >= 16L) {
725
+ lastProgressEmitTime = now
726
+ emitRefreshPerFeed(id, state)
727
+ }
728
+ } else {
729
+ emitRefreshPerFeed(id, state)
730
+ }
731
+ }
732
+ fragment.onVideoCarouselCellTap = { payload ->
733
+ emitVideoCarouselCellTap(payload.id, payload.index, payload.pageIndex)
734
+ }
735
+ // Per-feed transition event. Fires once per transition, bound to this
736
+ // fragment. Replaces the global player.feedTransition.collect pattern
737
+ // (which fanned every feed's transitions out to every mounted
738
+ // <ShortKitFeed>, causing cross-feed state pollution). Structural fix:
739
+ // callback literally cannot fire on the wrong feed. Mirrors iOS
740
+ // PR #170 ShortKitBridge.swift:117-143.
741
+ fragment.onFeedTransition = { event ->
742
+ val params = Arguments.createMap().apply {
743
+ putString("feedId", id)
744
+ putString("phase", when (event.phase) {
745
+ FeedTransitionPhase.BEGAN -> "began"
746
+ FeedTransitionPhase.ENDED -> "ended"
747
+ })
748
+ putString("direction", when (event.direction) {
749
+ FeedDirection.FORWARD -> "forward"
750
+ FeedDirection.BACKWARD -> "backward"
751
+ else -> "forward"
752
+ })
753
+ // Serialize from the underlying FeedItem (not event.from/to
754
+ // which are null for non-content cells). See the docstring on
755
+ // serializeFeedItemIdentityJSON for the why — in short, this
756
+ // ensures carousels/ads/surveys come through with a populated
757
+ // id, so JS-side hosts can track feed position for any cell type.
758
+ event.fromFeedItem?.let {
759
+ putString("fromItem", serializeFeedItemIdentityJSON(it))
760
+ }
761
+ event.toFeedItem?.let {
762
+ putString("toItem", serializeFeedItemIdentityJSON(it))
763
+ }
764
+ }
765
+ emitEventOnMain("onFeedTransition", params)
766
+ }
643
767
 
644
768
  // Replay buffered operations after the fragment's view is created.
645
769
  // commitNowAllowingStateLoss triggers onCreate but onViewCreated
@@ -669,6 +793,61 @@ class ShortKitBridge(
669
793
  return feedRegistry[id]?.get()
670
794
  }
671
795
 
796
+ /**
797
+ * Emit `onRefreshStateChangedPerFeed` with feedId in the payload.
798
+ * Used by the per-feed [ShortKitFeedFragment.onRefreshStateChanged]
799
+ * hook wired in [registerFeedFragment]. Mirrors iOS
800
+ * `ShortKitBridge.swift:74-90` body construction.
801
+ */
802
+ private fun emitRefreshPerFeed(feedId: String, state: ShortKitRefreshState) {
803
+ val params = Arguments.createMap().apply {
804
+ putString("feedId", feedId)
805
+ when (state) {
806
+ is ShortKitRefreshState.Idle -> {
807
+ putString("status", "idle")
808
+ putDouble("progress", 0.0)
809
+ }
810
+ is ShortKitRefreshState.Pulling -> {
811
+ putString("status", "pulling")
812
+ putDouble("progress", state.progress.toDouble())
813
+ }
814
+ is ShortKitRefreshState.Triggered -> {
815
+ putString("status", "triggered")
816
+ putDouble("progress", 0.0)
817
+ }
818
+ is ShortKitRefreshState.Refreshing -> {
819
+ putString("status", "refreshing")
820
+ putDouble("progress", 0.0)
821
+ }
822
+ }
823
+ }
824
+ emitEventOnMain("onRefreshStateChangedPerFeed", params)
825
+ }
826
+
827
+ /**
828
+ * Reverse lookup: which registered feedId corresponds to the SDK's
829
+ * currently-active surface? Returns the empty string when no feed
830
+ * surface is active or when the active surface isn't a registered
831
+ * fragment (e.g. host-driven scenario without RN bridge mounting).
832
+ *
833
+ * Used by the three delegate-level emit sites
834
+ * (`onContentTapped`, `onRefreshStateChanged`, `onDidFetchContentItems`)
835
+ * so multi-feed setups route the callback to the originating
836
+ * `<ShortKitFeed>` instead of broadcasting to all mounted feeds.
837
+ *
838
+ * Mirrors iOS `ShortKitBridge.activeSurfaceFeedId()`
839
+ * (`react_native_sdk/ios/ShortKitBridge.swift:1174`). Capture this
840
+ * synchronously at delegate-call time; after any await/post the
841
+ * active surface may have shifted (user swiped tabs).
842
+ */
843
+ fun activeSurfaceFeedId(): String {
844
+ val active = shortKit?.activeSurface ?: return ""
845
+ for ((id, ref) in feedRegistry) {
846
+ if (ref.get() === active) return id
847
+ }
848
+ return ""
849
+ }
850
+
672
851
  fun registerFeed(id: String) {
673
852
  // No-op — registerFeedFragment handles drain
674
853
  }
@@ -735,6 +914,25 @@ class ShortKitBridge(
735
914
  emitEvent.invoke(name, params)
736
915
  }
737
916
 
917
+ /**
918
+ * Emit `onVideoCarouselCellTap` with the active surface's feedId and
919
+ * the cell-supplied (id, index, pageIndex). Mirrors iOS
920
+ * ShortKitBridge.swift:94-101.
921
+ *
922
+ * Called from the cell's onCellTap closure (installed in
923
+ * ShortKitFeedFragment) — see Task 5.
924
+ */
925
+ internal fun emitVideoCarouselCellTap(itemId: String, index: Int, pageIndex: Int) {
926
+ val feedId = activeSurfaceFeedId()
927
+ val params = Arguments.createMap().apply {
928
+ putString("feedId", feedId)
929
+ putString("id", itemId)
930
+ putInt("index", index)
931
+ putInt("pageIndex", pageIndex)
932
+ }
933
+ emitEventOnMain("onVideoCarouselCellTap", params)
934
+ }
935
+
738
936
  // ------------------------------------------------------------------
739
937
  // Player commands
740
938
  // ------------------------------------------------------------------
@@ -753,6 +951,24 @@ class ShortKitBridge(
753
951
  fun skipToNext() { runOnMain { shortKit?.player?.skipToNext() } }
754
952
  fun skipToPrevious() { runOnMain { shortKit?.player?.skipToPrevious() } }
755
953
  fun setMuted(muted: Boolean) { runOnMain { shortKit?.player?.setMuted(muted) } }
954
+
955
+ /**
956
+ * Toggle outer feed scroll on every registered feed. Note this
957
+ * intentionally diverges from iOS: iOS routes the same command only
958
+ * to the *active* feed (via player.onCommand), but Android fans out
959
+ * to all registered feeds — including masked-feed underlays. The
960
+ * user-visible effect is the same (the active feed's scroll is
961
+ * locked) and fanning out costs nothing, avoiding an active-surface
962
+ * lookup at this layer. Must run on main: viewPager.isUserInputEnabled
963
+ * mutates RecyclerView state, and feedRegistry is mutated on main
964
+ * during register/unregister — concurrent iteration would race.
965
+ */
966
+ fun setFeedScrollEnabled(enabled: Boolean) = runOnMain {
967
+ feedRegistry.values.forEach { ref ->
968
+ ref.get()?.setFeedScrollEnabled(enabled)
969
+ }
970
+ }
971
+
756
972
  fun setPlaybackRate(rate: Double) { runOnMain { shortKit?.player?.setPlaybackRate(rate.toFloat()) } }
757
973
  fun setCaptionsEnabled(enabled: Boolean) { runOnMain { shortKit?.player?.setCaptionsEnabled(enabled) } }
758
974
  fun selectCaptionTrack(language: String) { runOnMain { shortKit?.player?.selectCaptionTrack(language) } }
@@ -762,6 +978,17 @@ class ShortKitBridge(
762
978
  }
763
979
  fun setMaxBitrate(bitrate: Double) { runOnMain { shortKit?.player?.setMaxBitrate(bitrate.toInt()) } }
764
980
 
981
+ // ---- SHO-15: ShortKitCarousel imperative methods + sync accessors ----
982
+ // ShortKitCarousel's onMain shim handles cross-thread routing internally,
983
+ // so we DON'T wrap with bridge.runOnMain — the namespace's threading
984
+ // contract already routes to main + blocks on the latch when called from
985
+ // a background thread.
986
+ fun carouselNext(): Boolean = shortKit?.carousel?.next() ?: false
987
+ fun carouselPrevious(): Boolean = shortKit?.carousel?.previous() ?: false
988
+ fun carouselSetActiveIndex(index: Int): Boolean = shortKit?.carousel?.setActiveIndex(index) ?: false
989
+ fun carouselActiveIndex(): Int = shortKit?.carousel?.activeIndexValue ?: -1
990
+ fun carouselVideoCount(): Int = shortKit?.carousel?.videoCountValue ?: 0
991
+
765
992
  fun setUserId(userId: String) {
766
993
  shortKit?.setUserId(userId)
767
994
  }
@@ -1090,10 +1317,16 @@ class ShortKitBridge(
1090
1317
  }
1091
1318
  }
1092
1319
 
1093
- // Did loop
1320
+ // Did loop — tagged with active surface's feedId. player.didLoop is a
1321
+ // singleton flow on the shared ShortKitPlayer (only one item plays at
1322
+ // a time). The event semantically belongs to whichever feed owns the
1323
+ // active surface; tag with that feedId so JS consumers bound to
1324
+ // specific feeds can filter. Empty string means no active surface
1325
+ // (treat as unknown/global). Mirrors iOS bridge :615-631.
1094
1326
  newScope.launch {
1095
1327
  player.didLoop.collect { event ->
1096
1328
  val params = Arguments.createMap().apply {
1329
+ putString("feedId", activeSurfaceFeedId())
1097
1330
  putString("contentId", event.contentId)
1098
1331
  putInt("loopCount", event.loopCount)
1099
1332
  }
@@ -1101,34 +1334,26 @@ class ShortKitBridge(
1101
1334
  }
1102
1335
  }
1103
1336
 
1104
- // Feed transition
1105
- newScope.launch {
1106
- player.feedTransition.collect { event ->
1107
- val params = Arguments.createMap().apply {
1108
- putString("phase", when (event.phase) {
1109
- FeedTransitionPhase.BEGAN -> "began"
1110
- FeedTransitionPhase.ENDED -> "ended"
1111
- })
1112
- putString("direction", when (event.direction) {
1113
- FeedDirection.FORWARD -> "forward"
1114
- FeedDirection.BACKWARD -> "backward"
1115
- else -> "forward"
1116
- })
1117
- if (event.from != null) {
1118
- putString("fromItem", serializeContentItemToJSON(event.from!!))
1119
- }
1120
- if (event.to != null) {
1121
- putString("toItem", serializeContentItemToJSON(event.to!!))
1122
- }
1123
- }
1124
- emitEvent.invoke("onFeedTransition", params)
1125
- }
1126
- }
1127
-
1128
- // Format change
1337
+ // NOTE: the global `player.feedTransition.collect` subscription used
1338
+ // to live here. It's been removed — feed transitions are now emitted
1339
+ // to RN via the per-fragment `fragment.onFeedTransition` closure
1340
+ // wired in `registerFeedFragment` above. This eliminates the cross-
1341
+ // feed routing bug where every mounted <ShortKitFeed> consumer
1342
+ // received every feed's transitions. Mirrors iOS PR #170
1343
+ // ShortKitBridge.swift:633-642.
1344
+ //
1345
+ // `player.sendFeedTransition` is still called by ShortKitFeedFragment
1346
+ // for backward compatibility with native Kotlin SDK consumers that
1347
+ // subscribe to `ShortKitPlayer.feedTransition` directly.
1348
+
1349
+ // Format change — tagged with active surface's feedId. Same reasoning
1350
+ // as onDidLoop: fires from shared player singleton, but semantically
1351
+ // belongs to whichever feed owns the active surface. Mirrors iOS
1352
+ // bridge :671-688.
1129
1353
  newScope.launch {
1130
1354
  player.formatChange.collect { event ->
1131
1355
  val params = Arguments.createMap().apply {
1356
+ putString("feedId", activeSurfaceFeedId())
1132
1357
  putString("contentId", event.contentId)
1133
1358
  putDouble("fromBitrate", event.fromBitrate.toDouble())
1134
1359
  putDouble("toBitrate", event.toBitrate.toDouble())
@@ -30,6 +30,15 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
30
30
  var feedId: String? = null
31
31
  var startAtItemId: String? = null
32
32
  var preloadId: String? = null
33
+ /**
34
+ * JSON-encoded `FeedInput[]` from the `feedItems` JS prop. Parsed at
35
+ * fragment-construction time only — post-mount changes are a no-op
36
+ * (matches iOS PR #150). Host apps that need post-mount item updates
37
+ * use the imperative `ShortKit.setFeedItems(feedId, items)` API.
38
+ *
39
+ * `preloadId` takes precedence when both are set.
40
+ */
41
+ var feedItemsJSON: String? = null
33
42
  // Fabric's generated delegate maps an absent boolean prop to the Java
34
43
  // primitive false, so Boolean? is always non-null from the bridge. Track
35
44
  // whether the prop was explicitly set so we can fall back to the provider
@@ -37,6 +46,62 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
37
46
  var debugPanel: Boolean = false
38
47
  var debugPanelPropSet: Boolean = false
39
48
 
49
+ /**
50
+ * URL of the thumbnail the host app is already rendering for the item that
51
+ * will be the first active cell. Looked up synchronously via Glide's cache
52
+ * (10ms cap; supports expo-image / FastImage clients) and seeded onto the
53
+ * fragment before its first lifecycle event. Falls back to the existing
54
+ * patchMissingThumbnailIfNeeded helper on cache miss or timeout.
55
+ *
56
+ * Mirrors iOS ShortKitFeedView.seedThumbnailUrl
57
+ * (react_native_sdk/ios/ShortKitFeedView.swift:28).
58
+ */
59
+ var seedThumbnailUrl: String? = null
60
+
61
+ /**
62
+ * Host-driven activation state, set by the `active` JS prop. Defaults to
63
+ * `true`. Mirrors iOS [`ShortKitFeedView.active`](react_native_sdk/ios/ShortKitFeedView.swift:46).
64
+ */
65
+ var active: Boolean = true
66
+ internal set
67
+
68
+ /**
69
+ * Tracks whether `active` was explicitly set by the host (vs. defaulted).
70
+ * Same purpose as `debugPanelPropSet` — distinguishes "absent" from "false"
71
+ * since Fabric maps absent boolean props to the Java primitive `false`.
72
+ *
73
+ * Used only by the first-mount race fix in [embedFeedFragmentIfNeeded] to
74
+ * decide whether a `false` value is a real host intent or a Fabric default.
75
+ */
76
+ var activePropSet: Boolean = false
77
+ internal set
78
+
79
+ /**
80
+ * Set the `active` prop from the bridge. The canonical entry point for
81
+ * all `active` state transitions — do not mutate the field directly.
82
+ *
83
+ * Behavior:
84
+ * - Always sets `activePropSet = true`.
85
+ * - On transition while a fragment is embedded:
86
+ * - `true → false`: calls `fragment.deactivate()`.
87
+ * - `false → true`: calls `fragment.activate()`.
88
+ * - Returns early if no fragment is yet embedded — the first-mount race
89
+ * in [embedFeedFragmentIfNeeded] handles the deferred deactivate case.
90
+ *
91
+ * Invoked by [ShortKitFeedViewManager.setActive].
92
+ */
93
+ fun setActiveFromBridge(value: Boolean) {
94
+ val prev = active
95
+ active = value
96
+ activePropSet = true
97
+ val fragment = feedFragment ?: return
98
+ if (prev && !value) {
99
+ fragment.deactivate()
100
+ } else if (!prev && value) {
101
+ fragment.activate()
102
+ }
103
+ }
104
+
40
105
  // -----------------------------------------------------------------------
41
106
  // Fragment management
42
107
  // -----------------------------------------------------------------------
@@ -140,7 +205,15 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
140
205
  synchronized(ShortKitBridge.staticPendingFeedViews) {
141
206
  ShortKitBridge.staticPendingFeedViews.remove(this)
142
207
  }
143
- suspendFeedFragment()
208
+ // Mirror iOS willMove(toWindow:) at react_native_sdk/ios/ShortKitFeedView.swift:91-99 —
209
+ // suspend only if leaving the window AND the prop says we should be active.
210
+ // When active=false the host has already driven the fragment into deactivate
211
+ // (or will once setActiveFromBridge runs), and we mustn't double-suspend.
212
+ // Default active=true preserves legacy window-inference behavior for hosts
213
+ // that never set the active prop.
214
+ if (active) {
215
+ suspendFeedFragment()
216
+ }
144
217
  super.onDetachedFromWindow()
145
218
  }
146
219
 
@@ -167,7 +240,11 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
167
240
  .beginTransaction()
168
241
  .show(existingFragment)
169
242
  .commitNowAllowingStateLoss()
170
- existingFragment.activate()
243
+ // Mirror iOS: only auto-activate if the host's active prop says so.
244
+ // Default active=true preserves legacy "always activate on un-hide" behavior.
245
+ if (active) {
246
+ existingFragment.activate()
247
+ }
171
248
  } catch (e: Exception) {
172
249
  Log.e(TAG, "Failed to show feed fragment", e)
173
250
  }
@@ -188,24 +265,58 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
188
265
 
189
266
  var feedConfig = ShortKitBridge.parseFeedConfig(config ?: "{}", context)
190
267
 
191
- preloadId?.let { id ->
192
- val preload = ShortKitBridge.shared?.consumePreload(id)
268
+ val explicitPreloadId = preloadId
269
+ val explicitFeedItemsJSON = feedItemsJSON
270
+ if (explicitPreloadId != null) {
271
+ val preload = ShortKitBridge.shared?.consumePreload(explicitPreloadId)
193
272
  if (preload != null) {
194
273
  feedConfig = feedConfig.copy(preload = preload)
195
274
  }
275
+ } else if (explicitFeedItemsJSON != null) {
276
+ // Mount-time custom-feed fast-path (SHO-29 / iOS PR #150).
277
+ // Parse JSON → FeedInput[] → sdk.preloadFeed(items=) which
278
+ // internally calls FeedPreload.forCustomItems with the SDK's
279
+ // own scope. Assign to feedConfig.preload BEFORE constructing
280
+ // the fragment, so FeedPreload.consume() returns synchronously
281
+ // on the first cell bind. Requires Task 1's sync-payload fix
282
+ // in FeedPreload.kt.
283
+ //
284
+ // Using sdk.preloadFeed(items=) (the public SDK entry point at
285
+ // ShortKit.kt:232) instead of FeedPreload.forCustomItems
286
+ // directly — avoids needing to promote internal SDK helpers
287
+ // for cross-module access from the bridge.
288
+ val parsed = ShortKitBridge.parseFeedInputs(explicitFeedItemsJSON)
289
+ if (parsed != null && parsed.isNotEmpty()) {
290
+ val preload = sdk.preloadFeed(items = parsed)
291
+ feedConfig = feedConfig.copy(preload = preload)
292
+ } else {
293
+ Log.w(TAG, "feedItemsJSON parsed to null/empty — ignoring (config feedSource will drive the fetch path)")
294
+ }
196
295
  }
197
296
 
198
297
  val fragment = ShortKitFeedFragment.newInstance(sdk, feedConfig, startAtItemId)
199
298
 
200
299
  val debugPanelEnabled = if (debugPanelPropSet) debugPanel else sdk.debugPanelEnabled
201
300
  if (debugPanelEnabled) {
202
- fragment.debugPanelFactory = { active, prev, next, lifecycleOwner ->
301
+ fragment.debugPanelFactory = { activeMetrics, prev, next, lifecycleOwner ->
203
302
  com.shortkit.sdk.debug.DebugPanelView(context).also { panel ->
204
- panel.subscribe(active, prev, next, lifecycleOwner)
303
+ panel.subscribe(activeMetrics, prev, next, lifecycleOwner)
205
304
  }
206
305
  }
207
306
  }
208
307
 
308
+ // SHO-11: seed a thumbnail from the host app's image cache so the first
309
+ // cell renders with a visible thumbnail from frame zero. Synchronous
310
+ // Glide lookup (memory-only via 10ms cap) — no network. If null (cache
311
+ // miss, timeout, or Glide not linked), the existing
312
+ // patchMissingThumbnailIfNeeded helper handles the fallback at feed-open
313
+ // time. Mirrors iOS ShortKitFeedView.swift:170-173.
314
+ seedThumbnailUrl?.let { url ->
315
+ com.shortkit.sdk.feed.SeedThumbnailResolver.resolveFromCache(context, url)?.let { bitmap ->
316
+ fragment.seedThumbnail = bitmap
317
+ }
318
+ }
319
+
209
320
  try {
210
321
  activity.supportFragmentManager
211
322
  .beginTransaction()
@@ -213,6 +324,24 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
213
324
  .commitNowAllowingStateLoss()
214
325
  this.feedFragment = fragment
215
326
 
327
+ // Android-specific first-mount race: Fabric may have set active=false
328
+ // BEFORE we got to embed the fragment. The fragment self-claims the
329
+ // player pool in onResume (ShortKitFeedFragment.kt:2112), so by the
330
+ // time commitNowAllowingStateLoss returns the fragment is already
331
+ // active. Retroactively deactivate to honor the host's intent.
332
+ //
333
+ // Note: this produces a brief claim-then-yield window (microseconds).
334
+ // Acceptable for normal use; if a host needs strictly never-active
335
+ // mounts, the fragment's onResume self-claim would need a managed-mode
336
+ // flag to suppress (separate ticket).
337
+ //
338
+ // activePropSet guard: only fires when the host explicitly set the prop.
339
+ // Fabric maps absent booleans to Java false, so without this guard
340
+ // every fragment would deactivate at mount.
341
+ if (activePropSet && !active) {
342
+ fragment.deactivate()
343
+ }
344
+
216
345
  // View.generateViewId() can return IDs that collide with Fabric-
217
346
  // managed views (e.g. ReactSurfaceView also gets id=1). When that
218
347
  // happens, FragmentManager.replace() places the fragment view in
@@ -35,12 +35,27 @@ class ShortKitFeedViewManager : SimpleViewManager<ShortKitFeedView>() {
35
35
  view.preloadId = preloadId
36
36
  }
37
37
 
38
+ @ReactProp(name = "feedItemsJSON")
39
+ fun setFeedItemsJSON(view: ShortKitFeedView, feedItemsJSON: String?) {
40
+ view.feedItemsJSON = feedItemsJSON
41
+ }
42
+
38
43
  @ReactProp(name = "debugPanel")
39
44
  fun setDebugPanel(view: ShortKitFeedView, debugPanel: Boolean) {
40
45
  view.debugPanel = debugPanel
41
46
  view.debugPanelPropSet = true
42
47
  }
43
48
 
49
+ @ReactProp(name = "active")
50
+ fun setActive(view: ShortKitFeedView, active: Boolean) {
51
+ view.setActiveFromBridge(active)
52
+ }
53
+
54
+ @ReactProp(name = "seedThumbnailUrl")
55
+ fun setSeedThumbnailUrl(view: ShortKitFeedView, url: String?) {
56
+ view.seedThumbnailUrl = url
57
+ }
58
+
44
59
  override fun onDropViewInstance(view: ShortKitFeedView) {
45
60
  view.destroy()
46
61
  super.onDropViewInstance(view)
@@ -136,6 +136,11 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
136
136
  @ReactMethod
137
137
  override fun pause() { bridge?.pause() }
138
138
 
139
+ @ReactMethod
140
+ override fun setFeedScrollEnabled(enabled: Boolean) {
141
+ bridge?.setFeedScrollEnabled(enabled)
142
+ }
143
+
139
144
  @ReactMethod
140
145
  override fun seek(seconds: Double) { bridge?.seek(seconds) }
141
146
 
@@ -149,28 +154,30 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
149
154
  override fun skipToPrevious() { bridge?.skipToPrevious() }
150
155
 
151
156
  // -----------------------------------------------------------------
152
- // Carousel commands — stubs for PR 1.
153
- // TODO: PR 2 replace these stubs with real bridge to ShortKit.activeInstance.get().carousel
157
+ // Carousel commands — bridge to ShortKit.activeInstance().carousel.
158
+ // Mirrors iOS RN bridge from PR #136. SHO-15.
159
+ // Boundary semantics: returns false / -1 / 0 when no ShortKit instance
160
+ // is active or no fragment is bound, matching iOS `?? false` / `?? nil`
161
+ // fallbacks via the namespace's onMain shim + null-tolerance.
154
162
  // -----------------------------------------------------------------
155
163
 
156
164
  @ReactMethod(isBlockingSynchronousMethod = true)
157
- override fun carouselNext(): Boolean = false
165
+ override fun carouselNext(): Boolean = bridge?.carouselNext() ?: false
158
166
 
159
167
  @ReactMethod(isBlockingSynchronousMethod = true)
160
- override fun carouselPrevious(): Boolean = false
168
+ override fun carouselPrevious(): Boolean = bridge?.carouselPrevious() ?: false
161
169
 
162
170
  @ReactMethod(isBlockingSynchronousMethod = true)
163
- override fun carouselSetActiveIndex(index: Double): Boolean = false
164
-
165
- // Carousel accessors — stubs for PR 1.
166
- // onCarouselActiveVideoCompleted emitter intentionally unwired — never fires on Android in PR 1.
167
- // PR 2 will subscribe to ShortKit.activeInstance.get().carousel.activeVideoCompleted.
171
+ override fun carouselSetActiveIndex(index: Double): Boolean =
172
+ bridge?.carouselSetActiveIndex(index.toInt()) ?: false
168
173
 
169
174
  @ReactMethod(isBlockingSynchronousMethod = true)
170
- override fun getCarouselActiveIndex(): Double = -1.0
175
+ override fun getCarouselActiveIndex(): Double =
176
+ (bridge?.carouselActiveIndex() ?: -1).toDouble()
171
177
 
172
178
  @ReactMethod(isBlockingSynchronousMethod = true)
173
- override fun getCarouselVideoCount(): Double = 0.0
179
+ override fun getCarouselVideoCount(): Double =
180
+ (bridge?.carouselVideoCount() ?: 0).toDouble()
174
181
 
175
182
  @ReactMethod
176
183
  override fun setMuted(muted: Boolean) { bridge?.setMuted(muted) }
@@ -326,6 +333,7 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
326
333
  "onContentTapped" -> emitOnContentTapped(params)
327
334
  "onDismiss" -> emitOnDismiss(params)
328
335
  "onRefreshStateChanged" -> emitOnRefreshStateChanged(params)
336
+ "onRefreshStateChangedPerFeed" -> emitOnRefreshStateChangedPerFeed(params)
329
337
  "onDidFetchContentItems" -> emitOnDidFetchContentItems(params)
330
338
  "onFeedReady" -> emitOnFeedReady(params)
331
339
  "onOverlayActiveChanged" -> emitOnOverlayActiveChanged(params)
@@ -341,8 +349,10 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
341
349
  "onCarouselActiveImageChanged" -> emitOnCarouselActiveImageChanged(params)
342
350
  "onCarouselItemChanged" -> emitOnCarouselItemChanged(params)
343
351
  "onVideoCarouselActiveVideoChanged" -> emitOnVideoCarouselActiveVideoChanged(params)
352
+ "onVideoCarouselCellTap" -> emitOnVideoCarouselCellTap(params)
344
353
  "onVideoCarouselItemChanged" -> emitOnVideoCarouselItemChanged(params)
345
354
  "onCarouselActiveVideoCompleted" -> emitOnCarouselActiveVideoCompleted(params)
355
+ "onVideoCarouselCellTap" -> emitOnVideoCarouselCellTap(params)
346
356
  else -> {
347
357
  android.util.Log.w("SK:Module", "sendEvent: unknown event name '$name', using legacy emitter")
348
358
  reactApplicationContext