@shortkitsdk/react-native 0.2.11 → 0.2.14

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 (57) hide show
  1. package/android/build.gradle.kts +13 -1
  2. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +157 -54
  3. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +67 -56
  4. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +431 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +154 -26
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +160 -35
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +5 -0
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +45 -10
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +9 -0
  10. package/ios/ReactCarouselOverlayHost.swift +37 -17
  11. package/ios/ReactOverlayHost.swift +33 -35
  12. package/ios/ReactVideoCarouselOverlayHost.swift +283 -0
  13. package/ios/ShortKitBridge.swift +78 -2
  14. package/ios/ShortKitFeedView.swift +24 -3
  15. package/ios/ShortKitModule.mm +6 -2
  16. package/ios/ShortKitSDK.xcframework/Info.plist +4 -4
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +2597 -389
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +69 -5
  19. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  20. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +69 -5
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  22. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  23. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +2597 -389
  24. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +69 -5
  25. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +69 -5
  27. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  28. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  29. package/ios/ShortKitSDK.xcframework.bak2/Info.plist +43 -0
  30. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  31. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
  32. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +31351 -0
  33. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +865 -0
  34. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  35. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +865 -0
  36. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  37. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  38. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  39. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
  40. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +31351 -0
  41. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +865 -0
  42. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  43. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +865 -0
  44. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  45. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  46. package/package.json +1 -1
  47. package/src/ShortKitCarouselOverlaySurface.tsx +57 -2
  48. package/src/ShortKitContext.ts +2 -1
  49. package/src/ShortKitFeed.tsx +19 -1
  50. package/src/ShortKitOverlaySurface.tsx +148 -41
  51. package/src/ShortKitPlayer.tsx +25 -3
  52. package/src/ShortKitProvider.tsx +4 -2
  53. package/src/ShortKitVideoCarouselOverlaySurface.tsx +156 -0
  54. package/src/index.ts +8 -1
  55. package/src/serialization.ts +8 -0
  56. package/src/specs/NativeShortKitModule.ts +31 -1
  57. package/src/types.ts +45 -1
@@ -17,6 +17,7 @@ import com.shortkit.sdk.model.FeedInput
17
17
  import com.shortkit.sdk.model.FeedScrollPhase
18
18
  import com.shortkit.sdk.model.FeedTransitionPhase
19
19
  import com.shortkit.sdk.model.ImageCarouselItem
20
+ import com.shortkit.sdk.model.VideoCarouselItem
20
21
  import com.shortkit.sdk.model.JsonValue
21
22
  import com.shortkit.sdk.model.PlayerState
22
23
  import com.shortkit.sdk.config.AdOverlayMode
@@ -24,8 +25,11 @@ import com.shortkit.sdk.config.CarouselOverlayMode
24
25
  import com.shortkit.sdk.config.FeedConfig
25
26
  import com.shortkit.sdk.config.FeedHeight
26
27
  import com.shortkit.sdk.config.FeedSource
28
+ import com.shortkit.sdk.config.ScrollAxis
27
29
  import com.shortkit.sdk.config.SurveyOverlayMode
30
+ import com.shortkit.sdk.config.VideoCarouselOverlayMode
28
31
  import com.shortkit.sdk.config.VideoOverlayMode
32
+ import com.shortkit.sdk.feed.FeedPreload
29
33
  import com.shortkit.sdk.feed.ShortKitFeedFragment
30
34
  import kotlinx.coroutines.CoroutineScope
31
35
  import kotlinx.coroutines.Dispatchers
