@shortkitsdk/react-native 0.2.34 → 0.2.36

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 (52) 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 +100 -47
  4. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +54 -8
  5. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +240 -27
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +151 -1
  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/main/java/com/shortkit/reactnative/ShortKitPackage.kt +0 -2
  11. package/android/src/test/java/com/shortkit/reactnative/ReactCarouselOverlayHostEmitTest.kt +134 -0
  12. package/android/src/test/java/com/shortkit/reactnative/ReactOverlayHostDragTest.kt +45 -0
  13. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostDragTest.kt +69 -0
  14. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostEmitTest.kt +144 -0
  15. package/android/src/test/java/com/shortkit/reactnative/ShortKitFeedViewActivePropTest.kt +57 -0
  16. package/ios/ReactOverlayHost.swift +10 -8
  17. package/ios/ReactVideoCarouselOverlayHost.swift +6 -5
  18. package/ios/ShortKitBridge.swift +18 -0
  19. package/ios/ShortKitModule.mm +5 -0
  20. package/ios/ShortKitPlayerNativeView.swift +36 -0
  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 +1252 -82
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +28 -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 +28 -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 +1252 -82
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +28 -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 +28 -2
  33. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +1252 -82
  34. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +28 -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 +28 -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/ios/ShortKitWidgetNativeView.swift +30 -3
  40. package/ios/ShortKitWidgetNativeViewManager.mm +1 -0
  41. package/package.json +1 -1
  42. package/src/ShortKitCommands.ts +20 -0
  43. package/src/ShortKitFeed.tsx +21 -0
  44. package/src/ShortKitPlayer.tsx +20 -1
  45. package/src/ShortKitWidget.tsx +63 -15
  46. package/src/specs/NativeShortKitModule.ts +10 -0
  47. package/src/specs/ShortKitWidgetViewNativeComponent.ts +15 -3
  48. package/src/types.ts +40 -0
  49. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +0 -149
  50. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerViewManager.kt +0 -35
  51. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +0 -149
  52. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetViewManager.kt +0 -30
@@ -4,6 +4,7 @@ import android.content.Context
4
4
  import android.os.Bundle
5
5
  import android.os.Handler
6
6
  import android.os.Looper
7
+ import android.view.MotionEvent
7
8
  import android.widget.FrameLayout
8
9
  import com.facebook.react.ReactApplication
9
10
  import com.facebook.react.bridge.Arguments
@@ -11,6 +12,7 @@ import com.facebook.react.interfaces.fabric.ReactSurface
11
12
  import com.facebook.react.runtime.ReactSurfaceImpl
12
13
  import com.shortkit.sdk.ShortKitPlayer
13
14
  import com.shortkit.sdk.model.ContentItem
15
+ import com.shortkit.sdk.model.FeedScrollPhase
14
16
  import com.shortkit.sdk.model.PlayerState
15
17
  import com.shortkit.sdk.model.VideoCarouselItem
16
18
  import com.shortkit.sdk.overlay.VideoCarouselOverlay
@@ -40,9 +42,17 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
40
42
  const val TAG = "SK:VidCarouselHost"
41
43
  }
42
44
 
43
- // Touch handling: this view sits below the ViewPager2 in z-order.
44
- // The cell's root FrameLayout dispatches taps to this overlay when the
45
- // ViewPager2 doesn't consume them (i.e. non-scroll touches).
45
+ // Touch handling: this view sits ABOVE the ViewPager2 in z-order
46
+ // (overlay_container is the last child of cell_video_carousel.xml's
47
+ // root FrameLayout). RN's pointerEvents="box-none" lets the React
48
+ // tree claim DOWN only for interactive children; the inner pager
49
+ // sees DOWN for empty-area touches via standard z-order dispatch.
50
+ //
51
+ // For touches that DO land on interactive overlay children — chiefly
52
+ // the scrubber drag — we additionally call requestDisallowIntercept
53
+ // on the parent chain so the inner carousel pager and the outer feed
54
+ // pager don't intercept on subsequent MOVE events. Mirrors the same
55
+ // idiom used in ReactOverlayHost for the regular feed overlay.
46
56
 
