@shortkitsdk/react-native 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/android/build.gradle.kts +13 -1
  2. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +115 -55
  3. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +67 -56
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +71 -26
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +160 -35
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +5 -0
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +43 -10
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +9 -0
  9. package/ios/ReactOverlayHost.swift +13 -27
  10. package/ios/ShortKitBridge.swift +36 -2
  11. package/ios/ShortKitFeedView.swift +24 -3
  12. package/ios/ShortKitModule.mm +4 -1
  13. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +720 -144
  14. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +19 -5
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +19 -5
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  18. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +720 -144
  19. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +19 -5
  20. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  21. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +19 -5
  22. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  23. package/package.json +1 -1
  24. package/src/ShortKitContext.ts +2 -1
  25. package/src/ShortKitFeed.tsx +14 -0
  26. package/src/ShortKitOverlaySurface.tsx +153 -45
  27. package/src/ShortKitPlayer.tsx +25 -3
  28. package/src/ShortKitProvider.tsx +4 -2
  29. package/src/index.ts +4 -0
  30. package/src/serialization.ts +1 -0
  31. package/src/specs/NativeShortKitModule.ts +18 -1
  32. package/src/types.ts +6 -0
@@ -24,8 +24,10 @@ import com.shortkit.sdk.config.CarouselOverlayMode
24
24
  import com.shortkit.sdk.config.FeedConfig
25
25
  import com.shortkit.sdk.config.FeedHeight
26
26
  import com.shortkit.sdk.config.FeedSource
27
+ import com.shortkit.sdk.config.ScrollAxis
27
28
  import com.shortkit.sdk.config.SurveyOverlayMode
28
29
  import com.shortkit.sdk.config.VideoOverlayMode
30
+ import com.shortkit.sdk.feed.FeedPreload
29
31
  import com.shortkit.sdk.feed.ShortKitFeedFragment
30
32
  import kotlinx.coroutines.CoroutineScope
31
33
  import kotlinx.coroutines.Dispatchers