@@ -74,6 +78,7 @@ class ShortKitBridge(
74
78
  return try {
75
79
  val obj = JSONObject().apply {
76
80
  put("id", item.id)
81
+ item.playbackId?.let { put("playbackId", it) }
77
82
  put("title", item.title)
78
83
  item.description?.let { put("description", it) }
79
84
  put("duration", item.duration)
@@ -138,29 +143,40 @@ class ShortKitBridge(
138
143
  return try {
139
144
  val obj = JSONObject(json)
140
145
 
146
+ val overlayRaw = obj.optString("overlay", null)
147
+
141
148
  val feedHeight = parseFeedHeight(obj.optString("feedHeight", null))
142
149
  val muteOnStart = obj.optBoolean("muteOnStart", true)
143
- val videoOverlay = parseVideoOverlay(obj.optString("overlay", null), context)
150
+ val videoOverlay = parseVideoOverlay(overlayRaw, context)
144
151
 
145
152
  val feedSourceStr = obj.optString("feedSource", "algorithmic")
146
153
  val feedSource = if (feedSourceStr == "custom") FeedSource.CUSTOM else FeedSource.ALGORITHMIC
147
154
 
148
- val carouselOverlay = parseCarouselOverlay(obj.optString("carouselOverlay", null), context)
155
+ val carouselOverlayRaw = obj.optString("carouselOverlay", null)
156
+ val carouselOverlay = parseCarouselOverlay(carouselOverlayRaw, context)
157
+ val videoCarouselOverlayRaw = obj.optString("videoCarouselOverlay", null)
158
+ val videoCarouselOverlay = parseVideoCarouselOverlay(videoCarouselOverlayRaw, context)
149
159
  val autoplay = obj.optBoolean("autoplay", true)
150
160
  val filter = obj.optJSONObject("filter")?.let { parseFeedFilterToModel(it.toString()) }
151
161
 
162
+ val scrollAxisStr = obj.optString("scrollAxis", "vertical")
163
+ val scrollAxis = if (scrollAxisStr == "horizontal") ScrollAxis.Horizontal else ScrollAxis.Vertical
164
+
152
165
  FeedConfig(
153
166
  feedHeight = feedHeight,
154
167
  videoOverlay = videoOverlay,
155
168
  carouselOverlay = carouselOverlay,
169
+ videoCarouselOverlay = videoCarouselOverlay,
156
170
  surveyOverlay = SurveyOverlayMode.None,
157
171
  adOverlay = AdOverlayMode.None,
158
172
  muteOnStart = muteOnStart,
159
173
  autoplay = autoplay,
160
174
  feedSource = feedSource,
161
175
  filter = filter,
176
+ scrollAxis = scrollAxis,
162
177
  )
163
- } catch (_: Exception) {
178
+ } catch (e: Exception) {
179
+ android.util.Log.e("SK:Bridge", "parseFeedConfig: EXCEPTION parsing config, returning default. json=${json.take(300)}", e)
164
180
  FeedConfig()
165
181
  }
166
182
  }
@@ -191,7 +207,9 @@ class ShortKitBridge(
191
207
  * - `"{\"type\":\"custom\"}"` -> Custom with ReactOverlayHost factory
192
208
  */
193
209
  private fun parseVideoOverlay(json: String?, context: android.content.Context?): VideoOverlayMode {
194
- if (json.isNullOrEmpty()) return VideoOverlayMode.None
210
+ if (json.isNullOrEmpty()) {
211
+ return VideoOverlayMode.None
212
+ }
195
213
  return try {
196
214
  val parsed = json.trim()
197
215
 
@@ -205,14 +223,18 @@ class ShortKitBridge(
205
223
  JSONObject(parsed)
206
224
  }
207
225
 
208
- if (inner.optString("type") == "custom" && context != null) {
209
- val name = inner.optString("name", "Default")
226
+ val type = inner.optString("type")
227
+ val name = inner.optString("name", "Default")
228
+
229
+ if (type == "custom" && context != null) {
210
230
  val ctx = context.applicationContext
211
231
  VideoOverlayMode.Custom { ReactOverlayHost(ctx).apply { overlayName = name } }
212
232
  } else {
233
+ android.util.Log.w("SK:Bridge", "parseVideoOverlay: → None (type='$type' context=${if (context != null) "OK" else "NULL"})")
213
234
  VideoOverlayMode.None
214
235
  }
215
- } catch (_: Exception) {
236
+ } catch (e: Exception) {
237
+ android.util.Log.e("SK:Bridge", "parseVideoOverlay: EXCEPTION → None. json=${json.take(200)}", e)
216
238
  VideoOverlayMode.None
217
239
  }
218
240
  }
@@ -256,6 +278,43 @@ class ShortKitBridge(
256
278
  }
257
279
  }
258
280
 
281
+ /**
282
+ * Parse a double-stringified video carousel overlay JSON.
283
+ * - `"\"none\""` -> None
284
+ * - `"{\"type\":\"custom\"}"` -> Custom with ReactVideoCarouselOverlayHost factory
285
+ */
286
+ private fun parseVideoCarouselOverlay(json: String?, context: android.content.Context?): VideoCarouselOverlayMode {
287
+ if (json.isNullOrEmpty()) return VideoCarouselOverlayMode.None
288
+ return try {
289
+ val parsed = json.trim()
290
+
291
+ if (parsed == "\"none\"" || parsed == "none") {
292
+ return VideoCarouselOverlayMode.None
293
+ }
294
+
295
+ val inner = if (parsed.startsWith("\"") && parsed.endsWith("\"")) {
296
+ JSONObject(parsed.substring(1, parsed.length - 1).replace("\\\"", "\""))
297
+ } else {
298
+ JSONObject(parsed)
299
+ }
300
+
301
+ if (inner.optString("type") == "custom" && context != null) {
302
+ val name = inner.optString("name", "Default")
303
+ val ctx = context.applicationContext
304
+ VideoCarouselOverlayMode.Custom {
305
+ ReactVideoCarouselOverlayHost(ctx).apply {
306
+ videoCarouselOverlayName = name
307
+ prepareSurface()
308
+ }
309
+ }
310
+ } else {
311
+ VideoCarouselOverlayMode.None
312
+ }
313
+ } catch (_: Exception) {
314
+ VideoCarouselOverlayMode.None
315
+ }
316
+ }
317
+
259
318
  private fun parseFeedFilter(json: String?): String? {
260
319
  // On Android, the SDK accepts the raw JSON string for filtering.
261
320
  // Return as-is if non-null/non-empty.
@@ -299,6 +358,26 @@ class ShortKitBridge(
299
358
  val carouselItem = parseImageCarouselItem(itemObj) ?: continue
300
359
  result.add(FeedInput.ImageCarousel(carouselItem))
301
360
  }
361
+ "videoCarousel" -> {
362
+ val itemObj = obj.optJSONObject("item") ?: continue
363
+ val videosArr = itemObj.optJSONArray("videos") ?: continue
364
+ val videos = mutableListOf<ContentItem>()
365
+ for (i in 0 until videosArr.length()) {
366
+ val videoObj = videosArr.getJSONObject(i)
367
+ parseContentItem(videoObj)?.let { videos.add(it) }
368
+ }
369
+ if (videos.isEmpty()) continue
370
+ val carouselItem = VideoCarouselItem(
371
+ id = itemObj.getString("id"),
372
+ videos = videos,
373
+ title = itemObj.optString("title", null),
374
+ description = itemObj.optString("description", null),
375
+ author = itemObj.optString("author", null),
376
+ section = itemObj.optString("section", null),
377
+ articleUrl = itemObj.optString("articleUrl", null),
378
+ )
379
+ result.add(FeedInput.VideoCarousel(carouselItem))
380
+ }
302
381
  }
303
382
  }
304
383
  result.ifEmpty { null }
@@ -332,6 +411,27 @@ class ShortKitBridge(
332
411
  )
333
412
  }
334
413
 
414
+ private fun parseContentItem(obj: JSONObject): ContentItem? {
415
+ val id = obj.optString("id", null) ?: return null
416
+ val title = obj.optString("title", null) ?: return null
417
+ val duration = obj.optDouble("duration", -1.0).takeIf { it >= 0 } ?: return null
418
+ val streamingUrl = obj.optString("streamingUrl", null) ?: return null
419
+ val thumbnailUrl = obj.optString("thumbnailUrl", null) ?: return null
420
+ return ContentItem(
421
+ id = id,
422
+ playbackId = obj.optString("playbackId", null),
423
+ title = title,
424
+ description = obj.optString("description", null),
425
+ duration = duration,
426
+ streamingUrl = streamingUrl,
427
+ thumbnailUrl = thumbnailUrl,
428
+ captionTracks = emptyList(),
429
+ author = obj.optString("author", null),
430
+ articleUrl = obj.optString("articleUrl", null),
431
+ fallbackUrl = obj.optString("fallbackUrl", null),
432
+ )
433
+ }
434
+
335
435
  private fun buildCaptionTracksJSONArray(tracks: List<CaptionTrack>): JSONArray {
336
436
  val arr = JSONArray()
337
437
  for (track in tracks) {
@@ -378,7 +478,7 @@ class ShortKitBridge(
378
478
  private var scope: CoroutineScope? = null
379
479
 
380
480
  /** Preload handles keyed by UUID, consumed by feed views via preloadId prop. */
381
- var preloadHandles = mutableMapOf<String, Any>()
481
+ var preloadHandles = mutableMapOf<String, FeedPreload>()
382
482
  private set
383
483
 
384
484
  // ------------------------------------------------------------------
@@ -450,8 +550,11 @@ class ShortKitBridge(
450
550
  pending = staticPendingFeedViews.toList()
451
551
  staticPendingFeedViews.clear()
452
552
  }
553
+ val mainHandler = Handler(Looper.getMainLooper())
453
554
  for (view in pending) {
454
- view.embedFeedFragmentIfNeeded()
555
+ mainHandler.post {
556
+ if (view.isAttachedToWindow) view.embedFeedFragmentIfNeeded()
557
+ }
455
558
  }
456
559
  }
457
560
 
@@ -490,19 +593,29 @@ class ShortKitBridge(
490
593
  }
491
594
  emitEventOnMain("onRemainingContentCountChanged", params)
492
595
  }
596
+ fragment.onFeedReady = {
597
+ val params = Arguments.createMap().apply {
598
+ putString("feedId", id)
599
+ }
600
+ emitEventOnMain("onFeedReady", params)
601
+ }
493
602
 
494
- // Replay buffered operations on the next main-thread tick
603
+ // Replay buffered operations after the fragment's view is created.
604
+ // commitNowAllowingStateLoss triggers onCreate but onViewCreated
605
+ // (which sets up customFeedController) runs on a later main-thread
606
+ // tick. A single post isn't enough — use postDelayed to ensure the
607
+ // fragment's view lifecycle has completed.
495
608
  val ops: List<(ShortKitFeedFragment) -> Unit>?
496
609
  synchronized(pendingOpsLock) {
497
610
  ops = pendingOps.remove(id)?.toList()
498
611
  }
499
612
  if (ops != null) {
500
- Handler(Looper.getMainLooper()).post {
501
- val frag = feedRegistry[id]?.get() ?: return@post
613
+ Handler(Looper.getMainLooper()).postDelayed({
614
+ val frag = feedRegistry[id]?.get() ?: return@postDelayed
502
615
  for (op in ops) {
503
616
  op(frag)
504
617
  }
505
- }
618
+ }, 100)
506
619
  }
507
620
  }
508
621
 
@@ -544,7 +657,7 @@ class ShortKitBridge(
544
657
  // Preload handle management
545
658
  // ------------------------------------------------------------------
546
659
 
547
- fun consumePreload(id: String): Any? {
660
+ fun consumePreload(id: String): FeedPreload? {
548
661
  return preloadHandles.remove(id)
549
662
  }
550
663
 
@@ -714,13 +827,37 @@ class ShortKitBridge(
714
827
  // Preload feed
715
828
  // ------------------------------------------------------------------
716
829
 
717
- fun preloadFeed(configJSON: String, callback: (String) -> Unit) {
830
+ fun preloadFeed(configJSON: String, itemsJSON: String?, callback: (String) -> Unit) {
718
831
  val sdk = shortKit
719
832
  if (sdk == null) {
720
833
  callback("")
721
834
  return
722
835
  }
723
- val preload = sdk.preloadFeed(filter = configJSON)
836
+ val config = parseFeedConfig(configJSON)
837
+ if (config.feedSource == FeedSource.CUSTOM) {
838
+ if (itemsJSON == null) {
839
+ android.util.Log.w("ShortKit", "preloadFeed called with feedSource=CUSTOM but no items")
840
+ callback("")
841
+ return
842
+ }
843
+ val items = parseFeedInputs(itemsJSON)
844
+ if (items == null) {
845
+ callback("")
846
+ return
847
+ }
848
+ val preload = sdk.preloadFeed(items = items)
849
+ val uuid = java.util.UUID.randomUUID().toString()
850
+ preloadHandles[uuid] = preload
851
+ callback(uuid)
852
+ return
853
+ }
854
+ val filterJSON = config.filter?.let { org.json.JSONObject().apply {
855
+ it.tags?.let { tags -> put("tags", org.json.JSONArray(tags)) }
856
+ it.section?.let { s -> put("section", s) }
857
+ it.author?.let { a -> put("author", a) }
858
+ it.contentType?.let { ct -> put("contentType", ct) }
859
+ }.toString() }
860
+ val preload = sdk.preloadFeed(filter = filterJSON)
724
861
  val uuid = java.util.UUID.randomUUID().toString()
725
862
  preloadHandles[uuid] = preload
726
863
  callback(uuid)
@@ -952,16 +1089,7 @@ class ShortKitBridge(
952
1089
  }
953
1090
  }
954
1091
 
955
- // Remaining content count
956
- newScope.launch {
957
- player.remainingContentCount.collect { count ->
958
- val params = Arguments.createMap().apply {
959
- putString("feedId", "") // Global fallback — per-feed routing via fragment callback
960
- putInt("count", count)
961
- }
962
- emitEvent.invoke("onRemainingContentCountChanged", params)
963
- }
964
- }
1092
+ // Remaining content count — handled per-feed via fragment.onRemainingContentCountChange callback
965
1093
 
966
1094
  // Feed scroll phase
967
1095
  newScope.launch {
@@ -9,7 +9,6 @@ import android.view.View
9
9
  import android.view.ViewGroup
10
10
  import android.widget.FrameLayout
11
11
  import androidx.fragment.app.FragmentActivity
12
- import androidx.viewpager2.widget.ViewPager2
13
12
  import com.shortkit.sdk.feed.ShortKitFeedFragment
14
13
 
15
14
  /**
@@ -41,6 +40,11 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
41
40
  private var fragmentContainerId: Int = View.generateViewId()
42
41
  private val handler = Handler(Looper.getMainLooper())
43
42
 
43
+ // The React Native-assigned size. Stored so we can re-apply it after the
44
+ // SDK's forceLayoutIfNeeded potentially re-measures to full-screen size.
45
+ private var rnWidth: Int = 0
46
+ private var rnHeight: Int = 0
47
+
44
48
  init {
45
49
  // Use a child FrameLayout as the fragment container. Fabric overrides
46
50
  // this view's own id, so we can't use it as the fragment container.
@@ -73,6 +77,42 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
73
77
  layout(left, top, right, bottom)
74
78
  }
75
79
 
80
+ /**
81
+ * Re-apply the React Native-assigned size to the fragment container and
82
+ * all descendants (fragment root, VP, RV). The SDK's forceLayoutIfNeeded
83
+ * may have sized them to the full activity height instead.
84
+ */
85
+ private val constrainRunnable = Runnable {
86
+ val w = rnWidth
87
+ val h = rnHeight
88
+ if (w == 0 || h == 0) return@Runnable
89
+
90
+ val fragView = feedFragment?.view ?: return@Runnable
91
+ val wSpec = MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY)
92
+ val hSpec = MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
93
+
94
+ // Re-pin container
95
+ fragmentContainer.measure(wSpec, hSpec)
96
+ fragmentContainer.layout(0, 0, w, h)
97
+
98
+ // Re-pin fragment root, VP, and RV if they got the wrong size
99
+ if (fragView.height != h) {
100
+ fragView.measure(wSpec, hSpec)
101
+ fragView.layout(0, 0, w, h)
102
+ }
103
+
104
+ val vp = findViewPager2(fragView)
105
+ if (vp != null && vp.height != h) {
106
+ vp.measure(wSpec, hSpec)
107
+ vp.layout(0, 0, w, h)
108
+ val rv = vp.getChildAt(0) as? ViewGroup
109
+ if (rv != null) {
110
+ rv.measure(wSpec, hSpec)
111
+ rv.layout(0, 0, w, h)
112
+ }
113
+ }
114
+ }
115
+
76
116
 
77
117
  // -----------------------------------------------------------------------
78
118
  // Lifecycle
@@ -80,14 +120,21 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
80
120
 
81
121
  override fun onAttachedToWindow() {
82
122
  super.onAttachedToWindow()
83
- embedFeedFragmentIfNeeded()
123
+ // Defer to the next message-loop tick. onAttachedToWindow can fire
124
+ // during a parent fragment transaction (e.g. react-native-screens
125
+ // ScreenStack.onUpdate), and FragmentManager forbids nested
126
+ // commitNow calls. Posting ensures we run after the outer
127
+ // transaction completes — still before the first draw pass.
128
+ handler.post {
129
+ if (isAttachedToWindow) embedFeedFragmentIfNeeded()
130
+ }
84
131
  }
85
132
 
86
133
  override fun onDetachedFromWindow() {
87
134
  synchronized(ShortKitBridge.staticPendingFeedViews) {
88
135
  ShortKitBridge.staticPendingFeedViews.remove(this)
89
136
  }
90
- removeFeedFragment()
137
+ suspendFeedFragment()
91
138
  super.onDetachedFromWindow()
92
139
  }
93
140
 
@@ -96,7 +143,35 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
96
143
  // -----------------------------------------------------------------------
97
144
 
98
145
  internal fun embedFeedFragmentIfNeeded() {
99
- if (feedFragment != null) return
146
+ val activity = getReactActivity()
147
+ if (activity == null) {
148
+ Log.e(TAG, "embedFeedFragment: FragmentActivity not found in context chain")
149
+ return
150
+ }
151
+
152
+ // Show a hidden fragment (e.g. returning to this tab).
153
+ // Using hide()/show() instead of detach()/attach() preserves the
154
+ // entire view hierarchy (ViewPager, RecyclerView, player surface)
155
+ // so there is zero flicker — matching iOS behavior exactly.
156
+ val existingFragment = feedFragment
157
+ if (existingFragment != null) {
158
+ if (existingFragment.isHidden) {
159
+ try {
160
+ activity.supportFragmentManager
161
+ .beginTransaction()
162
+ .show(existingFragment)
163
+ .commitNowAllowingStateLoss()
164
+ existingFragment.activate()
165
+ } catch (e: Exception) {
166
+ Log.e(TAG, "Failed to show feed fragment", e)
167
+ }
168
+ }
169
+ feedId?.let { id ->
170
+ ShortKitBridge.shared?.registerFeed(id)
171
+ ShortKitBridge.shared?.registerFeedFragment(id, existingFragment)
172
+ }
173
+ return
174
+ }
100
175
 
101
176
  val sdk = ShortKitBridge.shared?.sdk ?: run {
102
177
  synchronized(ShortKitBridge.staticPendingFeedViews) {
@@ -105,15 +180,16 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
105
180
  return
106
181
  }
107
182
 
108
- val activity = getReactActivity() ?: return
109
-
110
- val feedConfig = ShortKitBridge.parseFeedConfig(config ?: "{}", context)
183
+ var feedConfig = ShortKitBridge.parseFeedConfig(config ?: "{}", context)
111
184
 
112
185
  preloadId?.let { id ->
113
- ShortKitBridge.shared?.consumePreload(id)
186
+ val preload = ShortKitBridge.shared?.consumePreload(id)
187
+ if (preload != null) {
188
+ feedConfig = feedConfig.copy(preload = preload)
189
+ }
114
190
  }
115
191
 
116
- val fragment = ShortKitFeedFragment.newInstance(sdk, feedConfig)
192
+ val fragment = ShortKitFeedFragment.newInstance(sdk, feedConfig, startAtItemId)
117
193
 
118
194
  try {
119
195
  activity.supportFragmentManager
@@ -122,34 +198,39 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
122
198
  .commitNowAllowingStateLoss()
123
199
  this.feedFragment = fragment
124
200
 
125
- // Force an immediate layout pass on the container so the fragment's
126
- // ViewPager2/RecyclerView gets measured and can create cells.
201
+ // View.generateViewId() can return IDs that collide with Fabric-
202
+ // managed views (e.g. ReactSurfaceView also gets id=1). When that
203
+ // happens, FragmentManager.replace() places the fragment view in
204
+ // the wrong parent. Detect and correct this by reparenting.
205
+ val fragView = fragment.view
206
+ val fragParent = fragView?.parent
207
+ if (fragParent != null && fragParent !== fragmentContainer) {
208
+ Log.w(TAG, "embedFeedFragment: reparenting — fragment placed in ${fragParent.javaClass.simpleName} due to ID collision")
209
+ (fragParent as? ViewGroup)?.removeView(fragView)
210
+ fragmentContainer.addView(fragView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
211
+ }
212
+
213
+ // Pin the fragment container to the React Native-assigned size.
214
+ // Without this, MATCH_PARENT causes Android to re-measure the
215
+ // container to the full activity size, and the SDK's deferred
216
+ // layout forcing picks up the wrong (full-screen) dimensions.
217
+ rnWidth = measuredWidth
218
+ rnHeight = measuredHeight
219
+ fragmentContainer.layoutParams = LayoutParams(measuredWidth, measuredHeight)
127
220
  fragmentContainer.measure(
128
221
  MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY),
129
222
  MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
130
223
  )
131
224
  fragmentContainer.layout(0, 0, measuredWidth, measuredHeight)
225
+ // Schedule a delayed re-pin: the SDK's forceLayoutIfNeeded will
226
+ // run when deferCellSetup exhausts retries (~3 post ticks later)
227
+ // and may size the VP/RV to the wrong dimensions. Re-apply our
228
+ // correct RN size after it runs.
229
+ handler.postDelayed(constrainRunnable, 500)
132
230
 
133
- // The SDK's deferCellSetup retries only run during the current message
134
- // loop iteration. Since Fabric suppresses layout, ViewPager2 won't
135
- // have cells until our Choreographer callback forces layout on the
136
- // next frame. Schedule a delayed nudge that runs after layout settles.
137
- handler.postDelayed({
138
- val fragView = feedFragment?.view
139
- val vp = if (fragView != null) findViewPager2(fragView) else findViewPager2(fragmentContainer)
140
- if (vp != null) {
141
- if (vp.width == 0 || vp.height == 0) {
142
- fragView?.measure(
143
- MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY),
144
- MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
145
- )
146
- fragView?.layout(0, 0, measuredWidth, measuredHeight)
147
- }
148
- if (vp.adapter != null && vp.adapter!!.itemCount > 0) {
149
- vp.setCurrentItem(vp.currentItem, false)
150
- }
151
- }
152
- }, 200)
231
+ if (measuredWidth == 0 || measuredHeight == 0) {
232
+ Log.w(TAG, "embedFeedFragment: WARNING container is 0x0, overlays will be invisible. Check if parent has display:none or flex:0")
233
+ }
153
234
 
154
235
  feedId?.let { id ->
155
236
  ShortKitBridge.shared?.registerFeed(id)
@@ -160,12 +241,56 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
160
241
  }
161
242
  }
162
243
 
163
- private fun removeFeedFragment() {
244
+ /**
245
+ * Hide the fragment without destroying its view hierarchy (mirrors iOS
246
+ * `suspendFeedViewController`). Using hide() instead of detach() keeps
247
+ * the ViewPager, RecyclerView, player surface, and all overlay views
248
+ * alive in memory — so returning to the tab is instant with no flicker.
249
+ */
250
+ private fun suspendFeedFragment() {
251
+ feedId?.let { id ->
252
+ ShortKitBridge.shared?.unregisterFeed(id)
253
+ ShortKitBridge.shared?.unregisterFeedFragment(id)
254
+ }
255
+ val fragment = feedFragment ?: return
256
+
257
+ try {
258
+ fragment.deactivate()
259
+ val activity = getReactActivity() ?: return
260
+ activity.supportFragmentManager
261
+ .beginTransaction()
262
+ .hide(fragment)
263
+ .commitNowAllowingStateLoss()
264
+ } catch (e: IllegalStateException) {
265
+ // Expected during Activity teardown — fall back to full destroy
266
+ feedFragment = null
267
+ } catch (e: Exception) {
268
+ Log.e(TAG, "Unexpected error suspending feed fragment", e)
269
+ feedFragment = null
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Called by [ShortKitFeedViewManager.onDropViewInstance] when React
275
+ * unmounts the native view. Performs a full fragment teardown.
276
+ */
277
+ fun destroy() {
278
+ handler.removeCallbacks(constrainRunnable)
279
+ handler.removeCallbacks(layoutRunnable)
280
+ destroyFeedFragment()
281
+ }
282
+
283
+ /**
284
+ * Full teardown — removes the fragment from FragmentManager entirely.
285
+ * Called when the Fabric view is being destroyed (not just hidden).
286
+ */
287
+ private fun destroyFeedFragment() {
164
288
  feedId?.let { id ->
165
289
  ShortKitBridge.shared?.unregisterFeed(id)
166
290
  ShortKitBridge.shared?.unregisterFeedFragment(id)
167
291
  }
168
292
  val fragment = feedFragment ?: return
293
+ fragment.deactivate()
169
294
  feedFragment = null
170
295
 
171
296
  try {
@@ -185,9 +310,9 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
185
310
  // Helpers
186
311
  // -----------------------------------------------------------------------
187
312
 
188
- /** Recursively find the first [ViewPager2] in a view hierarchy. */
189
- private fun findViewPager2(view: View): ViewPager2? {
190
- if (view is ViewPager2) return view
313
+ /** Recursively find the first ViewPager2 in a view hierarchy. */
314
+ private fun findViewPager2(view: View): androidx.viewpager2.widget.ViewPager2? {
315
+ if (view is androidx.viewpager2.widget.ViewPager2) return view
191
316
  if (view is ViewGroup) {
192
317
  for (i in 0 until view.childCount) {
193
318
  findViewPager2(view.getChildAt(i))?.let { return it }
@@ -35,6 +35,11 @@ class ShortKitFeedViewManager : SimpleViewManager<ShortKitFeedView>() {
35
35
  view.preloadId = preloadId
36
36
  }
37
37
 
38
+ override fun onDropViewInstance(view: ShortKitFeedView) {
39
+ view.destroy()
40
+ super.onDropViewInstance(view)
41
+ }
42
+
38
43
  companion object {
39
44
  const val REACT_CLASS = "ShortKitFeedView"
40
45
  }