47
57
  // ------------------------------------------------------------------
48
58
  // Configuration
@@ -65,6 +75,62 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
65
75
  /** Cached carouselItem JSON — updateInitProps replaces all props, doesn't merge. */
66
76
  private var carouselItemJSON: String? = null
67
77
 
78
+ /**
79
+ * Currently configured carousel item — used to detect item transitions so
80
+ * we can emit an event instead of triggering a full Fabric remount, and
81
+ * (SHO-15) to surface-filter onCarouselActiveVideoCompleted events so
82
+ * only the host bound to the just-completed video's carousel emits.
83
+ */
84
+ internal var currentCarouselItem: VideoCarouselItem? = null
85
+ private set
86
+
87
+ /**
88
+ * Whether initial props have been pushed at least once. First mount goes
89
+ * through applySurfaceProps + emit (matches iOS asymmetry); subsequent
90
+ * different-item rebinds emit only.
91
+ */
92
+ internal var hasPushedInitialProps: Boolean = false
93
+ private set
94
+
95
+ /**
96
+ * Tracks active drag phase. While true, per-frame player-state emissions
97
+ * (playerState, isMuted, time) are suppressed; emitFullState() re-syncs
98
+ * cached values on settle.
99
+ */
100
+ internal var isDragging: Boolean = false
101
+ private set
102
+
103
+ /** Test-only setter for [isDragging]. */
104
+ internal var isDraggingForTest: Boolean
105
+ get() = isDragging
106
+ set(value) { isDragging = value }
107
+
108
+ /**
109
+ * Test seam for emit-event interception. Production uses the default;
110
+ * tests overwrite to capture emitted (name, params) pairs.
111
+ */
112
+ internal var emitEvent: (String, com.facebook.react.bridge.WritableMap) -> Unit =
113
+ { name, params -> ShortKitBridge.shared?.emitEvent(name, params) }
114
+
115
+ /**
116
+ * Test seam for WritableMap creation. Production uses Arguments.createMap()
117
+ * which requires JNI; tests overwrite with JavaOnlyMap() to avoid Robolectric
118
+ * NativeLoader issues. Same pattern as ReactCarouselOverlayHost.
119
+ */
120
+ internal var createMap: () -> com.facebook.react.bridge.WritableMap =
121
+ { com.facebook.react.bridge.Arguments.createMap() }
122
+
123
+ /**
124
+ * Test seam for ReactSurface creation. Production resolves from ReactHost.
125
+ * Tests inject a stub so the surface is non-null after applySurfaceProps()
126
+ * and hasPushedInitialProps can flip. Same pattern as ReactCarouselOverlayHost.
127
+ */
128
+ internal var createSurface: (moduleName: String, initialProps: Bundle?) -> ReactSurface? =
129
+ { moduleName, initialProps ->
130
+ (context.applicationContext as? ReactApplication)?.reactHost
131
+ ?.createSurface(context, moduleName, initialProps)
132
+ }
133
+
68
134
  // Player state
69
135
  private var player: ShortKitPlayer? = null
70
136
  private var isActive: Boolean = false
@@ -76,6 +142,17 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
76
142
  private var timeDirty: Boolean = false
77
143
  private val handler = Handler(Looper.getMainLooper())
78
144
  private var timeCoalesceRunnable: Runnable? = null
145
+
146
+ /**
147
+ * Bottom strip in which a DOWN signals "interactive overlay drag pending"
148
+ * (chiefly the scrubber). Width = scrubberTouchArea height (40dp) plus the
149
+ * sample's BOTTOM_SAFE_AREA (100dp) = 140dp. Matches the JS scrubberTouchArea
150
+ * geometry in NewsVideoCarouselOverlay.tsx; future overlays with non-default
151
+ * BOTTOM_SAFE_AREA will need a prop to communicate this down.
152
+ */
153
+ private val interactiveBottomStripPx: Int
154
+ get() = (140 * resources.displayMetrics.density).toInt()
155
+
79
156
  private var flowScope: CoroutineScope? = null
