@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
@@ -27,6 +27,10 @@ android {
27
27
  java.srcDirs("src/main/java")
28
28
  }
29
29
  }
30
+
31
+ testOptions {
32
+ unitTests.isIncludeAndroidResources = true
33
+ }
30
34
  }
31
35
 
32
36
  dependencies {
@@ -47,4 +51,8 @@ dependencies {
47
51
  implementation("com.squareup.okhttp3:okhttp:4.12.0")
48
52
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
49
53
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
54
+
55
+ testImplementation("junit:junit:4.13.2")
56
+ testImplementation("org.robolectric:robolectric:4.14.1")
57
+ testImplementation("androidx.test:core:1.6.1")
50
58
  }
Binary file
@@ -59,6 +59,52 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
59
59
  /** Unique identifier for this overlay instance, used for event routing. */
60
60
  val surfaceId: String = java.util.UUID.randomUUID().toString()
61
61
 
62
+ /**
63
+ * Currently configured item id — used to detect item transitions so we
64
+ * can emit an event instead of triggering a full Fabric remount.
65
+ * Marked `internal` to allow unit-test verification.
66
+ */
67
+ internal var currentItemId: String? = null
68
+ private set
69
+
70
+ /**
71
+ * Whether initial props have been pushed at least once. First configure
72
+ * must go through pushProps()/setProperties; subsequent same-item
73
+ * rebinds are no-op; subsequent different-item rebinds emit
74
+ * onCarouselItemChanged for a React diff (no Fabric remount).
75
+ */
76
+ internal var hasPushedInitialProps: Boolean = false
77
+ private set
78
+
79
+ /**
80
+ * Test seam for emit-event interception. Production uses the default
81
+ * (forward to ShortKitBridge.shared); tests can overwrite to capture.
82
+ * Mirrors how iOS tests wrap bridge.emit().
83
+ */
84
+ internal var emitEvent: (String, com.facebook.react.bridge.WritableMap) -> Unit =
85
+ { name, params -> ShortKitBridge.shared?.emitEvent(name, params) }
86
+
87
+ /**
88
+ * Test seam for WritableMap creation. Defaults to Arguments.createMap()
89
+ * (WritableNativeMap — requires JNI). Tests override with JavaOnlyMap()
90
+ * to avoid the native-library initialization requirement in Robolectric.
91
+ */
92
+ internal var createMap: () -> com.facebook.react.bridge.WritableMap =
93
+ { com.facebook.react.bridge.Arguments.createMap() }
94
+
95
+ /**
96
+ * Test seam for ReactSurface creation. Production implementation resolves
97
+ * the surface from the ReactHost obtained from the application context.
98
+ * Tests override with a stub/mock so the surface is non-null after
99
+ * pushProps(), allowing hasPushedInitialProps to flip as expected.
100
+ * Returns null when surface creation fails (e.g. reactHost unavailable).
101
+ */
102
+ internal var createSurface: (moduleName: String, initialProps: Bundle?) -> ReactSurface? =
103
+ { moduleName, initialProps ->
104
+ (context.applicationContext as? ReactApplication)?.reactHost
105
+ ?.createSurface(context, moduleName, initialProps)
106
+ }
107
+
62
108
  private var cachedItemJSON: String? = null
63
109
  private var isActive: Boolean = false
64
110
  private var activeImageIndex: Int = 0
@@ -133,16 +179,12 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
133
179
  isActive = false
134
180
  activeImageIndex = 0
135
181
 
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
182
+ val isSameItem = item.id == currentItemId
183
+ currentItemId = item.id
139
184
 
140
- // Cancel any in-flight write job from a previous configure() call.
185
+ val gen = ++configureGeneration
141
186
  pendingWriteJob?.cancel()
142
187
 
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
188
  val lookup = cachedImage
147
189
  val fastItem = if (lookup != null) {
148
190
  val fastImages = item.images.map { image ->
@@ -166,9 +208,23 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
166
208
 
167
209
  val json = Json.encodeToString(fastItem)
168
210
  cachedItemJSON = json
169
- pushProps()
170
211
 
171
- // Pre-size the surface view NOW — before the overlay is attached to a cell.
212
+ if (!hasPushedInitialProps) {
213
+ pushProps()
214
+ // Only flip the flag if the surface was actually created. If reactHost
215
+ // was null (createSurfaceIfNeeded silently returned), surface remains
216
+ // null and pendingProps is parked — stay in "first mount" mode so the
217
+ // next configure() retries pushProps() with the new item. Mirrors iOS
218
+ // ReactCarouselOverlayHost.swift:131-141.
219
+ if (surface != null) {
220
+ hasPushedInitialProps = true
221
+ }
222
+ } else if (!isSameItem) {
223
+ emitItemChanged(json)
224
+ }
225
+ // else: same item rebind — no emit, no Fabric prop push.
226
+
227
+ // Pre-size the surface view (existing logic, unchanged).
172
228
  val parentView = parent as? android.view.View
173
229
  val w = if (width > 0) width
174
230
  else if (parentView != null && parentView.width > 0) parentView.width
@@ -178,54 +234,35 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
178
234
  else context.resources.displayMetrics.heightPixels
179
235
  measureAndLayoutSurfaceView(w, h)
180
236
 
181
- // Background: write any newly-cached images to temp files.
182
- // Only runs if there are remote URLs remaining (not all were fast-pathed).
237
+ // Background: write any newly-cached images to temp files for next-time
238
+ // fast-path availability. iOS-aligned: write-only, no second pushProps()
239
+ // (next configure() picks up the local URL via the synchronous fast-path).
183
240
  if (lookup == null) return
184
241
  val hasRemoteUrls = fastItem.images.any { !it.url.startsWith("file://") }
185
242
  if (!hasRemoteUrls) return
186
243
 
187
244
  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
- }
245
+ withContext(Dispatchers.IO) {
246
+ item.images.forEach { image ->
247
+ if (gen != configureGeneration) return@forEach
248
+ lookup(image.url)?.let { writeTempImage(it, image.url) }
198
249
  }
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
- }
210
-
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
250
  }
219
251
  }