@@ -74,6 +76,7 @@ class ShortKitBridge(
74
76
  return try {
75
77
  val obj = JSONObject().apply {
76
78
  put("id", item.id)
79
+ item.playbackId?.let { put("playbackId", it) }
77
80
  put("title", item.title)
78
81
  item.description?.let { put("description", it) }
79
82
  put("duration", item.duration)
@@ -138,17 +141,23 @@ class ShortKitBridge(
138
141
  return try {
139
142
  val obj = JSONObject(json)
140
143
 
144
+ val overlayRaw = obj.optString("overlay", null)
145
+
141
146
  val feedHeight = parseFeedHeight(obj.optString("feedHeight", null))
142
147
  val muteOnStart = obj.optBoolean("muteOnStart", true)
143
- val videoOverlay = parseVideoOverlay(obj.optString("overlay", null), context)
148
+ val videoOverlay = parseVideoOverlay(overlayRaw, context)
144
149
 
145
150
  val feedSourceStr = obj.optString("feedSource", "algorithmic")
146
151
  val feedSource = if (feedSourceStr == "custom") FeedSource.CUSTOM else FeedSource.ALGORITHMIC
147
152
 
148
- val carouselOverlay = parseCarouselOverlay(obj.optString("carouselOverlay", null), context)
153
+ val carouselOverlayRaw = obj.optString("carouselOverlay", null)
154
+ val carouselOverlay = parseCarouselOverlay(carouselOverlayRaw, context)
149
155
  val autoplay = obj.optBoolean("autoplay", true)
150
156
  val filter = obj.optJSONObject("filter")?.let { parseFeedFilterToModel(it.toString()) }
151
157
 
158
+ val scrollAxisStr = obj.optString("scrollAxis", "vertical")
159
+ val scrollAxis = if (scrollAxisStr == "horizontal") ScrollAxis.Horizontal else ScrollAxis.Vertical
160
+
152
161
  FeedConfig(
153
162
  feedHeight = feedHeight,
154
163
  videoOverlay = videoOverlay,
@@ -159,8 +168,10 @@ class ShortKitBridge(
159
168
  autoplay = autoplay,
160
169
  feedSource = feedSource,
161
170
  filter = filter,
171
+ scrollAxis = scrollAxis,
162
172
  )
163
- } catch (_: Exception) {
173
+ } catch (e: Exception) {
174
+ android.util.Log.e("SK:Bridge", "parseFeedConfig: EXCEPTION parsing config, returning default. json=${json.take(300)}", e)
164
175
  FeedConfig()
165
176
  }
166
177
  }
@@ -191,7 +202,9 @@ class ShortKitBridge(
191
202
  * - `"{\"type\":\"custom\"}"` -> Custom with ReactOverlayHost factory
192
203
  */
193
204
  private fun parseVideoOverlay(json: String?, context: android.content.Context?): VideoOverlayMode {
194
- if (json.isNullOrEmpty()) return VideoOverlayMode.None
205
+ if (json.isNullOrEmpty()) {
206
+ return VideoOverlayMode.None
207
+ }
195
208
  return try {
196
209
  val parsed = json.trim()
197
210
 
@@ -205,14 +218,18 @@ class ShortKitBridge(
205
218
  JSONObject(parsed)
206
219
  }
207
220
 
208
- if (inner.optString("type") == "custom" && context != null) {
209
- val name = inner.optString("name", "Default")
221
+ val type = inner.optString("type")
222
+ val name = inner.optString("name", "Default")
223
+
224
+ if (type == "custom" && context != null) {
210
225
  val ctx = context.applicationContext
211
226
  VideoOverlayMode.Custom { ReactOverlayHost(ctx).apply { overlayName = name } }
212
227
  } else {
228
+ android.util.Log.w("SK:Bridge", "parseVideoOverlay: → None (type='$type' context=${if (context != null) "OK" else "NULL"})")
213
229
  VideoOverlayMode.None
214
230
  }
215
- } catch (_: Exception) {
231
+ } catch (e: Exception) {
232
+ android.util.Log.e("SK:Bridge", "parseVideoOverlay: EXCEPTION → None. json=${json.take(200)}", e)
216
233
  VideoOverlayMode.None
217
234
  }
218
235
  }
@@ -378,7 +395,7 @@ class ShortKitBridge(
378
395
  private var scope: CoroutineScope? = null
379
396
 
380
397
  /** Preload handles keyed by UUID, consumed by feed views via preloadId prop. */
381
- var preloadHandles = mutableMapOf<String, Any>()
398
+ var preloadHandles = mutableMapOf<String, FeedPreload>()
382
399
  private set
383
400
 
384
401
  // ------------------------------------------------------------------
@@ -450,8 +467,11 @@ class ShortKitBridge(
450
467
  pending = staticPendingFeedViews.toList()
451
468
  staticPendingFeedViews.clear()
452
469
  }
470
+ val mainHandler = Handler(Looper.getMainLooper())
453
471
  for (view in pending) {
454
- view.embedFeedFragmentIfNeeded()
472
+ mainHandler.post {
473
+ if (view.isAttachedToWindow) view.embedFeedFragmentIfNeeded()
474
+ }
455
475
  }
456
476
  }
457
477
 
@@ -490,19 +510,29 @@ class ShortKitBridge(
490
510
  }
491
511
  emitEventOnMain("onRemainingContentCountChanged", params)
492
512
  }
513
+ fragment.onFeedReady = {
514
+ val params = Arguments.createMap().apply {
515
+ putString("feedId", id)
516
+ }
517
+ emitEventOnMain("onFeedReady", params)
518
+ }
493
519
 
494
- // Replay buffered operations on the next main-thread tick
520
+ // Replay buffered operations after the fragment's view is created.
521
+ // commitNowAllowingStateLoss triggers onCreate but onViewCreated
522
+ // (which sets up customFeedController) runs on a later main-thread
523
+ // tick. A single post isn't enough — use postDelayed to ensure the
524
+ // fragment's view lifecycle has completed.
495
525
  val ops: List<(ShortKitFeedFragment) -> Unit>?
496
526
  synchronized(pendingOpsLock) {
497
527
  ops = pendingOps.remove(id)?.toList()
498
528
  }
499
529
  if (ops != null) {
500
- Handler(Looper.getMainLooper()).post {
501
- val frag = feedRegistry[id]?.get() ?: return@post
530
+ Handler(Looper.getMainLooper()).postDelayed({
531
+ val frag = feedRegistry[id]?.get() ?: return@postDelayed
502
532
  for (op in ops) {
503
533
  op(frag)
504
534
  }
505
- }
535
+ }, 100)
506
536
  }
507
537
  }
508
538
 
@@ -544,7 +574,7 @@ class ShortKitBridge(
544
574
  // Preload handle management
545
575
  // ------------------------------------------------------------------
546
576
 
547
- fun consumePreload(id: String): Any? {
577
+ fun consumePreload(id: String): FeedPreload? {
548
578
  return preloadHandles.remove(id)
549
579
  }
550
580
 
@@ -714,13 +744,37 @@ class ShortKitBridge(
714
744
  // Preload feed
715
745
  // ------------------------------------------------------------------
716
746
 
717
- fun preloadFeed(configJSON: String, callback: (String) -> Unit) {
747
+ fun preloadFeed(configJSON: String, itemsJSON: String?, callback: (String) -> Unit) {
718
748
  val sdk = shortKit
719
749
  if (sdk == null) {
720
750
  callback("")
721
751
  return
722
752
  }
723
- val preload = sdk.preloadFeed(filter = configJSON)
753
+ val config = parseFeedConfig(configJSON)
754
+ if (config.feedSource == FeedSource.CUSTOM) {
755
+ if (itemsJSON == null) {
756
+ android.util.Log.w("ShortKit", "preloadFeed called with feedSource=CUSTOM but no items")
757
+ callback("")
758
+ return
759
+ }
760
+ val items = parseFeedInputs(itemsJSON)
761
+ if (items == null) {
762
+ callback("")
763
+ return
764
+ }
765
+ val preload = sdk.preloadFeed(items = items)
766
+ val uuid = java.util.UUID.randomUUID().toString()
767
+ preloadHandles[uuid] = preload
768
+ callback(uuid)
769
+ return
770
+ }
771
+ val filterJSON = config.filter?.let { org.json.JSONObject().apply {
772
+ it.tags?.let { tags -> put("tags", org.json.JSONArray(tags)) }
773
+ it.section?.let { s -> put("section", s) }
774
+ it.author?.let { a -> put("author", a) }
775
+ it.contentType?.let { ct -> put("contentType", ct) }
776
+ }.toString() }
777
+ val preload = sdk.preloadFeed(filter = filterJSON)
724
778
  val uuid = java.util.UUID.randomUUID().toString()
725
779
  preloadHandles[uuid] = preload
726
780
  callback(uuid)
@@ -952,16 +1006,7 @@ class ShortKitBridge(
952
1006
  }
953
1007
  }
954
1008
 
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
- }
1009
+ // Remaining content count — handled per-feed via fragment.onRemainingContentCountChange callback
965
1010
 
966
1011
  // Feed scroll phase
967
1012
  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
  }
@@ -205,13 +205,13 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
205
205
  }
206
206
 
207
207
  @ReactMethod
208
- override fun preloadFeed(configJSON: String, promise: Promise) {
208
+ override fun preloadFeed(configJSON: String, itemsJSON: String?, promise: Promise) {
209
209
  val b = bridge
210
210
  if (b == null) {
211
211
  promise.resolve("")
212
212
  return
213
213
  }
214
- b.preloadFeed(configJSON) { result -> promise.resolve(result) }
214
+ b.preloadFeed(configJSON, itemsJSON) { result -> promise.resolve(result) }
215
215
  }
216
216
 
217
217
  // -----------------------------------------------------------------
@@ -238,15 +238,48 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
238
238
  // -----------------------------------------------------------------
239
239
 
240
240
  private fun sendEvent(name: String, params: WritableMap) {
241
- // NOTE: In RN 0.78 new architecture, the codegen EventEmitter<T> does
242
- // NOT call the legacy addListener/removeListeners methods, so the
243
- // hasListeners flag stays false forever. We skip the guard and always
244
- // emit. The try/catch handles the case where the catalyst instance
245
- // is torn down.
241
+ // Use the codegen-generated emitOn* methods which route through
242
+ // mEventEmitterCallback (JSI direct channel). The legacy
243
+ // DeviceEventManagerModule.RCTDeviceEventEmitter path does NOT
244
+ // reach codegen EventEmitter<T> subscribers in RN 0.78 new arch.
246
245
  try {
247
- reactApplicationContext
248
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
249
- .emit(name, params)
246
+ when (name) {
247
+ "onPlayerStateChanged" -> emitOnPlayerStateChanged(params)
248
+ "onCurrentItemChanged" -> emitOnCurrentItemChanged(params)
249
+ "onTimeUpdate" -> emitOnTimeUpdate(params)
250
+ "onMutedChanged" -> emitOnMutedChanged(params)
251
+ "onPlaybackRateChanged" -> emitOnPlaybackRateChanged(params)
252
+ "onCaptionsEnabledChanged" -> emitOnCaptionsEnabledChanged(params)
253
+ "onActiveCaptionTrackChanged" -> emitOnActiveCaptionTrackChanged(params)
254
+ "onActiveCueChanged" -> emitOnActiveCueChanged(params)
255
+ "onDidLoop" -> emitOnDidLoop(params)
256
+ "onFeedTransition" -> emitOnFeedTransition(params)
257
+ "onFeedScrollPhase" -> emitOnFeedScrollPhase(params)
258
+ "onFormatChange" -> emitOnFormatChange(params)
259
+ "onPrefetchedAheadCountChanged" -> emitOnPrefetchedAheadCountChanged(params)
260
+ "onRemainingContentCountChanged" -> emitOnRemainingContentCountChanged(params)
261
+ "onSurveyResponse" -> emitOnSurveyResponse(params)
262
+ "onContentTapped" -> emitOnContentTapped(params)
263
+ "onDismiss" -> emitOnDismiss(params)
264
+ "onRefreshRequested" -> emitOnRefreshRequested(params)
265
+ "onDidFetchContentItems" -> emitOnDidFetchContentItems(params)
266
+ "onFeedReady" -> emitOnFeedReady(params)
267
+ "onOverlayActiveChanged" -> emitOnOverlayActiveChanged(params)
268
+ "onOverlayPlayerStateChanged" -> emitOnOverlayPlayerStateChanged(params)
269
+ "onOverlayMutedChanged" -> emitOnOverlayMutedChanged(params)
270
+ "onOverlayPlaybackRateChanged" -> emitOnOverlayPlaybackRateChanged(params)
271
+ "onOverlayCaptionsEnabledChanged" -> emitOnOverlayCaptionsEnabledChanged(params)
272
+ "onOverlayActiveCueChanged" -> emitOnOverlayActiveCueChanged(params)
273
+ "onOverlayFeedScrollPhaseChanged" -> emitOnOverlayFeedScrollPhaseChanged(params)
274
+ "onOverlayTimeUpdate" -> emitOnOverlayTimeUpdate(params)
275
+ "onOverlayFullState" -> emitOnOverlayFullState(params)
276
+ else -> {
277
+ android.util.Log.w("SK:Module", "sendEvent: unknown event name '$name', using legacy emitter")
278
+ reactApplicationContext
279
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
280
+ .emit(name, params)
281
+ }
282
+ }
250
283
  } catch (e: Exception) {
251
284
  android.util.Log.e("SK:Module", "sendEvent($name) EXCEPTION: ${e.message}", e)
252
285
  }
@@ -1,6 +1,7 @@
1
1
  package com.shortkit.reactnative
2
2
 
3
3
  import android.content.Context
4
+ import android.view.MotionEvent
4
5
  import android.widget.FrameLayout
5
6
  import com.shortkit.sdk.config.PlayerClickAction
6
7
  import com.shortkit.sdk.config.PlayerConfig
@@ -42,6 +43,14 @@ class ShortKitPlayerNativeView(context: Context) : FrameLayout(context) {
42
43
  applyActive()
43
44
  }
44
45
 
46
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
47
+ return super.onInterceptTouchEvent(ev)
48
+ }
49
+
50
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
51
+ return super.dispatchTouchEvent(ev)
52
+ }
53
+
45
54
  override fun onAttachedToWindow() {
46
55
  super.onAttachedToWindow()
47
56
  rebuildIfNeeded()
@@ -165,41 +165,27 @@ import ShortKitSDK
165
165
  /// Emit all cached state as individual events. Called from activatePlayback()
166
166
  /// (deferred) and can be called whenever we need to synchronize JS state.
167
167
  private func emitFullState() {
168
- bridge?.emit("onOverlayActiveChanged", body: [
169
- "surfaceId": surfaceId, "isActive": true
170
- ])
171
- bridge?.emit("onOverlayPlayerStateChanged", body: [
172
- "surfaceId": surfaceId, "playerState": cachedPlayerState
173
- ])
174
- bridge?.emit("onOverlayMutedChanged", body: [
175
- "surfaceId": surfaceId, "isMuted": cachedIsMuted
176
- ])
177
- bridge?.emit("onOverlayPlaybackRateChanged", body: [
178
- "surfaceId": surfaceId, "playbackRate": cachedPlaybackRate
179
- ])
180
- bridge?.emit("onOverlayCaptionsEnabledChanged", body: [
181
- "surfaceId": surfaceId, "captionsEnabled": cachedCaptionsEnabled
182
- ])
168
+ var body: [String: Any] = [
169
+ "surfaceId": surfaceId,
170
+ "isActive": true,
171
+ "playerState": cachedPlayerState,
172
+ "isMuted": cachedIsMuted,
173
+ "playbackRate": cachedPlaybackRate,
174
+ "captionsEnabled": cachedCaptionsEnabled,
175
+ ]
183
176
  if let cue = cachedActiveCue,
184
177
  let cueData = try? JSONSerialization.data(withJSONObject: cue),
185
178
  let cueJson = String(data: cueData, encoding: .utf8) {
186
- bridge?.emit("onOverlayActiveCueChanged", body: [
187
- "surfaceId": surfaceId, "activeCue": cueJson
188
- ])
179
+ body["activeCue"] = cueJson
189
180
  } else {
190
- bridge?.emit("onOverlayActiveCueChanged", body: [
191
- "surfaceId": surfaceId, "activeCue": NSNull()
192
- ])
181
+ body["activeCue"] = NSNull()
193
182
  }
194
183
  if let scrollPhase = cachedFeedScrollPhase {
195
- bridge?.emit("onOverlayFeedScrollPhaseChanged", body: [
196
- "surfaceId": surfaceId, "feedScrollPhase": scrollPhase
197
- ])
184
+ body["feedScrollPhase"] = scrollPhase
198
185
  } else {
199
- bridge?.emit("onOverlayFeedScrollPhaseChanged", body: [
200
- "surfaceId": surfaceId, "feedScrollPhase": NSNull()
201
- ])
186
+ body["feedScrollPhase"] = NSNull()
202
187
  }
188
+ bridge?.emit("onOverlayFullState", body: body)
203
189
  }
204
190
 
205
191
  // MARK: - Surface Creation