80
157
 
81
158
  // ------------------------------------------------------------------
@@ -129,30 +206,49 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
129
206
  // ------------------------------------------------------------------
130
207
 
131
208
  override fun configure(item: VideoCarouselItem) {
209
+ val isSameItem = item.id == currentCarouselItem?.id
210
+ currentCarouselItem = item
211
+
132
212
  isActive = false
213
+ isDragging = false
133
214
  timeDirty = false
134
215
  stopTimeCoalescing()
135
216
  cachedCurrentTime = 0.0
136
217
  cachedDuration = 0.0
137
218
  cachedBuffered = 0.0
138
219
  cachedPlayerState = "idle"
220
+ // Seed cachedIsMuted from the SDK's current value so the new
221
+ // surface's initialProps reflect actual mute state. The flow
222
+ // subscription (resubscribeToPlayer) will keep it in sync going
223
+ // forward, but its first emission can race with a host tap on the
224
+ // new cell, leading to a stale-true bug where the toggle target is
225
+ // computed wrong.
226
+ player?.isMuted?.value?.let { cachedIsMuted = it }
139
227
 
140
228
  val json = Json.encodeToString(item)
141
229
  carouselItemJSON = json
142
- val bundle = Bundle().apply {
143
- putString("surfaceId", surfaceId)
144
- putString("carouselItem", json)
145
- putBoolean("isActive", false)
146
- putString("playerState", "idle")
147
- putBoolean("isMuted", cachedIsMuted)
148
- if (item.videos.isNotEmpty()) {
149
- putString("activeVideo", ShortKitBridge.serializeContentItemToJSON(item.videos.first()))
150
- putInt("activeVideoIndex", 0)
230
+
231
+ if (!hasPushedInitialProps) {
232
+ // First mount: BOTH applySurfaceProps AND emit. Matches iOS asymmetry
233
+ // at ReactVideoCarouselOverlayHost.swift:127-138.
234
+ applySurfaceProps(buildInitialPropsBundle(item, json))
235
+ // Only flip the flag if surface was actually created. If reactHost was
236
+ // null (createSurface seam returned null), surface remains null and
237
+ // pendingProps is parked — stay in "first mount" mode so the next
238
+ // configure() retries. Mirrors iOS pattern.
239
+ if (surface != null) {
240
+ hasPushedInitialProps = true
151
241
  }
242
+ // Emit fires regardless of surface state — external subscribers
243
+ // should see the item update even when the in-surface listener
244
+ // isn't yet wired. iOS does the same at :135-137.
245
+ emitItemChanged(item, json)
246
+ } else if (!isSameItem) {
247
+ emitItemChanged(item, json)
152
248
  }
153
- applySurfaceProps(bundle)
249
+ // else: same item rebind — no emit, no Fabric prop push.
154
250
 
155
- // Pre-size the surface view
251
+ // Pre-size the surface view (existing logic, unchanged).
156
252
  val parentView = parent as? android.view.View
157
253
  val w = if (width > 0) width
158
254
  else if (parentView != null && parentView.width > 0) parentView.width
@@ -163,6 +259,58 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
163
259
  measureAndLayoutSurfaceView(w, h)
164
260
  }
165
261
 
262
+ /**
263
+ * Build the initial-properties bundle for first-mount setProperties path.
264
+ * Mirrors iOS buildInitialProps(item:json:) at
265
+ * ReactVideoCarouselOverlayHost.swift:143-158.
266
+ *
267
+ * Note: when videos is empty, activeVideo + activeVideoIndex keys are
268
+ * OMITTED from the bundle (matches iOS first-mount behavior). The
269
+ * emit path uses a separate convention — it emits putNull("activeVideo")
270
+ * to match iOS NSNull(). See emitItemChanged() below.
271
+ */
272
+ private fun buildInitialPropsBundle(item: VideoCarouselItem, json: String): Bundle =
273
+ Bundle().apply {
274
+ putString("surfaceId", surfaceId)
275
+ putString("carouselItem", json)
276
+ putBoolean("isActive", false)
277
+ putString("playerState", "idle")
278
+ putBoolean("isMuted", cachedIsMuted)
279
+ if (item.videos.isNotEmpty()) {
280
+ putString("activeVideo", ShortKitBridge.serializeContentItemToJSON(item.videos.first()))
281
+ putInt("activeVideoIndex", 0)
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Emit onVideoCarouselItemChanged for React diff on cell reuse.
287
+ * Mirrors iOS emitItemChanged(item:json:) at
288
+ * ReactVideoCarouselOverlayHost.swift:162-183.
289
+ *
290
+ * Empty-videos: uses putNull("activeVideo") — NOT key omission. This
291
+ * matches iOS NSNull() and avoids the empty-string-falsy-check bug
292
+ * documented at iOS :175-180.
293
+ */
294
+ private fun emitItemChanged(item: VideoCarouselItem, json: String) {
295
+ val params = createMap().apply {
296
+ putString("surfaceId", surfaceId)
297
+ putString("carouselItem", json)
298
+ putBoolean("isActive", false)
299
+ putString("playerState", "idle")
300
+ putBoolean("isMuted", cachedIsMuted)
301
+ putInt("activeVideoIndex", 0)
302
+ if (item.videos.isNotEmpty()) {
303
+ putString(
304
+ "activeVideo",
305
+ ShortKitBridge.serializeContentItemToJSON(item.videos.first()),
306
+ )
307
+ } else {
308
+ putNull("activeVideo")
309
+ }
310
+ }
311
+ emitEvent("onVideoCarouselItemChanged", params)
312
+ }
313
+
166
314
  override fun updateActiveVideo(index: Int, item: ContentItem) {
167
315
  // Only emit when active — matches ReactOverlayHost pattern.
168
316
  // During initial setup, configure() sets the first video via surface props.
@@ -214,12 +362,12 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
214
362
  scope.launch {
215
363
  player.playerState.collect { state ->
216
364
  cachedPlayerState = ShortKitBridge.playerStateString(state)
217
- if (isActive) {
218
- val params = Arguments.createMap().apply {
365
+ if (isActive && !isDragging) {
366
+ val params = createMap().apply {
219
367
  putString("surfaceId", surfaceId)
220
368
  putString("playerState", cachedPlayerState)
221
369
  }
222
- ShortKitBridge.shared?.emitEvent("onOverlayPlayerStateChanged", params)
370
+ emitEvent("onOverlayPlayerStateChanged", params)
223
371
  }
224
372
  }
225
373
  }
@@ -227,12 +375,12 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
227
375
  scope.launch {
228
376
  player.isMuted.collect { muted ->
229
377
  cachedIsMuted = muted
230
- if (isActive) {
231
- val params = Arguments.createMap().apply {
378
+ if (isActive && !isDragging) {
379
+ val params = createMap().apply {
232
380
  putString("surfaceId", surfaceId)
233
381
  putBoolean("isMuted", cachedIsMuted)
234
382
  }
235
- ShortKitBridge.shared?.emitEvent("onOverlayMutedChanged", params)
383
+ emitEvent("onOverlayMutedChanged", params)
236
384
  }
237
385
  }
238
386
  }
@@ -242,11 +390,56 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
242
390
  cachedCurrentTime = time.currentMs / 1000.0
243
391
  cachedDuration = time.durationMs / 1000.0
244
392
  cachedBuffered = time.bufferedMs / 1000.0
245
- if (isActive) {
393
+ if (isActive && !isDragging) {
246
394
  timeDirty = true
247
395
  }
248
396
  }
249
397
  }
398
+
399
+ scope.launch {
400
+ player.feedScrollPhase.collect { phase ->
401
+ when (phase) {
402
+ is FeedScrollPhase.Dragging -> {
403
+ isDragging = true
404
+ stopTimeCoalescing()
405
+ }
406
+ is FeedScrollPhase.Settled -> {
407
+ val wasDragging = isDragging
408
+ isDragging = false
409
+ if (isActive) {
410
+ startTimeCoalescing()
411
+ if (wasDragging) emitFullState()
412
+ }
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ // SHO-15: subscribe to ShortKitCarousel.activeVideoCompleted and emit
419
+ // onCarouselActiveVideoCompleted to JS. Surface-filtering: only the
420
+ // currently-active host (one per active carousel cell) emits, AND the
421
+ // completed item's id must belong to this host's bound carousel.
422
+ scope.launch {
423
+ val carousel = com.shortkit.sdk.ShortKit.activeInstance()?.carousel ?: return@launch
424
+ carousel.activeVideoCompleted.collect { event ->
425
+ if (!isActive) return@collect
426
+ val ourCarousel = currentCarouselItem ?: return@collect
427
+ val belongs = ourCarousel.videos.any { it.id == event.contentItem.id }
428
+ if (!belongs) return@collect
429
+
430
+ val feedId = ShortKitBridge.shared?.activeSurfaceFeedId() ?: ""
431
+ val params = Arguments.createMap().apply {
432
+ putString("feedId", feedId)
433
+ putString("surfaceId", surfaceId)
434
+ putString("contentItem", ShortKitBridge.serializeContentItemToJSON(event.contentItem))
435
+ putInt("indexInCarousel", event.indexInCarousel)
436
+ putString("carouselItem", ShortKitBridge.serializeVideoCarouselItemToJSON(event.carouselItem))
437
+ putBoolean("wasLast", event.wasLast)
438
+ putBoolean("willAutoAdvance", event.willAutoAdvance)
439
+ }
440
+ ShortKitBridge.shared?.emitEvent("onCarouselActiveVideoCompleted", params)
441
+ }
442
+ }
250
443
  }
251
444
 
252
445
  // ------------------------------------------------------------------
@@ -323,6 +516,24 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
323
516
  }
324
517
  }
325
518
 
519
+ /**
520
+ * Pre-dispatch hook so a DOWN inside the scrubber strip immediately tells
521
+ * every ancestor (the cell, the inner carousel pager, and the outer feed
522
+ * pager) to stop intercepting touch events. Without this, the inner
523
+ * carousel pager's horizontal-pan recognizer and the outer feed pager's
524
+ * vertical-pan recognizer can grab a scrubber drag mid-stream.
525
+ *
526
+ * Mirrors ReactOverlayHost.onInterceptTouchEvent for the regular feed.
527
+ */
528
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
529
+ if (ev.action == MotionEvent.ACTION_DOWN && height > 0) {
530
+ if (ev.y > height - interactiveBottomStripPx) {
531
+ parent?.requestDisallowInterceptTouchEvent(true)
532
+ }
533
+ }
534
+ return super.onInterceptTouchEvent(ev)
535
+ }
536
+
326
537
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
327
538
  super.onSizeChanged(w, h, oldw, oldh)
328
539
  if (w > 0 && h > 0) {
@@ -380,17 +591,18 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
380
591
  private fun createSurfaceIfNeeded() {
381
592
  if (surface != null) return
382
593
 
383
- val reactHost = (context.applicationContext as? ReactApplication)?.reactHost
384
- if (reactHost == null) {
385
- android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
386
- return
387
- }
388
594
  val moduleName = "ShortKitVideoCarouselOverlay_$videoCarouselOverlayName"
389
595
 
390
596
  val initialProps = pendingProps
391
597
  pendingProps = null
392
598
 
393
- val newSurface = reactHost.createSurface(context, moduleName, initialProps)
599
+ val newSurface = createSurface(moduleName, initialProps)
600
+ if (newSurface == null) {
601
+ android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
602
+ // Restore pending props so a future createSurfaceIfNeeded() can retry.
603
+ if (initialProps != null) pendingProps = initialProps
604
+ return
605
+ }
394
606
  surface = newSurface
395
607
 
396
608
  newSurface.view?.let { surfaceView ->
@@ -423,6 +635,7 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
423
635
  flowScope?.cancel()
424
636
  flowScope = null
425
637
  isActive = false
638
+ currentCarouselItem = null
426
639
  stopTimeCoalescing()
427
640
  if (surface?.isRunning == true) {
428
641
  surface?.stop()
@@ -76,6 +76,25 @@ 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
+
79
98
  /**
80
99
  * Serialize a [ContentItem] to a JSON string for bridge transport.
81
100
  */
@@ -349,7 +368,7 @@ class ShortKitBridge(
349
368
  } catch (_: Exception) { null }
350
369
  }
351
370
 
352
- private fun parseFeedInputs(json: String): List<FeedInput>? {
371
+ internal fun parseFeedInputs(json: String): List<FeedInput>? {
353
372
  return try {
354
373
  val arr = JSONArray(json)
355
374
  val result = mutableListOf<FeedInput>()
@@ -535,7 +554,12 @@ class ShortKitBridge(
535
554
  // Wire delegate
536
555
  sdk.delegate = object : ShortKitDelegate {
537
556
  override fun onContentTapped(contentId: String, index: Int) {
557
+ // Capture feedId synchronously — after the post-to-main hop
558
+ // the active surface may have shifted (user swiped tabs).
559
+ // Mirrors iOS ShortKitBridge.swift:1174 pattern.
560
+ val feedId = activeSurfaceFeedId()
538
561
  val params = Arguments.createMap().apply {
562
+ putString("feedId", feedId)
539
563
  putString("contentId", contentId)
540
564
  putInt("index", index)
541
565
  }
@@ -550,7 +574,9 @@ class ShortKitBridge(
550
574
  lastProgressEmitTime = now
551
575
  }
552
576
 
577
+ val feedId = activeSurfaceFeedId()
553
578
  val params = Arguments.createMap().apply {
579
+ putString("feedId", feedId)
554
580
  when (state) {
555
581
  is ShortKitRefreshState.Idle -> {
556
582
  putString("status", "idle")
@@ -574,11 +600,13 @@ class ShortKitBridge(
574
600
  }
575
601
 
576
602
  override fun onFeedContentFetched(items: List<ContentItem>) {
603
+ val feedId = activeSurfaceFeedId()
577
604
  val arr = org.json.JSONArray()
578
605
  for (item in items) {
579
606
  arr.put(org.json.JSONObject(serializeContentItemToJSON(item)))
580
607
  }
581
608
  val params = Arguments.createMap().apply {
609
+ putString("feedId", feedId)
582
610
  putString("items", arr.toString())
583
611
  }
584
612
  emitEventOnMain("onDidFetchContentItems", params)
@@ -640,6 +668,25 @@ class ShortKitBridge(
640
668
  }
641
669
  emitEventOnMain("onFeedReady", params)
642
670
  }
671
+ // Per-feed pull-to-refresh — the JS-side ShortKitFeed subscribes
672
+ // to `onRefreshStateChangedPerFeed` (not the unscoped `onRefreshStateChanged`),
673
+ // so this is the event that actually drives the host's refresh
674
+ // handler. Mirrors iOS ShortKitBridge.swift:74-90 emit pattern.
675
+ fragment.onRefreshStateChanged = { state ->
676
+ // Throttle pulling events to max 1 per 16ms to avoid bridge saturation
677
+ if (state is ShortKitRefreshState.Pulling) {
678
+ val now = android.os.SystemClock.uptimeMillis()
679
+ if (now - lastProgressEmitTime >= 16L) {
680
+ lastProgressEmitTime = now
681
+ emitRefreshPerFeed(id, state)
682
+ }
683
+ } else {
684
+ emitRefreshPerFeed(id, state)
685
+ }
686
+ }
687
+ fragment.onVideoCarouselCellTap = { payload ->
688
+ emitVideoCarouselCellTap(payload.id, payload.index, payload.pageIndex)
689
+ }
643
690
 
644
691
  // Replay buffered operations after the fragment's view is created.
645
692
  // commitNowAllowingStateLoss triggers onCreate but onViewCreated
@@ -669,6 +716,61 @@ class ShortKitBridge(
669
716
  return feedRegistry[id]?.get()
670
717
  }
671
718
 
719
+ /**
720
+ * Emit `onRefreshStateChangedPerFeed` with feedId in the payload.
721
+ * Used by the per-feed [ShortKitFeedFragment.onRefreshStateChanged]
722
+ * hook wired in [registerFeedFragment]. Mirrors iOS
723
+ * `ShortKitBridge.swift:74-90` body construction.
724
+ */
725
+ private fun emitRefreshPerFeed(feedId: String, state: ShortKitRefreshState) {
726
+ val params = Arguments.createMap().apply {
727
+ putString("feedId", feedId)
728
+ when (state) {
729
+ is ShortKitRefreshState.Idle -> {
730
+ putString("status", "idle")
731
+ putDouble("progress", 0.0)
732
+ }
733
+ is ShortKitRefreshState.Pulling -> {
734
+ putString("status", "pulling")
735
+ putDouble("progress", state.progress.toDouble())
736
+ }
737
+ is ShortKitRefreshState.Triggered -> {
738
+ putString("status", "triggered")
739
+ putDouble("progress", 0.0)
740
+ }
741
+ is ShortKitRefreshState.Refreshing -> {
742
+ putString("status", "refreshing")
743
+ putDouble("progress", 0.0)
744
+ }
745
+ }
746
+ }
747
+ emitEventOnMain("onRefreshStateChangedPerFeed", params)
748
+ }
749
+
750
+ /**
751
+ * Reverse lookup: which registered feedId corresponds to the SDK's
752
+ * currently-active surface? Returns the empty string when no feed
753
+ * surface is active or when the active surface isn't a registered
754
+ * fragment (e.g. host-driven scenario without RN bridge mounting).
755
+ *
756
+ * Used by the three delegate-level emit sites
757
+ * (`onContentTapped`, `onRefreshStateChanged`, `onDidFetchContentItems`)
758
+ * so multi-feed setups route the callback to the originating
759
+ * `<ShortKitFeed>` instead of broadcasting to all mounted feeds.
760
+ *
761
+ * Mirrors iOS `ShortKitBridge.activeSurfaceFeedId()`
762
+ * (`react_native_sdk/ios/ShortKitBridge.swift:1174`). Capture this
763
+ * synchronously at delegate-call time; after any await/post the
764
+ * active surface may have shifted (user swiped tabs).
765
+ */
766
+ fun activeSurfaceFeedId(): String {
767
+ val active = shortKit?.activeSurface ?: return ""
768
+ for ((id, ref) in feedRegistry) {
769
+ if (ref.get() === active) return id
770
+ }
771
+ return ""
772
+ }
773
+
672
774
  fun registerFeed(id: String) {
673
775
  // No-op — registerFeedFragment handles drain
674
776
  }
@@ -735,6 +837,25 @@ class ShortKitBridge(
735
837
  emitEvent.invoke(name, params)
736
838
  }
737
839
 
840
+ /**
841
+ * Emit `onVideoCarouselCellTap` with the active surface's feedId and
842
+ * the cell-supplied (id, index, pageIndex). Mirrors iOS
843
+ * ShortKitBridge.swift:94-101.
844
+ *
845
+ * Called from the cell's onCellTap closure (installed in
846
+ * ShortKitFeedFragment) — see Task 5.
847
+ */
848
+ internal fun emitVideoCarouselCellTap(itemId: String, index: Int, pageIndex: Int) {
849
+ val feedId = activeSurfaceFeedId()
850
+ val params = Arguments.createMap().apply {
851
+ putString("feedId", feedId)
852
+ putString("id", itemId)
853
+ putInt("index", index)
854
+ putInt("pageIndex", pageIndex)
855
+ }
856
+ emitEventOnMain("onVideoCarouselCellTap", params)
857
+ }
858
+
738
859
  // ------------------------------------------------------------------
739
860
  // Player commands
740
861
  // ------------------------------------------------------------------
@@ -753,6 +874,24 @@ class ShortKitBridge(
753
874
  fun skipToNext() { runOnMain { shortKit?.player?.skipToNext() } }
754
875
  fun skipToPrevious() { runOnMain { shortKit?.player?.skipToPrevious() } }
755
876
  fun setMuted(muted: Boolean) { runOnMain { shortKit?.player?.setMuted(muted) } }
877
+
878
+ /**
879
+ * Toggle outer feed scroll on every registered feed. Note this
880
+ * intentionally diverges from iOS: iOS routes the same command only
881
+ * to the *active* feed (via player.onCommand), but Android fans out
882
+ * to all registered feeds — including masked-feed underlays. The
883
+ * user-visible effect is the same (the active feed's scroll is
884
+ * locked) and fanning out costs nothing, avoiding an active-surface
885
+ * lookup at this layer. Must run on main: viewPager.isUserInputEnabled
886
+ * mutates RecyclerView state, and feedRegistry is mutated on main
887
+ * during register/unregister — concurrent iteration would race.
888
+ */
889
+ fun setFeedScrollEnabled(enabled: Boolean) = runOnMain {
890
+ feedRegistry.values.forEach { ref ->
891
+ ref.get()?.setFeedScrollEnabled(enabled)
892
+ }
893
+ }
894
+
756
895
  fun setPlaybackRate(rate: Double) { runOnMain { shortKit?.player?.setPlaybackRate(rate.toFloat()) } }
757
896
  fun setCaptionsEnabled(enabled: Boolean) { runOnMain { shortKit?.player?.setCaptionsEnabled(enabled) } }
758
897
  fun selectCaptionTrack(language: String) { runOnMain { shortKit?.player?.selectCaptionTrack(language) } }
@@ -762,6 +901,17 @@ class ShortKitBridge(
762
901
  }
763
902
  fun setMaxBitrate(bitrate: Double) { runOnMain { shortKit?.player?.setMaxBitrate(bitrate.toInt()) } }
764
903
 
904
+ // ---- SHO-15: ShortKitCarousel imperative methods + sync accessors ----
905
+ // ShortKitCarousel's onMain shim handles cross-thread routing internally,
906
+ // so we DON'T wrap with bridge.runOnMain — the namespace's threading
907
+ // contract already routes to main + blocks on the latch when called from
908
+ // a background thread.
909
+ fun carouselNext(): Boolean = shortKit?.carousel?.next() ?: false
910
+ fun carouselPrevious(): Boolean = shortKit?.carousel?.previous() ?: false
911
+ fun carouselSetActiveIndex(index: Int): Boolean = shortKit?.carousel?.setActiveIndex(index) ?: false
912
+ fun carouselActiveIndex(): Int = shortKit?.carousel?.activeIndexValue ?: -1
913
+ fun carouselVideoCount(): Int = shortKit?.carousel?.videoCountValue ?: 0
914
+
765
915
  fun setUserId(userId: String) {
766
916
  shortKit?.setUserId(userId)
767
917
  }