220
252
  }
221
253
 
222
254
  override fun activatePlayback() {
223
255
  isActive = true
256
+ // Source mute state from the SDK rather than hardcoding true so
257
+ // overlay icons reflect actual mute state even on image carousels
258
+ // (which have no audio of their own but should still match host
259
+ // expectations).
260
+ val isMutedNow = ShortKitBridge.shared?.sdk?.player?.isMuted?.value ?: true
224
261
  val params = com.facebook.react.bridge.Arguments.createMap().apply {
225
262
  putString("surfaceId", surfaceId)
226
263
  putBoolean("isActive", true)
227
264
  putString("playerState", "idle")
228
- putBoolean("isMuted", true)
265
+ putBoolean("isMuted", isMutedNow)
229
266
  putDouble("playbackRate", 1.0)
230
267
  putBoolean("captionsEnabled", false)
231
268
  putNull("activeCue")
@@ -252,6 +289,21 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
252
289
  applySurfaceProps(bundle)
253
290
  }
254
291
 
292
+ /**
293
+ * Emit onCarouselItemChanged for a React diff update on cell reuse.
294
+ * Replaces pushProps() on subsequent item changes to avoid Fabric remount.
295
+ * Payload shape mirrors iOS ReactCarouselOverlayHost.swift:151-158.
296
+ */
297
+ private fun emitItemChanged(json: String) {
298
+ val params = createMap().apply {
299
+ putString("surfaceId", surfaceId)
300
+ putString("item", json)
301
+ putBoolean("isActive", false)
302
+ putInt("activeImageIndex", 0)
303
+ }
304
+ emitEvent("onCarouselItemChanged", params)
305
+ }
306
+
255
307
  /** Apply props to the surface, handling all lifecycle states. */
256
308
  private fun applySurfaceProps(bundle: Bundle) {
257
309
  val s = surface
@@ -369,11 +421,6 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
369
421
  private fun createSurfaceIfNeeded() {
370
422
  if (surface != null) return
371
423
 
372
- val reactHost = (context.applicationContext as? ReactApplication)?.reactHost
373
- if (reactHost == null) {
374
- android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
375
- return
376
- }
377
424
  val moduleName = "ShortKitCarouselOverlay_$carouselOverlayName"
378
425
 
379
426
  // Pass pending props as initial props so the JS component has data on first render.
@@ -382,7 +429,13 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
382
429
  val initialProps = pendingProps
383
430
  pendingProps = null
384
431
 
385
- val newSurface = reactHost.createSurface(context, moduleName, initialProps)
432
+ val newSurface = createSurface(moduleName, initialProps)
433
+ if (newSurface == null) {
434
+ android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
435
+ // Restore pending props so a future createSurfaceIfNeeded() can retry.
436
+ if (initialProps != null) pendingProps = initialProps
437
+ return
438
+ }
386
439
  surface = newSurface
387
440
 
388
441
  newSurface.view?.let { surfaceView ->
@@ -99,6 +99,21 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
99
99
  private var cachedActiveCue: JSONObject? = null
100
100
  private var cachedFeedScrollPhase: String? = null
101
101
 
102
+ /**
103
+ * Tracks active drag phase. While true, per-frame player-state emissions
104
+ * (playerState, isMuted, playbackRate, captionsEnabled, activeCue, time)
105
+ * are suppressed; emitFullState() re-syncs cached values on settle.
106
+ *
107
+ * Mirrors iOS ReactOverlayHost.swift:51 isDragging field.
108
+ */
109
+ internal var isDragging: Boolean = false
110
+ private set
111
+
112
+ /** Test-only setter for [isDragging]. */
113
+ internal var isDraggingForTest: Boolean
114
+ get() = isDragging
115
+ set(value) { isDragging = value }
116
+
102
117
  // Time coalescing (250ms)
103
118
  private var cachedCurrentTime: Double = 0.0
104
119
  private var cachedDuration: Double = 0.0
@@ -172,15 +187,29 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
172
187
  cachedDuration = 0.0
173
188
  cachedBuffered = 0.0
174
189
  cachedPlayerState = "idle"
175
- cachedIsMuted = true
190
+ // Read the SDK's actual current mute state instead of hardcoding
191
+ // `true`. The flow subscription below will keep this in sync going
192
+ // forward, but during the brief window between configure() and the
193
+ // first emission the overlay's initialProps would otherwise carry a
194
+ // stale `true`, causing the next-cell mute toggle to compute the
195
+ // wrong target value (the user's tap on the new cell would resolve
196
+ // to setMuted(false) regardless of actual state).
197
+ player?.isMuted?.value?.let { cachedIsMuted = it }
176
198
  cachedPlaybackRate = 1.0
177
199
  cachedCaptionsEnabled = false
178
200
  cachedActiveCue = null
179
201
  cachedFeedScrollPhase = null
202
+ isDragging = false
180
203
 
181
204
  if (surface == null) {
182
205
  createSurfaceIfNeeded()
183
- surfaceHasStarted = true
206
+ // Only flip the flag if surface was actually created. If reactHost was
207
+ // null (createSurfaceIfNeeded silently returned), surface remains null
208
+ // and the next configure() will retry. Mirrors iOS pattern and matches
209
+ // the carousel host fix from 6e5e1dce.
210
+ if (surface != null) {
211
+ surfaceHasStarted = true
212
+ }
184
213
  } else if (!surfaceHasStarted) {
185
214
  (surface as? ReactSurfaceImpl)?.updateInitProps(buildInitialPropsBundle())
186
215
  surfaceHasStarted = true
@@ -403,7 +432,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
403
432
  scope.launch {
404
433
  player.playerState.collect { state ->
405
434
  cachedPlayerState = ShortKitBridge.playerStateString(state)
406
- if (isActive) {
435
+ if (isActive && !isDragging) {
407
436
  val params = Arguments.createMap().apply {
408
437
  putString("surfaceId", surfaceId)
409
438
  putString("playerState", cachedPlayerState)
@@ -417,7 +446,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
417
446
  scope.launch {
418
447
  player.isMuted.collect { muted ->
419
448
  cachedIsMuted = muted
420
- if (isActive) {
449
+ if (isActive && !isDragging) {
421
450
  val params = Arguments.createMap().apply {
422
451
  putString("surfaceId", surfaceId)
423
452
  putBoolean("isMuted", cachedIsMuted)
@@ -431,7 +460,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
431
460
  scope.launch {
432
461
  player.playbackRate.collect { rate ->
433
462
  cachedPlaybackRate = rate.toDouble()
434
- if (isActive) {
463
+ if (isActive && !isDragging) {
435
464
  val params = Arguments.createMap().apply {
436
465
  putString("surfaceId", surfaceId)
437
466
  putDouble("playbackRate", cachedPlaybackRate)
@@ -445,7 +474,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
445
474
  scope.launch {
446
475
  player.captionsEnabled.collect { enabled ->
447
476
  cachedCaptionsEnabled = enabled
448
- if (isActive) {
477
+ if (isActive && !isDragging) {
449
478
  val params = Arguments.createMap().apply {
450
479
  putString("surfaceId", surfaceId)
451
480
  putBoolean("captionsEnabled", cachedCaptionsEnabled)
@@ -467,7 +496,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
467
496
  } else {
468
497
  null
469
498
  }
470
- if (isActive) {
499
+ if (isActive && !isDragging) {
471
500
  val params = Arguments.createMap().apply {
472
501
  putString("surfaceId", surfaceId)
473
502
  val cueJson = cachedActiveCue?.toString()
@@ -488,7 +517,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
488
517
  cachedCurrentTime = time.currentMs / 1000.0
489
518
  cachedDuration = time.durationMs / 1000.0
490
519
  cachedBuffered = time.bufferedMs / 1000.0
491
- if (isActive) {
520
+ if (isActive && !isDragging) {
492
521
  timeDirty = true
493
522
  }
494
523
  }
@@ -504,6 +533,23 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
504
533
  }.toString()
505
534
  is FeedScrollPhase.Settled -> """{"phase":"settled"}"""
506
535
  }
536
+ when (phase) {
537
+ is FeedScrollPhase.Dragging -> {
538
+ isDragging = true
539
+ stopTimeCoalescing()
540
+ }
541
+ is FeedScrollPhase.Settled -> {
542
+ val wasDragging = isDragging
543
+ isDragging = false
544
+ if (isActive) {
545
+ startTimeCoalescing()
546
+ if (wasDragging) {
547
+ emitFullState()
548
+ return@collect // emitFullState includes feedScrollPhase
549
+ }
550
+ }
551
+ }
552
+ }
507
553
  if (isActive) {
508
554
  val params = Arguments.createMap().apply {
509
555
  putString("surfaceId", surfaceId)