@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
@@ -31,8 +31,20 @@ android {
31
31
 
32
32
  dependencies {
33
33
  implementation("com.facebook.react:react-android")
34
- implementation("dev.shortkit:shortkit:0.2.6")
34
+ // When Reco's settings.gradle has the composite build, Gradle substitutes
35
+ // this with the live source from android_sdk/shortkit automatically.
36
+ // implementation("dev.shortkit:shortkit:0.2.11")
37
+ implementation(files("/Users/michaelseleman/shortkit/android_sdk/shortkit/build/outputs/aar/shortkit-release.aar"))
38
+ // Transitive deps needed when using local AAR (Maven artifact bundles these automatically)
39
+ implementation("androidx.media3:media3-exoplayer:1.5.1")
40
+ implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
41
+ implementation("androidx.media3:media3-datasource:1.5.1")
42
+ implementation("androidx.media3:media3-ui:1.5.1")
43
+ implementation("androidx.recyclerview:recyclerview:1.4.0")
35
44
  implementation("androidx.viewpager2:viewpager2:1.1.0")
45
+ implementation("androidx.fragment:fragment-ktx:1.8.5")
46
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
47
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
36
48
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
37
49
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
38
50
  }
@@ -11,6 +11,12 @@ import com.facebook.react.runtime.ReactSurfaceImpl
11
11
  import com.shortkit.sdk.model.CarouselImage
12
12
  import com.shortkit.sdk.model.ImageCarouselItem
13
13
  import com.shortkit.sdk.overlay.CarouselOverlay
14
+ import kotlinx.coroutines.CoroutineScope
15
+ import kotlinx.coroutines.Dispatchers
16
+ import kotlinx.coroutines.Job
17
+ import kotlinx.coroutines.SupervisorJob
18
+ import kotlinx.coroutines.launch
19
+ import kotlinx.coroutines.withContext
14
20
  import kotlinx.serialization.encodeToString
15
21
  import kotlinx.serialization.json.Json
16
22
  import java.io.File
@@ -45,6 +51,17 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
45
51
 
46
52
  private var surface: ReactSurface? = null
47
53
  private var pendingProps: Bundle? = null
54
+ private var isInLayoutPass: Boolean = false
55
+ private val ioScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
56
+ private var pendingWriteJob: Job? = null
57
+ private var configureGeneration: Int = 0
58
+
59
+ /** Unique identifier for this overlay instance, used for event routing. */
60
+ val surfaceId: String = java.util.UUID.randomUUID().toString()
61
+
62
+ private var cachedItemJSON: String? = null
63
+ private var isActive: Boolean = false
64
+ private var activeImageIndex: Int = 0
48
65
 
49
66
  // ------------------------------------------------------------------
50
67
  // Fabric layout workaround
@@ -52,15 +69,19 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
52
69
 
53
70
  private val layoutHandler = android.os.Handler(android.os.Looper.getMainLooper())
54
71
 
72
+ private val layoutRunnable = Runnable {
73
+ measure(
74
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
75
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
76
+ )
77
+ layout(left, top, right, bottom)
78
+ }
79
+
55
80
  override fun requestLayout() {
56
81
  super.requestLayout()
57
- @Suppress("UNNECESSARY_SAFE_CALL")
58
- layoutHandler?.post {
59
- measure(
60
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
61
- MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
62
- )
63
- layout(left, top, right, bottom)
82
+ if (!isInLayoutPass) {
83
+ @Suppress("UNNECESSARY_SAFE_CALL")
84
+ layoutHandler?.post(layoutRunnable)
64
85
  }
65
86
  }
66
87
 
@@ -109,65 +130,140 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
109
130
  // ------------------------------------------------------------------
110
131
 
111
132
  override fun configure(item: ImageCarouselItem) {
112
- // Replace remote URLs with local file:// URLs for any natively-cached images.
113
- val modifiedItem = cachedImage?.let { lookup ->
114
- val localImages = item.images.map { image ->
115
- val bitmap = lookup(image.url)
116
- if (bitmap != null) {
117
- val localUrl = writeTempImage(bitmap, image.url)
118
- if (localUrl != null) {
119
- CarouselImage(url = localUrl, alt = image.alt)
120
- } else {
121
- image
122
- }
123
- } else {
124
- image
125
- }
133
+ isActive = false
134
+ activeImageIndex = 0
135
+
136
+ // Increment generation — any in-flight coroutine from a previous
137
+ // configure() call will see a stale generation and bail out.
138
+ val gen = ++configureGeneration
139
+
140
+ // Cancel any in-flight write job from a previous configure() call.
141
+ pendingWriteJob?.cancel()
142
+
143
+ // Fast path: check for pre-existing temp files synchronously (just
144
+ // file.exists(), no I/O). This avoids the flicker of remote→local
145
+ // URL swap for images that were cached in a previous session.
146
+ val lookup = cachedImage
147
+ val fastItem = if (lookup != null) {
148
+ val fastImages = item.images.map { image ->
149
+ val hash = image.url.hashCode()
150
+ val fileName = "sk-carousel-${kotlin.math.abs(hash)}.jpg"
151
+ val file = File(context.cacheDir, fileName)
152
+ if (file.exists()) CarouselImage(url = "file://${file.absolutePath}", alt = image.alt)
153
+ else image
126
154
  }
127
155
  ImageCarouselItem(
128
156
  id = item.id,
129
- images = localImages,
157
+ images = fastImages,
130
158
  caption = item.caption,
131
159
  title = item.title,
132
160
  description = item.description,
133
161
  author = item.author,
134
162
  section = item.section,
135
- articleUrl = item.articleUrl
163
+ articleUrl = item.articleUrl,
136
164
  )
137
- } ?: item
165
+ } else item
166
+
167
+ val json = Json.encodeToString(fastItem)
168
+ cachedItemJSON = json
169
+ pushProps()
170
+
171
+ // Pre-size the surface view NOW — before the overlay is attached to a cell.
172
+ val parentView = parent as? android.view.View
173
+ val w = if (width > 0) width
174
+ else if (parentView != null && parentView.width > 0) parentView.width
175
+ else context.resources.displayMetrics.widthPixels
176
+ val h = if (height > 0) height
177
+ else if (parentView != null && parentView.height > 0) parentView.height
178
+ else context.resources.displayMetrics.heightPixels
179
+ measureAndLayoutSurfaceView(w, h)
180
+
181
+ // Background: write any newly-cached images to temp files.
182
+ // Only runs if there are remote URLs remaining (not all were fast-pathed).
183
+ if (lookup == null) return
184
+ val hasRemoteUrls = fastItem.images.any { !it.url.startsWith("file://") }
185
+ if (!hasRemoteUrls) return
186
+
187
+ pendingWriteJob = ioScope.launch {
188
+ val modifiedItem = withContext(Dispatchers.IO) {
189
+ val localImages = item.images.map { image ->
190
+ val bitmap = lookup(image.url)
191
+ if (bitmap != null) {
192
+ val localUrl = writeTempImage(bitmap, image.url)
193
+ if (localUrl != null) CarouselImage(url = localUrl, alt = image.alt)
194
+ else image
195
+ } else {
196
+ image
197
+ }
198
+ }
199
+ ImageCarouselItem(
200
+ id = item.id,
201
+ images = localImages,
202
+ caption = item.caption,
203
+ title = item.title,
204
+ description = item.description,
205
+ author = item.author,
206
+ section = item.section,
207
+ articleUrl = item.articleUrl,
208
+ )
209
+ }
138
210
 
139
- val json = Json.encodeToString(modifiedItem)
211
+ // Back on Main — only update if this configure() is still current
212
+ if (gen != configureGeneration) return@launch
213
+ val hasLocalImages = modifiedItem.images.any { it.url.startsWith("file://") }
214
+ if (hasLocalImages) {
215
+ val localJson = Json.encodeToString(modifiedItem)
216
+ cachedItemJSON = localJson
217
+ pushProps()
218
+ }
219
+ }
220
+ }
221
+
222
+ override fun activatePlayback() {
223
+ isActive = true
224
+ val params = com.facebook.react.bridge.Arguments.createMap().apply {
225
+ putString("surfaceId", surfaceId)
226
+ putBoolean("isActive", true)
227
+ putString("playerState", "idle")
228
+ putBoolean("isMuted", true)
229
+ putDouble("playbackRate", 1.0)
230
+ putBoolean("captionsEnabled", false)
231
+ putNull("activeCue")
232
+ putNull("feedScrollPhase")
233
+ }
234
+ ShortKitBridge.shared?.emitEvent("onOverlayFullState", params)
235
+ }
236
+
237
+ override fun updateActiveImage(index: Int) {
238
+ activeImageIndex = index
239
+ val params = com.facebook.react.bridge.Arguments.createMap().apply {
240
+ putString("surfaceId", surfaceId)
241
+ putInt("activeImageIndex", index)
242
+ }
243
+ ShortKitBridge.shared?.emitEvent("onCarouselActiveImageChanged", params)
244
+ }
245
+
246
+ /** Build a props bundle with item data and surfaceId. */
247
+ private fun pushProps() {
140
248
  val bundle = Bundle().apply {
141
- putString("item", json)
249
+ putString("surfaceId", surfaceId)
250
+ cachedItemJSON?.let { putString("item", it) }
142
251
  }
252
+ applySurfaceProps(bundle)
253
+ }
143
254
 
255
+ /** Apply props to the surface, handling all lifecycle states. */
256
+ private fun applySurfaceProps(bundle: Bundle) {
144
257
  val s = surface
145
258
  if (s != null && s.isRunning) {
146
- // Surface already running (cell reuse) — update props in place
147
259
  (s as? ReactSurfaceImpl)?.updateInitProps(bundle)
148
260
  } else if (s != null && !s.isRunning) {
149
- // Surface pre-created by prepareSurface() but not started yet.
150
- // Set props THEN start — the JS component mounts once with correct data.
151
261
  (s as? ReactSurfaceImpl)?.updateInitProps(bundle)
152
262
  s.start()
153
263
  } else {
154
- // No surface at all — create with item as initial props
155
264
  pendingProps = bundle
156
265
  createSurfaceIfNeeded()
157
266
  }
158
-
159
- // Pre-size the surface view NOW — before the overlay is attached to a cell.
160
- // This eliminates the black flash: when the cell displays the overlay, the
161
- // surface view already has correct dimensions and rendered content.
162
- // Use parent dimensions if attached, otherwise use display dimensions.
163
- val parentView = parent as? android.view.View
164
- val w = if (width > 0) width
165
- else if (parentView != null && parentView.width > 0) parentView.width
166
- else context.resources.displayMetrics.widthPixels
167
- val h = if (height > 0) height
168
- else if (parentView != null && parentView.height > 0) parentView.height
169
- else context.resources.displayMetrics.heightPixels
170
- measureAndLayoutSurfaceView(w, h)
171
267
  }
172
268
 
173
269
  /**
@@ -225,18 +321,23 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
225
321
  }
226
322
 
227
323
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
228
- super.onLayout(changed, left, top, right, bottom)
229
- val w = right - left
230
- val h = bottom - top
231
- if (w > 0 && h > 0) {
232
- measureAndLayoutSurfaceView(w, h)
233
- } else {
234
- val parentView = parent as? android.view.View
235
- val pw = parentView?.width ?: 0
236
- val ph = parentView?.height ?: 0
237
- if (pw > 0 && ph > 0) {
238
- measureAndLayoutSurfaceView(pw, ph)
324
+ isInLayoutPass = true
325
+ try {
326
+ super.onLayout(changed, left, top, right, bottom)
327
+ val w = right - left
328
+ val h = bottom - top
329
+ if (w > 0 && h > 0) {
330
+ measureAndLayoutSurfaceView(w, h)
331
+ } else {
332
+ val parentView = parent as? android.view.View
333
+ val pw = parentView?.width ?: 0
334
+ val ph = parentView?.height ?: 0
335
+ if (pw > 0 && ph > 0) {
336
+ measureAndLayoutSurfaceView(pw, ph)
337
+ }
239
338
  }
339
+ } finally {
340
+ isInLayoutPass = false
240
341
  }
241
342
  }
242
343
 
@@ -312,6 +413,8 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
312
413
 
313
414
  override fun onDetachedFromWindow() {
314
415
  super.onDetachedFromWindow()
416
+ pendingWriteJob?.cancel()
417
+ pendingWriteJob = null
315
418
  if (surface?.isRunning == true) {
316
419
  surface?.stop()
317
420
  }
@@ -60,19 +60,24 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
60
60
 
61
61
  private val layoutHandler = Handler(Looper.getMainLooper())
62
62
 
63
+ /** Reusable runnable to avoid lambda allocation on every requestLayout. */
64
+ private val layoutRunnable = Runnable {
65
+ measure(
66
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
67
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
68
+ )
69
+ layout(left, top, right, bottom)
70
+ }
71
+
63
72
  /**
64
73
  * Fabric may suppress layout propagation to native child views inside
65
74
  * the SDK's RecyclerView cells. Override to force a manual layout pass.
66
75
  */
67
76
  override fun requestLayout() {
68
77
  super.requestLayout()
69
- @Suppress("UNNECESSARY_SAFE_CALL")
70
- layoutHandler?.post {
71
- measure(
72
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
73
- MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
74
- )
75
- layout(left, top, right, bottom)
78
+ if (!isInLayoutPass) {
79
+ @Suppress("UNNECESSARY_SAFE_CALL")
80
+ layoutHandler?.post(layoutRunnable)
76
81
  }
77
82
  }
78
83
 
@@ -103,6 +108,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
103
108
  private var timeCoalesceRunnable: Runnable? = null
104
109
 
105
110
  private var flowScope: CoroutineScope? = null
111
+ private var isInLayoutPass: Boolean = false
106
112
 
107
113
  // ------------------------------------------------------------------
108
114
  // Init
@@ -147,14 +153,17 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
147
153
  resubscribeToPlayer(player)
148
154
  }
149
155
 
156
+ /** Whether the surface has been started at least once. */
157
+ private var surfaceHasStarted: Boolean = false
158
+
150
159
  override fun configure(item: ContentItem) {
160
+ val isSameItem = item.id == currentItem?.id
151
161
  currentItem = item
152
162
  isActive = false
153
163
  timeDirty = false
154
164
  stopTimeCoalescing()
155
165
 
156
166
  // Reset ALL cached state so recycled cells don't flash stale values.
157
- // Mirrors iOS ReactOverlayHost.configure(with:).
158
167
  cachedCurrentTime = 0.0
159
168
  cachedDuration = 0.0
160
169
  cachedBuffered = 0.0
@@ -164,12 +173,25 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
164
173
 
165
174
  if (surface == null) {
166
175
  createSurfaceIfNeeded()
167
- } else {
176
+ surfaceHasStarted = true
177
+ } else if (!surfaceHasStarted) {
168
178
  (surface as? ReactSurfaceImpl)?.updateInitProps(buildInitialPropsBundle())
179
+ surfaceHasStarted = true
180
+ } else if (isSameItem) {
181
+ // Same item = deactivation only. The overlay already has this
182
+ // item's data from a previous swipe. Just deactivate (isActive
183
+ // set to false above, timer stopped). No event emission — avoids
184
+ // broadcasting to all 7 surfaces for no visual change.
185
+ } else {
186
+ // Different item — send via event for React tree diff (not
187
+ // updateInitProps which causes full Fabric remount).
188
+ val params = Arguments.createMap().apply {
189
+ putString("surfaceId", surfaceId)
190
+ putString("item", ShortKitBridge.serializeContentItemToJSON(item))
191
+ }
192
+ ShortKitBridge.shared?.emitEvent("onOverlayItemChanged", params)
169
193
  }
170
-
171
- // Pre-size the surface view NOW — before the overlay is attached to a cell.
172
- // Eliminates the black flash on cell display.
194
+ // Pre-size the surface view
173
195
  val parentView = parent as? android.view.View
174
196
  val w = if (width > 0) width
175
197
  else if (parentView != null && parentView.width > 0) parentView.width
@@ -184,9 +206,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
184
206
  isActive = true
185
207
  startTimeCoalescing()
186
208
 
187
- // Defer the event burst to the next tick. The JS surface needs time to
188
- // mount and establish event subscriptions (useEffect runs after render).
189
- // Mirrors iOS: DispatchQueue.main.async { self.emitFullState() }
209
+ // Defer the event burst to the next tick.
190
210
  handler.post {
191
211
  if (isActive) {
192
212
  emitFullState()
@@ -195,13 +215,16 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
195
215
  }
196
216
 
197
217
  override fun fadeOutForTransition() {
198
- val item = currentItem ?: return
199
- ShortKitBridge.shared?.emitOverlayEvent("onOverlayFadeOut", item)
218
+ // No-op. The JS overlay component handles fade via feedScrollPhase
219
+ // prop (dragging/settled), not these events. iOS ReactOverlayHost
220
+ // doesn't implement these methods at all. The previous implementation
221
+ // serialized the full ContentItem to JSON and emitted across the
222
+ // bridge to a listener that doesn't exist — 2-15ms of pure waste
223
+ // on every swipe.
200
224
  }
201
225
 
202
226
  override fun restoreFromTransition() {
203
- val item = currentItem ?: return
204
- ShortKitBridge.shared?.emitOverlayEvent("onOverlayRestore", item)
227
+ // No-op. See fadeOutForTransition comment.
205
228
  }
206
229
 
207
230
  // ------------------------------------------------------------------
@@ -257,19 +280,24 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
257
280
  * calling setMinimumSize/setMaximumSize in ReactOverlayHost.swift.
258
281
  */
259
282
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
260
- super.onLayout(changed, left, top, right, bottom)
261
- val w = right - left
262
- val h = bottom - top
263
- if (w > 0 && h > 0) {
264
- measureAndLayoutSurfaceView(w, h)
265
- } else {
266
- // Host still 0x0 — try parent dimensions
267
- val parentView = parent as? android.view.View
268
- val pw = parentView?.width ?: 0
269
- val ph = parentView?.height ?: 0
270
- if (pw > 0 && ph > 0) {
271
- measureAndLayoutSurfaceView(pw, ph)
283
+ isInLayoutPass = true
284
+ try {
285
+ super.onLayout(changed, left, top, right, bottom)
286
+ val w = right - left
287
+ val h = bottom - top
288
+ if (w > 0 && h > 0) {
289
+ measureAndLayoutSurfaceView(w, h)
290
+ } else {
291
+ // Host still 0x0 try parent dimensions
292
+ val parentView = parent as? android.view.View
293
+ val pw = parentView?.width ?: 0
294
+ val ph = parentView?.height ?: 0
295
+ if (pw > 0 && ph > 0) {
296
+ measureAndLayoutSurfaceView(pw, ph)
297
+ }
272
298
  }
299
+ } finally {
300
+ isInLayoutPass = false
273
301
  }
274
302
  }
275
303
 
@@ -289,7 +317,9 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
289
317
  private fun createSurfaceIfNeeded() {
290
318
  if (surface != null) return
291
319
 
292
- val reactHost = (context.applicationContext as? ReactApplication)?.reactHost
320
+ val appContext = context.applicationContext
321
+ val reactHost = (appContext as? ReactApplication)?.reactHost
322
+
293
323
  if (reactHost == null) {
294
324
  android.util.Log.e(TAG, "[$surfaceId] createSurface FAILED: reactHost is null")
295
325
  return
@@ -310,10 +340,10 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
310
340
  } else {
311
341
  android.util.Log.e(TAG, "[$surfaceId] createSurface: surfaceView is NULL")
312
342
  }
343
+
313
344
  newSurface.start()
314
345
 
315
- // The host may still be 0x0 at this point (RecyclerView hasn't laid
316
- // out the cell yet). Use the parent's dimensions if available.
346
+ // The host may still be 0x0 at this point. Use the parent's dimensions.
317
347
  val parentView = parent as? android.view.View
318
348
  val w = if (width > 0) width else parentView?.width ?: 0
319
349
  val h = if (height > 0) height else parentView?.height ?: 0
@@ -519,41 +549,22 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
519
549
  private fun emitFullState() {
520
550
  val bridge = ShortKitBridge.shared ?: return
521
551
 
522
- bridge.emitEvent("onOverlayActiveChanged", Arguments.createMap().apply {
552
+ val params = Arguments.createMap().apply {
523
553
  putString("surfaceId", surfaceId)
524
554
  putBoolean("isActive", true)
525
- })
526
- bridge.emitEvent("onOverlayPlayerStateChanged", Arguments.createMap().apply {
527
- putString("surfaceId", surfaceId)
528
555
  putString("playerState", cachedPlayerState)
529
- })
530
- bridge.emitEvent("onOverlayMutedChanged", Arguments.createMap().apply {
531
- putString("surfaceId", surfaceId)
532
556
  putBoolean("isMuted", cachedIsMuted)
533
- })
534
- bridge.emitEvent("onOverlayPlaybackRateChanged", Arguments.createMap().apply {
535
- putString("surfaceId", surfaceId)
536
557
  putDouble("playbackRate", cachedPlaybackRate)
537
- })
538
- bridge.emitEvent("onOverlayCaptionsEnabledChanged", Arguments.createMap().apply {
539
- putString("surfaceId", surfaceId)
540
558
  putBoolean("captionsEnabled", cachedCaptionsEnabled)
541
- })
542
-
543
- bridge.emitEvent("onOverlayActiveCueChanged", Arguments.createMap().apply {
544
- putString("surfaceId", surfaceId)
545
559
  val cueJson = cachedActiveCue?.toString()
546
560
  if (cueJson != null) {
547
561
  putString("activeCue", cueJson)
548
562
  } else {
549
563
  putNull("activeCue")
550
564
  }
551
- })
552
-
553
- bridge.emitEvent("onOverlayFeedScrollPhaseChanged", Arguments.createMap().apply {
554
- putString("surfaceId", surfaceId)
555
565
  cachedFeedScrollPhase?.let { putString("feedScrollPhase", it) }
556
566
  ?: putNull("feedScrollPhase")
557
- })
567
+ }
568
+ bridge.emitEvent("onOverlayFullState", params)
558
569
  }
559
570
  }