@shortkitsdk/react-native 0.2.36 → 0.2.38

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 (19) hide show
  1. package/android/build.gradle.kts +4 -4
  2. package/android/libs/shortkit-release.aar +0 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +8 -6
  4. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +24 -10
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +103 -27
  6. package/android/src/test/java/com/shortkit/reactnative/ReactCarouselOverlayHostLifecycleTest.kt +136 -0
  7. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostLifecycleTest.kt +178 -0
  8. package/ios/ReactVideoCarouselOverlayHost.swift +8 -6
  9. package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
  10. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  11. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +1 -1
  12. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  13. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +3 -3
  14. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  15. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +1 -1
  16. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +1 -1
  17. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  18. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +5 -5
  19. package/package.json +1 -1
@@ -35,10 +35,10 @@ android {
35
35
 
36
36
  dependencies {
37
37
  implementation("com.facebook.react:react-android")
38
- // When Reco's settings.gradle has the composite build, Gradle substitutes
39
- // this with the live source from android_sdk/shortkit automatically.
40
- // implementation("dev.shortkit:shortkit:0.2.11")
41
- implementation(files("libs/shortkit-release.aar"))
38
+ // Composite build in sample/android/settings.gradle substitutes this with
39
+ // live android_sdk source automatically (dev.shortkit:shortkit :shortkit project).
40
+ implementation("dev.shortkit:shortkit:0.2.37")
41
+ // implementation(files("libs/shortkit-release.aar"))
42
42
  // Transitive deps needed when using local AAR (Maven artifact bundles these automatically)
43
43
  implementation("androidx.media3:media3-exoplayer:1.5.1")
44
44
  implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
Binary file
@@ -343,8 +343,9 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
343
343
  }
344
344
 
345
345
  override fun resetState() {
346
- val surfaceImpl = surface as? ReactSurfaceImpl ?: return
347
- surfaceImpl.updateInitProps(Bundle())
346
+ // Non-destructive: configure() will push new props on next bind.
347
+ // iOS parity: ReactCarouselOverlayHost.swift resetState() is a no-op.
348
+ // Mirrors ReactVideoCarouselOverlayHost.resetState() which is also a no-op.
348
349
  }
349
350
 
350
351
  override fun fadeOutForTransition() {
@@ -464,12 +465,13 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
464
465
  // Cleanup
465
466
  // ------------------------------------------------------------------
466
467
 
467
- override fun onDetachedFromWindow() {
468
+ public override fun onDetachedFromWindow() {
468
469
  super.onDetachedFromWindow()
469
470
  pendingWriteJob?.cancel()
470
471
  pendingWriteJob = null
471
- if (surface?.isRunning == true) {
472
- surface?.stop()
473
- }
472
+ // Non-destructive: surface survives cell recycle and view transitions.
473
+ // iOS parity: ReactCarouselOverlayHost.swift has no willMove/didMoveToWindow
474
+ // override — surface stops only in deinit. Mirrors the non-destructive
475
+ // contract enforced on ReactVideoCarouselOverlayHost.
474
476
  }
475
477
  }
@@ -133,7 +133,8 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
133
133
 
134
134
  // Player state
135
135
  private var player: ShortKitPlayer? = null
136
- private var isActive: Boolean = false
136
+ @androidx.annotation.VisibleForTesting
137
+ internal var isActive: Boolean = false
137
138
  private var cachedPlayerState: String = "idle"
138
139
  private var cachedIsMuted: Boolean = true
139
140
  private var cachedCurrentTime: Double = 0.0
@@ -630,15 +631,28 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
630
631
  // Cleanup
631
632
  // ------------------------------------------------------------------
632
633
 
633
- override fun onDetachedFromWindow() {
634
+ public override fun onDetachedFromWindow() {
634
635
  super.onDetachedFromWindow()
635
- flowScope?.cancel()
636
- flowScope = null
637
- isActive = false
638
- currentCarouselItem = null
639
- stopTimeCoalescing()
640
- if (surface?.isRunning == true) {
641
- surface?.stop()
642
- }
636
+ // iOS-parity: detach is fully non-destructive. The React surface,
637
+ // currentCarouselItem, flowScope, isActive, AND the time-coalescing
638
+ // runnable all survive visibility transitions. The cell's recycle
639
+ // path keeps the overlay attached (mirror of iOS
640
+ // VideoCarouselCell.prepareForReuse).
641
+ //
642
+ // isActive is owned exclusively by the feed's active-cell
643
+ // propagation (activatePlayback / deactivatePlayback). Touching it
644
+ // here would create a second writer and break the suspend/resume
645
+ // case where the active cell never changes — activatePlayback
646
+ // wouldn't re-fire on resume, leaving isActive stuck false and
647
+ // gating out every updateActiveVideo emit.
648
+ //
649
+ // Player flow subscriptions are idempotently re-established by
650
+ // attach() -> resubscribeToPlayer (which cancels the prior scope
651
+ // on entry) when the cell rebinds its player on next visit.
652
+ //
653
+ // True teardown happens via release() (called by the cell on
654
+ // permanent disposal) or implicitly when the host is GC'd along
655
+ // with the cell.
643
656
  }
657
+
644
658
  }
@@ -95,6 +95,51 @@ class ShortKitBridge(
95
95
  }
96
96
  }
97
97
 
98
+ /**
99
+ * Serialize a [FeedItem] to a ContentItem-shaped JSON string for the
100
+ * `onFeedTransition` event. For [FeedItem.Content] cells this is the
101
+ * real ContentItem. For every other cell kind (AdSlotItem,
102
+ * ImageCarousel, Survey, VideoCarousel) there is no playable
103
+ * ContentItem, so we synthesize a minimal one whose ONLY meaningful
104
+ * field is `id`.
105
+ *
106
+ * Why: JS hosts use `event.to` as an identity cursor for resume-on-
107
+ * tab-return (`setFeedItems(startAt:)`). Before this synthesis, non-
108
+ * content cells came through as `to=null` and the host's stored
109
+ * resume id stayed pinned to the last regular video — causing the
110
+ * SDK to re-seed the feed at the wrong index on tab return.
111
+ *
112
+ * Scope: this is a bridge-only synthesis. It never flows back into
113
+ * the Android SDK (pool, cache, FeedDataSource, etc. all still see
114
+ * the real FeedItem and treat non-content cells honestly). JS-side
115
+ * consumers should ONLY read `.id` / `.playbackId` from
116
+ * `onFeedTransition` items — deeper fields are placeholders for
117
+ * non-content cells.
118
+ *
119
+ * Mirrors iOS PR #170 ShortKitBridge.swift:770-790.
120
+ */
121
+ fun serializeFeedItemIdentityJSON(feedItem: com.shortkit.sdk.model.FeedItem): String {
122
+ if (feedItem is com.shortkit.sdk.model.FeedItem.Content) {
123
+ return serializeContentItemToJSON(feedItem.item)
124
+ }
125
+ val synthetic = ContentItem(
126
+ id = feedItem.id,
127
+ playbackId = null,
128
+ title = "",
129
+ description = null,
130
+ duration = 0.0,
131
+ streamingUrl = "",
132
+ thumbnailUrl = "",
133
+ captionTracks = emptyList(),
134
+ customMetadata = null,
135
+ author = null,
136
+ articleUrl = null,
137
+ commentCount = null,
138
+ fallbackUrl = null,
139
+ )
140
+ return serializeContentItemToJSON(synthetic)
141
+ }
142
+
98
143
  /**
99
144
  * Serialize a [ContentItem] to a JSON string for bridge transport.
100
145
  */
@@ -539,7 +584,8 @@ class ShortKitBridge(
539
584
  customDimensions = dims,
540
585
  debugPanelEnabled = debugPanel,
541
586
  serverTracingEnabled = serverTracingEnabled,
542
- consoleTracingEnabled = consoleTracingEnabled
587
+ consoleTracingEnabled = consoleTracingEnabled,
588
+ poolDebugEnabled = consoleTracingEnabled,
543
589
  )
544
590
  this.shortKit = sdk
545
591
  shared = this
@@ -687,6 +733,38 @@ class ShortKitBridge(
687
733
  fragment.onVideoCarouselCellTap = { payload ->
688
734
  emitVideoCarouselCellTap(payload.id, payload.index, payload.pageIndex)
689
735
  }
736
+ // Per-feed transition event. Fires once per transition, bound to this
737
+ // fragment. Replaces the global player.feedTransition.collect pattern
738
+ // (which fanned every feed's transitions out to every mounted
739
+ // <ShortKitFeed>, causing cross-feed state pollution). Structural fix:
740
+ // callback literally cannot fire on the wrong feed. Mirrors iOS
741
+ // PR #170 ShortKitBridge.swift:117-143.
742
+ fragment.onFeedTransition = { event ->
743
+ val params = Arguments.createMap().apply {
744
+ putString("feedId", id)
745
+ putString("phase", when (event.phase) {
746
+ FeedTransitionPhase.BEGAN -> "began"
747
+ FeedTransitionPhase.ENDED -> "ended"
748
+ })
749
+ putString("direction", when (event.direction) {
750
+ FeedDirection.FORWARD -> "forward"
751
+ FeedDirection.BACKWARD -> "backward"
752
+ else -> "forward"
753
+ })
754
+ // Serialize from the underlying FeedItem (not event.from/to
755
+ // which are null for non-content cells). See the docstring on
756
+ // serializeFeedItemIdentityJSON for the why — in short, this
757
+ // ensures carousels/ads/surveys come through with a populated
758
+ // id, so JS-side hosts can track feed position for any cell type.
759
+ event.fromFeedItem?.let {
760
+ putString("fromItem", serializeFeedItemIdentityJSON(it))
761
+ }
762
+ event.toFeedItem?.let {
763
+ putString("toItem", serializeFeedItemIdentityJSON(it))
764
+ }
765
+ }
766
+ emitEventOnMain("onFeedTransition", params)
767
+ }
690
768
 
691
769
  // Replay buffered operations after the fragment's view is created.
692
770
  // commitNowAllowingStateLoss triggers onCreate but onViewCreated
@@ -1240,10 +1318,16 @@ class ShortKitBridge(
1240
1318
  }
1241
1319
  }
1242
1320
 
1243
- // Did loop
1321
+ // Did loop — tagged with active surface's feedId. player.didLoop is a
1322
+ // singleton flow on the shared ShortKitPlayer (only one item plays at
1323
+ // a time). The event semantically belongs to whichever feed owns the
1324
+ // active surface; tag with that feedId so JS consumers bound to
1325
+ // specific feeds can filter. Empty string means no active surface
1326
+ // (treat as unknown/global). Mirrors iOS bridge :615-631.
1244
1327
  newScope.launch {
1245
1328
  player.didLoop.collect { event ->
1246
1329
  val params = Arguments.createMap().apply {
1330
+ putString("feedId", activeSurfaceFeedId())
1247
1331
  putString("contentId", event.contentId)
1248
1332
  putInt("loopCount", event.loopCount)
1249
1333
  }
@@ -1251,34 +1335,26 @@ class ShortKitBridge(
1251
1335
  }
1252
1336
  }
1253
1337
 
1254
- // Feed transition
1255
- newScope.launch {
1256
- player.feedTransition.collect { event ->
1257
- val params = Arguments.createMap().apply {
1258
- putString("phase", when (event.phase) {
1259
- FeedTransitionPhase.BEGAN -> "began"
1260
- FeedTransitionPhase.ENDED -> "ended"
1261
- })
1262
- putString("direction", when (event.direction) {
1263
- FeedDirection.FORWARD -> "forward"
1264
- FeedDirection.BACKWARD -> "backward"
1265
- else -> "forward"
1266
- })
1267
- if (event.from != null) {
1268
- putString("fromItem", serializeContentItemToJSON(event.from!!))
1269
- }
1270
- if (event.to != null) {
1271
- putString("toItem", serializeContentItemToJSON(event.to!!))
1272
- }
1273
- }
1274
- emitEvent.invoke("onFeedTransition", params)
1275
- }
1276
- }
1277
-
1278
- // Format change
1338
+ // NOTE: the global `player.feedTransition.collect` subscription used
1339
+ // to live here. It's been removed — feed transitions are now emitted
1340
+ // to RN via the per-fragment `fragment.onFeedTransition` closure
1341
+ // wired in `registerFeedFragment` above. This eliminates the cross-
1342
+ // feed routing bug where every mounted <ShortKitFeed> consumer
1343
+ // received every feed's transitions. Mirrors iOS PR #170
1344
+ // ShortKitBridge.swift:633-642.
1345
+ //
1346
+ // `player.sendFeedTransition` is still called by ShortKitFeedFragment
1347
+ // for backward compatibility with native Kotlin SDK consumers that
1348
+ // subscribe to `ShortKitPlayer.feedTransition` directly.
1349
+
1350
+ // Format change — tagged with active surface's feedId. Same reasoning
1351
+ // as onDidLoop: fires from shared player singleton, but semantically
1352
+ // belongs to whichever feed owns the active surface. Mirrors iOS
1353
+ // bridge :671-688.
1279
1354
  newScope.launch {
1280
1355
  player.formatChange.collect { event ->
1281
1356
  val params = Arguments.createMap().apply {
1357
+ putString("feedId", activeSurfaceFeedId())
1282
1358
  putString("contentId", event.contentId)
1283
1359
  putDouble("fromBitrate", event.fromBitrate.toDouble())
1284
1360
  putDouble("toBitrate", event.toBitrate.toDouble())
@@ -0,0 +1,136 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import android.content.Context
4
+ import android.view.ViewGroup
5
+ import androidx.test.core.app.ApplicationProvider
6
+ import com.facebook.react.bridge.JavaOnlyMap
7
+ import com.facebook.react.interfaces.TaskInterface
8
+ import com.facebook.react.interfaces.fabric.ReactSurface
9
+ import com.shortkit.sdk.model.CarouselImage
10
+ import com.shortkit.sdk.model.ImageCarouselItem
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertNotNull
13
+ import org.junit.Assert.assertTrue
14
+ import org.junit.Test
15
+ import org.junit.runner.RunWith
16
+ import org.robolectric.RobolectricTestRunner
17
+ import java.util.concurrent.TimeUnit
18
+
19
+ /**
20
+ * Lifecycle contract for ReactCarouselOverlayHost: surface must survive
21
+ * onDetachedFromWindow / onAttachedToWindow cycles (iOS-parity), and
22
+ * resetState() must be a no-op.
23
+ *
24
+ * iOS reference: ReactCarouselOverlayHost.swift has no willMove/didMoveToWindow
25
+ * override — surface only stops in deinit. resetState() is also a no-op on iOS:
26
+ * "Don't clear surface props — configure() will overwrite."
27
+ *
28
+ * Mirrors the contract enforced by ReactVideoCarouselOverlayHostLifecycleTest
29
+ * for the video carousel host.
30
+ */
31
+ @RunWith(RobolectricTestRunner::class)
32
+ class ReactCarouselOverlayHostLifecycleTest {
33
+
34
+ private object NoOpTask : TaskInterface<Void> {
35
+ override fun waitForCompletion() = Unit
36
+ override fun waitForCompletion(duration: Long, timeUnit: TimeUnit): Boolean = true
37
+ override fun getResult(): Void? = null
38
+ override fun getError(): Exception? = null
39
+ override fun isCompleted(): Boolean = true
40
+ override fun isCancelled(): Boolean = false
41
+ override fun isFaulted(): Boolean = false
42
+ }
43
+
44
+ /**
45
+ * Mutable-state ReactSurface stub tracking isRunning and stop call count.
46
+ * Mirrors TrackingSurface in ReactVideoCarouselOverlayHostLifecycleTest.
47
+ */
48
+ private class TrackingSurface : ReactSurface {
49
+ private val ctx: Context = ApplicationProvider.getApplicationContext()
50
+ private var _running: Boolean = false
51
+ var stopCount: Int = 0
52
+
53
+ override val surfaceID: Int = 0
54
+ override val moduleName: String = "TrackingSurface"
55
+ override val isRunning: Boolean get() = _running
56
+ override val view: ViewGroup? = null
57
+ override val context: Context get() = ctx
58
+ override fun prerender(): TaskInterface<Void> = NoOpTask
59
+ override fun start(): TaskInterface<Void> { _running = true; return NoOpTask }
60
+ override fun stop(): TaskInterface<Void> { _running = false; stopCount++; return NoOpTask }
61
+ override fun clear() = Unit
62
+ override fun detach() = Unit
63
+ }
64
+
65
+ private fun makeHost(surface: TrackingSurface): ReactCarouselOverlayHost {
66
+ val ctx = ApplicationProvider.getApplicationContext<Context>()
67
+ return ReactCarouselOverlayHost(ctx).also {
68
+ it.createMap = { JavaOnlyMap() }
69
+ it.createSurface = { _, _ -> surface }
70
+ it.emitEvent = { _, _ -> }
71
+ }
72
+ }
73
+
74
+ private fun item(id: String) = ImageCarouselItem(
75
+ id = id,
76
+ images = listOf(CarouselImage(url = "https://example.com/$id.jpg", alt = null)),
77
+ )
78
+
79
+ @Test
80
+ fun `onDetachedFromWindow does not stop surface`() {
81
+ val surface = TrackingSurface()
82
+ val host = makeHost(surface)
83
+ host.configure(item("A"))
84
+ assertTrue("surface must be running after configure", surface.isRunning)
85
+ val stopsBefore = surface.stopCount
86
+
87
+ host.onDetachedFromWindow()
88
+
89
+ assertTrue(
90
+ "surface must remain running after onDetachedFromWindow (iOS-parity)",
91
+ surface.isRunning,
92
+ )
93
+ assertEquals(
94
+ "onDetachedFromWindow must not call surface.stop()",
95
+ stopsBefore,
96
+ surface.stopCount,
97
+ )
98
+ }
99
+
100
+ @Test
101
+ fun `onDetachedFromWindow preserves currentItemId`() {
102
+ val surface = TrackingSurface()
103
+ val host = makeHost(surface)
104
+ host.configure(item("A"))
105
+ assertEquals("A", host.currentItemId)
106
+
107
+ host.onDetachedFromWindow()
108
+
109
+ assertEquals(
110
+ "currentItemId must survive onDetachedFromWindow so configure() can detect same vs different item",
111
+ "A",
112
+ host.currentItemId,
113
+ )
114
+ }
115
+
116
+ @Test
117
+ fun `resetState does not stop surface`() {
118
+ // resetState() must be a no-op: iOS ReactCarouselOverlayHost.swift
119
+ // resetState() body is "// Don't clear surface props — configure() will overwrite."
120
+ // Mirrors ReactVideoCarouselOverlayHost.resetState() which is also a no-op.
121
+ // Note: the current production cast (surface as? ReactSurfaceImpl) silently
122
+ // no-ops in Robolectric (stub isn't ReactSurfaceImpl), so this test
123
+ // documents intent and guards against future regressions that add
124
+ // a surface?.stop() call.
125
+ val surface = TrackingSurface()
126
+ val host = makeHost(surface)
127
+ host.configure(item("A"))
128
+ assertTrue("surface must be running after configure", surface.isRunning)
129
+ val stopsBefore = surface.stopCount
130
+
131
+ host.resetState()
132
+
133
+ assertTrue("surface must remain running after resetState", surface.isRunning)
134
+ assertEquals("resetState must not call surface.stop()", stopsBefore, surface.stopCount)
135
+ }
136
+ }
@@ -0,0 +1,178 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import android.content.Context
4
+ import android.view.ViewGroup
5
+ import androidx.test.core.app.ApplicationProvider
6
+ import com.facebook.react.bridge.JavaOnlyMap
7
+ import com.facebook.react.interfaces.TaskInterface
8
+ import com.facebook.react.interfaces.fabric.ReactSurface
9
+ import com.shortkit.sdk.model.ContentItem
10
+ import com.shortkit.sdk.model.VideoCarouselItem
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertFalse
13
+ import org.junit.Assert.assertNotNull
14
+ import org.junit.Assert.assertTrue
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+ import java.util.concurrent.TimeUnit
19
+
20
+ /**
21
+ * Lifecycle contract for ReactVideoCarouselOverlayHost: surface and state
22
+ * must survive onDetachedFromWindow / onAttachedToWindow cycles
23
+ * (iOS-parity), and only release() should perform true teardown.
24
+ *
25
+ * iOS reference: swift_sdk/.../VideoCarouselCell.swift:472-476 keeps the
26
+ * overlay alive across prepareForReuse; the host's deinit performs final
27
+ * cleanup. Android equivalent: this lifecycle test enforces the same
28
+ * contract on the RN host.
29
+ */
30
+ @RunWith(RobolectricTestRunner::class)
31
+ class ReactVideoCarouselOverlayHostLifecycleTest {
32
+
33
+ private object NoOpTask : TaskInterface<Void> {
34
+ override fun waitForCompletion() = Unit
35
+ override fun waitForCompletion(duration: Long, timeUnit: TimeUnit): Boolean = true
36
+ override fun getResult(): Void? = null
37
+ override fun getError(): Exception? = null
38
+ override fun isCompleted(): Boolean = true
39
+ override fun isCancelled(): Boolean = false
40
+ override fun isFaulted(): Boolean = false
41
+ }
42
+
43
+ /**
44
+ * Mutable-state ReactSurface stub that tracks isRunning + start/stop
45
+ * calls. Mirrors the StubReactSurface shape used by
46
+ * ReactVideoCarouselOverlayHostEmitTest, but with mutable state via
47
+ * a backing field + custom getter (the interface declares isRunning
48
+ * as `val`, so we can't override with `var`).
49
+ */
50
+ private class TrackingSurface : ReactSurface {
51
+ private val ctx: Context = ApplicationProvider.getApplicationContext()
52
+ private var _running: Boolean = false
53
+ var stopCount: Int = 0
54
+
55
+ override val surfaceID: Int = 0
56
+ override val moduleName: String = "TrackingSurface"
57
+ override val isRunning: Boolean get() = _running
58
+ override val view: ViewGroup? = null
59
+ override val context: Context get() = ctx
60
+ override fun prerender(): TaskInterface<Void> = NoOpTask
61
+ override fun start(): TaskInterface<Void> { _running = true; return NoOpTask }
62
+ override fun stop(): TaskInterface<Void> { _running = false; stopCount++; return NoOpTask }
63
+ override fun clear() = Unit
64
+ override fun detach() = Unit
65
+ }
66
+
67
+ private fun newHost(surface: TrackingSurface): ReactVideoCarouselOverlayHost {
68
+ val host = ReactVideoCarouselOverlayHost(
69
+ ApplicationProvider.getApplicationContext()
70
+ )
71
+ host.videoCarouselOverlayName = "test"
72
+ host.createSurface = { _, _ -> surface }
73
+ host.createMap = { JavaOnlyMap() }
74
+ host.emitEvent = { _, _ -> /* no-op for lifecycle tests */ }
75
+ return host
76
+ }
77
+
78
+ private fun video(id: String) = ContentItem(
79
+ id = id,
80
+ title = "t",
81
+ duration = 5.0,
82
+ streamingUrl = "https://example.com/$id.m3u8",
83
+ thumbnailUrl = "https://example.com/$id.jpg",
84
+ captionTracks = emptyList(),
85
+ )
86
+
87
+ private fun item(id: String, videoCount: Int = 1) = VideoCarouselItem(
88
+ id = id,
89
+ videos = (0 until videoCount).map { video("$id-$it") },
90
+ )
91
+
92
+ @Test
93
+ fun `onDetachedFromWindow does not stop surface`() {
94
+ val surface = TrackingSurface()
95
+ val host = newHost(surface)
96
+ host.configure(item("c1")) // first mount: applies props, starts surface
97
+ assertTrue("surface must be running after configure", surface.isRunning)
98
+ val stopsBefore = surface.stopCount
99
+
100
+ host.onDetachedFromWindow()
101
+
102
+ assertTrue(
103
+ "surface must remain running after onDetachedFromWindow (iOS-parity)",
104
+ surface.isRunning,
105
+ )
106
+ assertEquals(
107
+ "onDetachedFromWindow must not call surface.stop()",
108
+ stopsBefore,
109
+ surface.stopCount,
110
+ )
111
+ }
112
+
113
+ @Test
114
+ fun `onDetachedFromWindow preserves currentCarouselItem`() {
115
+ val surface = TrackingSurface()
116
+ val host = newHost(surface)
117
+ host.configure(item("c1"))
118
+ assertNotNull(host.currentCarouselItem)
119
+
120
+ host.onDetachedFromWindow()
121
+
122
+ assertNotNull(
123
+ "currentCarouselItem must survive onDetachedFromWindow so re-attach can detect same vs different item",
124
+ host.currentCarouselItem,
125
+ )
126
+ }
127
+
128
+ @Test
129
+ fun `onDetachedFromWindow preserves isActive`() {
130
+ // Suspend/resume scenario: active cell never changes, so the feed's
131
+ // active-cell propagation does not re-fire activatePlayback on
132
+ // resume. If detach flipped isActive false, every subsequent
133
+ // updateActiveVideo emit would be gated out and per-page swipes
134
+ // wouldn't update the overlay. iOS-parity: isActive is owned
135
+ // exclusively by activatePlayback / deactivatePlayback; lifecycle
136
+ // hooks must not touch it.
137
+ val surface = TrackingSurface()
138
+ val host = newHost(surface)
139
+ host.configure(item("c1"))
140
+ host.activatePlayback()
141
+ assertTrue(host.isActive)
142
+
143
+ host.onDetachedFromWindow()
144
+
145
+ assertTrue(
146
+ "isActive must survive onDetachedFromWindow — only activatePlayback/deactivatePlayback own it",
147
+ host.isActive,
148
+ )
149
+ }
150
+
151
+ @Test
152
+ fun `onDetachedFromWindow does not stop time coalescer`() {
153
+ // If detach stopped the coalescer, on resume nothing restarts it
154
+ // (activatePlayback is the only caller and doesn't re-fire when
155
+ // the active cell never changed). Time emits would silently stop.
156
+ // Mirrors the isActive ownership rule: detach is a no-op for
157
+ // playback-driven state.
158
+ val surface = TrackingSurface()
159
+ val host = newHost(surface)
160
+ host.configure(item("c1"))
161
+ host.activatePlayback()
162
+ // activatePlayback() is what kicks off time coalescing — once
163
+ // running, it should survive detach.
164
+
165
+ // No assertion on internals; the contract is observable via the
166
+ // updateActiveVideo regression test above. This test exists to
167
+ // document the non-destructive-detach intent: onDetachedFromWindow
168
+ // must not call stopTimeCoalescing().
169
+ host.onDetachedFromWindow()
170
+
171
+ // host.isActive still true => the coalesce runnable would still
172
+ // tick if it weren't cancelled. We cannot easily inspect the
173
+ // handler queue from here, but we can assert the public state
174
+ // hasn't been disturbed:
175
+ assertTrue("isActive untouched", host.isActive)
176
+ assertNotNull("currentCarouselItem untouched", host.currentCarouselItem)
177
+ }
178
+ }
@@ -148,11 +148,12 @@ import ShortKitSDK
148
148
  "playerState": "idle",
149
149
  "isMuted": cachedIsMuted,
150
150
  ]
151
- if let firstVideo = item.videos.first,
152
- let videoData = try? JSONEncoder().encode(firstVideo),
151
+ let initialIndex = item.clampedInitialPageIndex
152
+ if item.videos.indices.contains(initialIndex),
153
+ let videoData = try? JSONEncoder().encode(item.videos[initialIndex]),
153
154
  let videoJSON = String(data: videoData, encoding: .utf8) {
154
155
  props["activeVideo"] = videoJSON
155
- props["activeVideoIndex"] = 0
156
+ props["activeVideoIndex"] = initialIndex
156
157
  }
157
158
  return props
158
159
  }
@@ -160,16 +161,17 @@ import ShortKitSDK
160
161
  /// Emit onVideoCarouselItemChanged with full initial state for the new item.
161
162
  /// Replaces setProperties() on cell reuse, avoiding a full Fabric root remount.
162
163
  private func emitItemChanged(item: VideoCarouselItem, json: String) {
164
+ let initialIndex = item.clampedInitialPageIndex
163
165
  var body: [String: Any] = [
164
166
  "surfaceId": surfaceId,
165
167
  "carouselItem": json,
166
168
  "isActive": false,
167
169
  "playerState": "idle",
168
170
  "isMuted": cachedIsMuted,
169
- "activeVideoIndex": 0,
171
+ "activeVideoIndex": initialIndex,
170
172
  ]
171
- if let firstVideo = item.videos.first,
172
- let videoData = try? JSONEncoder().encode(firstVideo),
173
+ if item.videos.indices.contains(initialIndex),
174
+ let videoData = try? JSONEncoder().encode(item.videos[initialIndex]),
173
175
  let videoJSON = String(data: videoData, encoding: .utf8) {
174
176
  body["activeVideo"] = videoJSON
175
177
  } else {
@@ -8,32 +8,32 @@
8
8
  <key>BinaryPath</key>
9
9
  <string>ShortKitSDK.framework/ShortKitSDK</string>
10
10
  <key>LibraryIdentifier</key>
11
- <string>ios-arm64</string>
11
+ <string>ios-arm64_x86_64-simulator</string>
12
12
  <key>LibraryPath</key>
13
13
  <string>ShortKitSDK.framework</string>
14
14
  <key>SupportedArchitectures</key>
15
15
  <array>
16
16
  <string>arm64</string>
17
+ <string>x86_64</string>
17
18
  </array>
18
19
  <key>SupportedPlatform</key>
19
20
  <string>ios</string>
21
+ <key>SupportedPlatformVariant</key>
22
+ <string>simulator</string>
20
23
  </dict>
21
24
  <dict>
22
25
  <key>BinaryPath</key>
23
26
  <string>ShortKitSDK.framework/ShortKitSDK</string>
24
27
  <key>LibraryIdentifier</key>
25
- <string>ios-arm64_x86_64-simulator</string>
28
+ <string>ios-arm64</string>
26
29
  <key>LibraryPath</key>
27
30
  <string>ShortKitSDK.framework</string>
28
31
  <key>SupportedArchitectures</key>
29
32
  <array>
30
33
  <string>arm64</string>
31
- <string>x86_64</string>
32
34
  </array>
33
35
  <key>SupportedPlatform</key>
34
36
  <string>ios</string>
35
- <key>SupportedPlatformVariant</key>
36
- <string>simulator</string>
37
37
  </dict>
38
38
  </array>
39
39
  <key>CFBundlePackageType</key>
@@ -11,9 +11,9 @@
11
11
  <key>CFBundlePackageType</key>
12
12
  <string>FMWK</string>
13
13
  <key>CFBundleVersion</key>
14
- <string>0.2.36</string>
14
+ <string>0.2.38</string>
15
15
  <key>CFBundleShortVersionString</key>
16
- <string>0.2.36</string>
16
+ <string>0.2.38</string>
17
17
  <key>MinimumOSVersion</key>
18
18
  <string>16.0</string>
19
19
  </dict>
@@ -53517,7 +53517,7 @@
53517
53517
  "kind": "StringLiteral",
53518
53518
  "offset": 154,
53519
53519
  "length": 8,
53520
- "value": "\"0.2.36\""
53520
+ "value": "\"0.2.38\""
53521
53521
  },
53522
53522
  {
53523
53523
  "filePath": "\/Users\/michaelseleman\/shortkit\/swift_sdk\/Sources\/ShortKit\/ShortKit.swift",
@@ -10,11 +10,11 @@
10
10
  </data>
11
11
  <key>Info.plist</key>
12
12
  <data>
13
- WQrFujyFbopBKGGWbEKoC1woXeM=
13
+ OPbiGQgVvhc9i76qqjEwO15NZ9U=
14
14
  </data>
15
15
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json</key>
16
16
  <data>
17
- mwN55YBG0lFbYyqh8rFLlhydUhg=
17
+ bmR7HZYLcS/c/oJglUFjXRw3kcs=
18
18
  </data>
19
19
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface</key>
20
20
  <data>
@@ -50,7 +50,7 @@
50
50
  <dict>
51
51
  <key>hash2</key>
52
52
  <data>
53
- lPV80dTyQRi3R5Al7WxpvZYLMH1fLtDMRPj8kH++RCs=
53
+ k2NIGYptDbpRKu5vmsmuboSr7G2j4qb9eftlewub0LA=
54
54
  </data>
55
55
  </dict>
56
56
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface</key>
@@ -11,9 +11,9 @@
11
11
  <key>CFBundlePackageType</key>
12
12
  <string>FMWK</string>
13
13
  <key>CFBundleVersion</key>
14
- <string>0.2.36</string>
14
+ <string>0.2.38</string>
15
15
  <key>CFBundleShortVersionString</key>
16
- <string>0.2.36</string>
16
+ <string>0.2.38</string>
17
17
  <key>MinimumOSVersion</key>
18
18
  <string>16.0</string>
19
19
  </dict>
@@ -53517,7 +53517,7 @@
53517
53517
  "kind": "StringLiteral",
53518
53518
  "offset": 154,
53519
53519
  "length": 8,
53520
- "value": "\"0.2.36\""
53520
+ "value": "\"0.2.38\""
53521
53521
  },
53522
53522
  {
53523
53523
  "filePath": "\/Users\/michaelseleman\/shortkit\/swift_sdk\/Sources\/ShortKit\/ShortKit.swift",
@@ -53517,7 +53517,7 @@
53517
53517
  "kind": "StringLiteral",
53518
53518
  "offset": 154,
53519
53519
  "length": 8,
53520
- "value": "\"0.2.36\""
53520
+ "value": "\"0.2.38\""
53521
53521
  },
53522
53522
  {
53523
53523
  "filePath": "\/Users\/michaelseleman\/shortkit\/swift_sdk\/Sources\/ShortKit\/ShortKit.swift",
@@ -10,11 +10,11 @@
10
10
  </data>
11
11
  <key>Info.plist</key>
12
12
  <data>
13
- WQrFujyFbopBKGGWbEKoC1woXeM=
13
+ OPbiGQgVvhc9i76qqjEwO15NZ9U=
14
14
  </data>
15
15
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json</key>
16
16
  <data>
17
- mwN55YBG0lFbYyqh8rFLlhydUhg=
17
+ bmR7HZYLcS/c/oJglUFjXRw3kcs=
18
18
  </data>
19
19
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface</key>
20
20
  <data>
@@ -30,7 +30,7 @@
30
30
  </data>
31
31
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json</key>
32
32
  <data>
33
- mwN55YBG0lFbYyqh8rFLlhydUhg=
33
+ bmR7HZYLcS/c/oJglUFjXRw3kcs=
34
34
  </data>
35
35
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface</key>
36
36
  <data>
@@ -66,7 +66,7 @@
66
66
  <dict>
67
67
  <key>hash2</key>
68
68
  <data>
69
- lPV80dTyQRi3R5Al7WxpvZYLMH1fLtDMRPj8kH++RCs=
69
+ k2NIGYptDbpRKu5vmsmuboSr7G2j4qb9eftlewub0LA=
70
70
  </data>
71
71
  </dict>
72
72
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface</key>
@@ -94,7 +94,7 @@
94
94
  <dict>
95
95
  <key>hash2</key>
96
96
  <data>
97
- lPV80dTyQRi3R5Al7WxpvZYLMH1fLtDMRPj8kH++RCs=
97
+ k2NIGYptDbpRKu5vmsmuboSr7G2j4qb9eftlewub0LA=
98
98
  </data>
99
99
  </dict>
100
100
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface</key>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shortkitsdk/react-native",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "ShortKit React Native SDK — short-form video feed",
5
5
  "react-native": "src/index",
6
6
  "source": "src/index",