@shortkitsdk/react-native 0.2.35 → 0.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/android/build.gradle.kts +8 -0
  2. package/android/libs/shortkit-release.aar +0 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +94 -46
  4. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +46 -7
  5. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +233 -27
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +252 -27
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +135 -6
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +15 -0
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +21 -11
  10. package/android/src/test/java/com/shortkit/reactnative/ReactCarouselOverlayHostEmitTest.kt +134 -0
  11. package/android/src/test/java/com/shortkit/reactnative/ReactOverlayHostDragTest.kt +45 -0
  12. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostDragTest.kt +69 -0
  13. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostEmitTest.kt +144 -0
  14. package/android/src/test/java/com/shortkit/reactnative/ShortKitFeedViewActivePropTest.kt +57 -0
  15. package/ios/ReactOverlayHost.swift +10 -8
  16. package/ios/ReactVideoCarouselOverlayHost.swift +14 -11
  17. package/ios/ShortKitBridge.swift +18 -0
  18. package/ios/ShortKitModule.mm +5 -0
  19. package/ios/ShortKitPlayerNativeView.swift +36 -0
  20. package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  22. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +932 -84
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +26 -2
  24. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +26 -2
  26. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +932 -84
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +26 -2
  31. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  32. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +26 -2
  33. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +932 -84
  34. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +26 -2
  35. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  36. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +26 -2
  37. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  38. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  39. package/package.json +1 -1
  40. package/src/ShortKitCommands.ts +20 -0
  41. package/src/ShortKitFeed.tsx +21 -0
  42. package/src/specs/NativeShortKitModule.ts +10 -0
  43. package/src/types.ts +35 -0
@@ -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,7 +206,11 @@ 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
@@ -146,20 +227,28 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
146
227
 
147
228
  val json = Json.encodeToString(item)
148
229
  carouselItemJSON = json
149
- val bundle = Bundle().apply {
150
- putString("surfaceId", surfaceId)
151
- putString("carouselItem", json)
152
- putBoolean("isActive", false)
153
- putString("playerState", "idle")
154
- putBoolean("isMuted", cachedIsMuted)
155
- if (item.videos.isNotEmpty()) {
156
- putString("activeVideo", ShortKitBridge.serializeContentItemToJSON(item.videos.first()))
157
- 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
158
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)
159
248
  }
160
- applySurfaceProps(bundle)
249
+ // else: same item rebind — no emit, no Fabric prop push.
161
250
 
162
- // Pre-size the surface view
251
+ // Pre-size the surface view (existing logic, unchanged).
163
252
  val parentView = parent as? android.view.View
164
253
  val w = if (width > 0) width
165
254
  else if (parentView != null && parentView.width > 0) parentView.width
@@ -170,6 +259,58 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
170
259
  measureAndLayoutSurfaceView(w, h)
171
260
  }
172
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
+
173
314
  override fun updateActiveVideo(index: Int, item: ContentItem) {
174
315
  // Only emit when active — matches ReactOverlayHost pattern.
175
316
  // During initial setup, configure() sets the first video via surface props.
@@ -221,12 +362,12 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
221
362
  scope.launch {
222
363
  player.playerState.collect { state ->
223
364
  cachedPlayerState = ShortKitBridge.playerStateString(state)
224
- if (isActive) {
225
- val params = Arguments.createMap().apply {
365
+ if (isActive && !isDragging) {
366
+ val params = createMap().apply {
226
367
  putString("surfaceId", surfaceId)
227
368
  putString("playerState", cachedPlayerState)
228
369
  }
229
- ShortKitBridge.shared?.emitEvent("onOverlayPlayerStateChanged", params)
370
+ emitEvent("onOverlayPlayerStateChanged", params)
230
371
  }
231
372
  }
232
373
  }
@@ -234,12 +375,12 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
234
375
  scope.launch {
235
376
  player.isMuted.collect { muted ->
236
377
  cachedIsMuted = muted
237
- if (isActive) {
238
- val params = Arguments.createMap().apply {
378
+ if (isActive && !isDragging) {
379
+ val params = createMap().apply {
239
380
  putString("surfaceId", surfaceId)
240
381
  putBoolean("isMuted", cachedIsMuted)
241
382
  }
242
- ShortKitBridge.shared?.emitEvent("onOverlayMutedChanged", params)
383
+ emitEvent("onOverlayMutedChanged", params)
243
384
  }
244
385
  }
245
386
  }
@@ -249,11 +390,56 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
249
390
  cachedCurrentTime = time.currentMs / 1000.0
250
391
  cachedDuration = time.durationMs / 1000.0
251
392
  cachedBuffered = time.bufferedMs / 1000.0
252
- if (isActive) {
393
+ if (isActive && !isDragging) {
253
394
  timeDirty = true
254
395
  }
255
396
  }
256
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
+ }
257
443
  }
258
444
 
259
445
  // ------------------------------------------------------------------
@@ -330,6 +516,24 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
330
516
  }
331
517
  }
332
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
+
333
537
  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
334
538
  super.onSizeChanged(w, h, oldw, oldh)
335
539
  if (w > 0 && h > 0) {
@@ -387,17 +591,18 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
387
591
  private fun createSurfaceIfNeeded() {
388
592
  if (surface != null) return
389
593
 
390
- val reactHost = (context.applicationContext as? ReactApplication)?.reactHost
391
- if (reactHost == null) {
392
- android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
393
- return
394
- }
395
594
  val moduleName = "ShortKitVideoCarouselOverlay_$videoCarouselOverlayName"
396
595
 
397
596
  val initialProps = pendingProps
398
597
  pendingProps = null
399
598
 
400
- 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
+ }
401
606
  surface = newSurface
402
607
 
403
608
  newSurface.view?.let { surfaceView ->
@@ -430,6 +635,7 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
430
635
  flowScope?.cancel()
431
636
  flowScope = null
432
637
  isActive = false
638
+ currentCarouselItem = null
433
639
  stopTimeCoalescing()
434
640
  if (surface?.isRunning == true) {
435
641
  surface?.stop()