@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
@@ -0,0 +1,134 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import android.content.Context
4
+ import android.os.Bundle
5
+ import android.view.ViewGroup
6
+ import androidx.test.core.app.ApplicationProvider
7
+ import com.facebook.react.bridge.WritableMap
8
+ import com.facebook.react.interfaces.TaskInterface
9
+ import com.facebook.react.interfaces.fabric.ReactSurface
10
+ import com.shortkit.sdk.model.CarouselImage
11
+ import com.shortkit.sdk.model.ImageCarouselItem
12
+ import org.junit.Assert.assertEquals
13
+ import org.junit.Assert.assertFalse
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
+ * Tests SHO-8 emit-on-change gating in ReactCarouselOverlayHost.
22
+ *
23
+ * Behavior under test (mirrors iOS ReactCarouselOverlayHost.swift:132-145):
24
+ * - First configure() with item A → applySurfaceProps path; no emit.
25
+ * - Second configure() with same item A → no emit, no setProperties.
26
+ * - Second configure() with different item B → emit onCarouselItemChanged
27
+ * with payload { surfaceId, item, isActive: false, activeImageIndex: 0 }.
28
+ */
29
+ @RunWith(RobolectricTestRunner::class)
30
+ class ReactCarouselOverlayHostEmitTest {
31
+
32
+ /** No-op TaskInterface returned by stub surface lifecycle methods. */
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
+ * Minimal ReactSurface stub for Robolectric tests. ApplicationProvider does
45
+ * not supply a ReactApplication, so createSurfaceIfNeeded() would normally
46
+ * fail silently with reactHost == null. The createSurface seam lets tests
47
+ * inject this stub so surface != null after pushProps(), allowing
48
+ * hasPushedInitialProps to flip correctly (the bug-fix gate under test).
49
+ *
50
+ * isRunning is false so applySurfaceProps() calls start() (no-op here) and
51
+ * the updateInitProps cast to ReactSurfaceImpl is a safe no-op via `as?`.
52
+ */
53
+ private class StubReactSurface(private val ctx: Context) : ReactSurface {
54
+ override val surfaceID: Int = 0
55
+ override val moduleName: String = "StubSurface"
56
+ override val isRunning: Boolean = false
57
+ override val view: ViewGroup? = null
58
+ override val context: Context get() = ctx
59
+ override fun prerender(): TaskInterface<Void> = NoOpTask
60
+ override fun start(): TaskInterface<Void> = NoOpTask
61
+ override fun stop(): TaskInterface<Void> = NoOpTask
62
+ override fun clear() = Unit
63
+ override fun detach() = Unit
64
+ }
65
+
66
+ private fun makeHost(): ReactCarouselOverlayHost {
67
+ val ctx = ApplicationProvider.getApplicationContext<Context>()
68
+ return ReactCarouselOverlayHost(ctx).also {
69
+ // JavaOnlyMap is a pure-Java WritableMap that avoids the JNI init
70
+ // requirement of WritableNativeMap in Robolectric unit tests.
71
+ it.createMap = { com.facebook.react.bridge.JavaOnlyMap() }
72
+ // Inject a stub surface so createSurfaceIfNeeded() succeeds and
73
+ // hasPushedInitialProps flips after the first configure().
74
+ it.createSurface = { _, _ -> StubReactSurface(ctx) }
75
+ }
76
+ }
77
+
78
+ private fun item(id: String) = ImageCarouselItem(
79
+ id = id,
80
+ images = listOf(CarouselImage(url = "https://example.com/a.jpg", alt = null)),
81
+ )
82
+
83
+ @Test
84
+ fun firstConfigureSetsHasPushedAndCurrentItemNoEmit() {
85
+ val host = makeHost()
86
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
87
+ host.emitEvent = { name, params -> emitted.add(name to params) }
88
+
89
+ host.configure(item("A"))
90
+
91
+ assertEquals("A", host.currentItemId)
92
+ assertTrue(host.hasPushedInitialProps)
93
+ assertTrue("first mount must not emit", emitted.isEmpty())
94
+ }
95
+
96
+ @Test
97
+ fun sameItemConfigureIsNoOpAfterFirstMount() {
98
+ val host = makeHost()
99
+ host.configure(item("A"))
100
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
101
+ host.emitEvent = { name, params -> emitted.add(name to params) }
102
+
103
+ host.configure(item("A"))
104
+
105
+ assertEquals("A", host.currentItemId)
106
+ assertTrue("same item must not emit", emitted.isEmpty())
107
+ }
108
+
109
+ @Test
110
+ fun differentItemEmitsCarouselItemChanged() {
111
+ val host = makeHost()
112
+ host.configure(item("A"))
113
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
114
+ host.emitEvent = { name, params -> emitted.add(name to params) }
115
+
116
+ host.configure(item("B"))
117
+
118
+ assertEquals("B", host.currentItemId)
119
+ assertEquals(1, emitted.size)
120
+ assertEquals("onCarouselItemChanged", emitted[0].first)
121
+ val params = emitted[0].second
122
+ assertEquals(host.surfaceId, params.getString("surfaceId"))
123
+ assertFalse(params.getBoolean("isActive"))
124
+ assertEquals(0, params.getInt("activeImageIndex"))
125
+ // `item` is a JSON string of the ImageCarouselItem; assert it contains
126
+ // the new id. Payload-shape correctness is covered by the cross-platform
127
+ // JS subscriber's destructuring, not asserted here.
128
+ val itemJson = params.getString("item")
129
+ assertTrue(
130
+ "item JSON should contain id=B but was: $itemJson",
131
+ itemJson?.contains("\"id\":\"B\"") == true,
132
+ )
133
+ }
134
+ }
@@ -0,0 +1,45 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Test
6
+ import org.junit.runner.RunWith
7
+ import org.robolectric.RobolectricTestRunner
8
+
9
+ /**
10
+ * Tests SHO-9 drag-phase field on ReactOverlayHost.
11
+ * The full state-machine wiring (flow → field, emissions gated, full
12
+ * state on settle) is verified via the manual / instrumented test in
13
+ * the spec test plan. This test verifies the field's existence and
14
+ * resettability via configure().
15
+ */
16
+ @RunWith(RobolectricTestRunner::class)
17
+ class ReactOverlayHostDragTest {
18
+
19
+ private fun makeHost() = ReactOverlayHost(
20
+ ApplicationProvider.getApplicationContext()
21
+ )
22
+
23
+ @Test
24
+ fun isDraggingDefaultsFalse() {
25
+ val host = makeHost()
26
+ assertFalse(host.isDragging)
27
+ }
28
+
29
+ @Test
30
+ fun configureResetsIsDragging() {
31
+ val host = makeHost()
32
+ host.isDraggingForTest = true
33
+ host.configure(makeContentItem("A"))
34
+ assertFalse(host.isDragging)
35
+ }
36
+
37
+ private fun makeContentItem(id: String) = com.shortkit.sdk.model.ContentItem(
38
+ id = id,
39
+ title = "t",
40
+ duration = 5.0,
41
+ streamingUrl = "https://example.com/$id.m3u8",
42
+ thumbnailUrl = "https://example.com/$id.jpg",
43
+ captionTracks = emptyList(),
44
+ )
45
+ }
@@ -0,0 +1,69 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import com.facebook.react.bridge.JavaOnlyMap
5
+ import com.shortkit.sdk.model.VideoCarouselItem
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Test
8
+ import org.junit.runner.RunWith
9
+ import org.robolectric.RobolectricTestRunner
10
+
11
+ /**
12
+ * Tests SHO-9 drag-phase field on ReactVideoCarouselOverlayHost.
13
+ * The full state-machine wiring requires a ShortKitPlayer instance with
14
+ * controllable flows; verified via manual / instrumented testing per the
15
+ * spec test plan. This test verifies the field's existence and reset
16
+ * behavior on configure().
17
+ *
18
+ * configure() always calls emitItemChanged(), so the createMap and
19
+ * createSurface seams must be injected here too (same as the emit test).
20
+ */
21
+ @RunWith(RobolectricTestRunner::class)
22
+ class ReactVideoCarouselOverlayHostDragTest {
23
+
24
+ private fun makeHost(): ReactVideoCarouselOverlayHost {
25
+ val host = ReactVideoCarouselOverlayHost(ApplicationProvider.getApplicationContext())
26
+ host.createMap = { JavaOnlyMap() }
27
+ // Stub surface so hasPushedInitialProps can flip and configure()
28
+ // doesn't retry on every call due to null surface.
29
+ host.createSurface = { _, _ ->
30
+ // Re-use the same stub shape from the emit test — no-arg style.
31
+ object : com.facebook.react.interfaces.fabric.ReactSurface {
32
+ override val surfaceID: Int = 0
33
+ override val moduleName: String = "StubSurface"
34
+ override val isRunning: Boolean = false
35
+ override val view: android.view.ViewGroup? = null
36
+ override val context: android.content.Context
37
+ get() = ApplicationProvider.getApplicationContext()
38
+ override fun prerender() = object : com.facebook.react.interfaces.TaskInterface<Void> {
39
+ override fun waitForCompletion() = Unit
40
+ override fun waitForCompletion(d: Long, u: java.util.concurrent.TimeUnit) = true
41
+ override fun getResult(): Void? = null
42
+ override fun getError(): Exception? = null
43
+ override fun isCompleted() = true
44
+ override fun isCancelled() = false
45
+ override fun isFaulted() = false
46
+ }
47
+ override fun start() = prerender()
48
+ override fun stop() = prerender()
49
+ override fun clear() = Unit
50
+ override fun detach() = Unit
51
+ }
52
+ }
53
+ return host
54
+ }
55
+
56
+ @Test
57
+ fun isDraggingDefaultsFalse() {
58
+ val host = makeHost()
59
+ assertFalse(host.isDragging)
60
+ }
61
+
62
+ @Test
63
+ fun configureResetsIsDragging() {
64
+ val host = makeHost()
65
+ host.isDraggingForTest = true
66
+ host.configure(VideoCarouselItem(id = "A", videos = emptyList()))
67
+ assertFalse(host.isDragging)
68
+ }
69
+ }
@@ -0,0 +1,144 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import android.content.Context
4
+ import android.os.Bundle
5
+ import android.view.ViewGroup
6
+ import androidx.test.core.app.ApplicationProvider
7
+ import com.facebook.react.bridge.JavaOnlyMap
8
+ import com.facebook.react.bridge.ReadableType
9
+ import com.facebook.react.bridge.WritableMap
10
+ import com.facebook.react.interfaces.TaskInterface
11
+ import com.facebook.react.interfaces.fabric.ReactSurface
12
+ import com.shortkit.sdk.model.ContentItem
13
+ import com.shortkit.sdk.model.VideoCarouselItem
14
+ import org.junit.Assert.assertEquals
15
+ import org.junit.Assert.assertTrue
16
+ import org.junit.Test
17
+ import org.junit.runner.RunWith
18
+ import org.robolectric.RobolectricTestRunner
19
+ import java.util.concurrent.TimeUnit
20
+
21
+ /**
22
+ * Tests SHO-8 emit-on-change gating in ReactVideoCarouselOverlayHost,
23
+ * including the iOS first-mount asymmetry where BOTH setProperties AND
24
+ * emitItemChanged are called on first mount (matches iOS at
25
+ * ReactVideoCarouselOverlayHost.swift:127-138).
26
+ */
27
+ @RunWith(RobolectricTestRunner::class)
28
+ class ReactVideoCarouselOverlayHostEmitTest {
29
+
30
+ /** No-op TaskInterface returned by stub surface lifecycle methods. */
31
+ private object NoOpTask : TaskInterface<Void> {
32
+ override fun waitForCompletion() = Unit
33
+ override fun waitForCompletion(duration: Long, timeUnit: TimeUnit): Boolean = true
34
+ override fun getResult(): Void? = null
35
+ override fun getError(): Exception? = null
36
+ override fun isCompleted(): Boolean = true
37
+ override fun isCancelled(): Boolean = false
38
+ override fun isFaulted(): Boolean = false
39
+ }
40
+
41
+ /**
42
+ * Minimal ReactSurface stub for Robolectric tests. ApplicationProvider does
43
+ * not supply a ReactApplication, so createSurfaceIfNeeded() would normally
44
+ * fail silently with reactHost == null. The createSurface seam lets tests
45
+ * inject this stub so surface != null after applySurfaceProps(), allowing
46
+ * hasPushedInitialProps to flip correctly.
47
+ *
48
+ * isRunning is false so applySurfaceProps() calls start() (no-op here) and
49
+ * the updateInitProps cast to ReactSurfaceImpl is a safe no-op via `as?`.
50
+ */
51
+ private class StubReactSurface : ReactSurface {
52
+ private val ctx: Context = ApplicationProvider.getApplicationContext()
53
+ override val surfaceID: Int = 0
54
+ override val moduleName: String = "StubSurface"
55
+ override val isRunning: Boolean = false
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> = NoOpTask
60
+ override fun stop(): TaskInterface<Void> = NoOpTask
61
+ override fun clear() = Unit
62
+ override fun detach() = Unit
63
+ }
64
+
65
+ private fun makeHost(): ReactVideoCarouselOverlayHost {
66
+ val host = ReactVideoCarouselOverlayHost(ApplicationProvider.getApplicationContext())
67
+ host.createMap = { JavaOnlyMap() }
68
+ // Inject a stub surface so hasPushedInitialProps can flip in tests.
69
+ // Same pattern as Task 1's ReactCarouselOverlayHost — see commit 6e5e1dce.
70
+ host.createSurface = { _, _ -> StubReactSurface() }
71
+ return host
72
+ }
73
+
74
+ private fun video(id: String) = ContentItem(
75
+ id = id,
76
+ title = "t",
77
+ duration = 5.0,
78
+ streamingUrl = "https://example.com/$id.m3u8",
79
+ thumbnailUrl = "https://example.com/$id.jpg",
80
+ captionTracks = emptyList(),
81
+ )
82
+
83
+ private fun item(id: String, videoCount: Int = 1) = VideoCarouselItem(
84
+ id = id,
85
+ videos = (0 until videoCount).map { video("$id-$it") },
86
+ )
87
+
88
+ @Test
89
+ fun firstConfigureBothPushesAndEmits() {
90
+ val host = makeHost()
91
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
92
+ host.emitEvent = { name, params -> emitted.add(name to params) }
93
+
94
+ host.configure(item("A"))
95
+
96
+ assertEquals("A", host.currentCarouselItem?.id)
97
+ assertTrue(host.hasPushedInitialProps)
98
+ assertEquals(1, emitted.size)
99
+ assertEquals("onVideoCarouselItemChanged", emitted[0].first)
100
+ }
101
+
102
+ @Test
103
+ fun sameItemConfigureIsNoOpAfterFirstMount() {
104
+ val host = makeHost()
105
+ host.configure(item("A"))
106
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
107
+ host.emitEvent = { name, params -> emitted.add(name to params) }
108
+
109
+ host.configure(item("A"))
110
+
111
+ assertTrue("same item must not emit", emitted.isEmpty())
112
+ }
113
+
114
+ @Test
115
+ fun differentItemEmitsOnly() {
116
+ val host = makeHost()
117
+ host.configure(item("A"))
118
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
119
+ host.emitEvent = { name, params -> emitted.add(name to params) }
120
+
121
+ host.configure(item("B"))
122
+
123
+ assertEquals("B", host.currentCarouselItem?.id)
124
+ assertEquals(1, emitted.size)
125
+ assertEquals("onVideoCarouselItemChanged", emitted[0].first)
126
+ val params = emitted[0].second
127
+ assertEquals(host.surfaceId, params.getString("surfaceId"))
128
+ }
129
+
130
+ @Test
131
+ fun emptyVideosUsesNullSentinelForActiveVideo() {
132
+ val host = makeHost()
133
+ host.configure(item("A", videoCount = 0))
134
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
135
+ host.emitEvent = { name, params -> emitted.add(name to params) }
136
+
137
+ host.configure(item("B", videoCount = 0))
138
+
139
+ // Per spec: empty videos must use putNull, NOT key absent.
140
+ // iOS uses NSNull at ReactVideoCarouselOverlayHost.swift:179.
141
+ val params = emitted[0].second
142
+ assertEquals(ReadableType.Null, params.getType("activeVideo"))
143
+ }
144
+ }
@@ -0,0 +1,57 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+ import org.junit.runner.RunWith
8
+ import org.robolectric.RobolectricTestRunner
9
+
10
+ /**
11
+ * Tests the `active` prop state machine on ShortKitFeedView.
12
+ *
13
+ * Covers:
14
+ * - Default state (active=true, activePropSet=false)
15
+ * - First-mount race precondition (active=false set before embed)
16
+ * - activePropSet flips on every call
17
+ */
18
+ @RunWith(RobolectricTestRunner::class)
19
+ class ShortKitFeedViewActivePropTest {
20
+
21
+ private fun makeView() = ShortKitFeedView(
22
+ ApplicationProvider.getApplicationContext()
23
+ )
24
+
25
+ @Test
26
+ fun activeDefaultsToTrueAndPropSetIsFalse() {
27
+ val view = makeView()
28
+ assertTrue(view.active)
29
+ assertFalse(view.activePropSet)
30
+ }
31
+
32
+ @Test
33
+ fun settingActiveTrueFlipsPropSet() {
34
+ val view = makeView()
35
+ view.setActiveFromBridge(true)
36
+ assertTrue(view.active)
37
+ assertTrue(view.activePropSet)
38
+ }
39
+
40
+ @Test
41
+ fun settingActiveFalseFlipsPropSet() {
42
+ val view = makeView()
43
+ view.setActiveFromBridge(false)
44
+ assertFalse(view.active)
45
+ assertTrue(view.activePropSet)
46
+ }
47
+
48
+ @Test
49
+ fun activeReflectsLatestValue() {
50
+ val view = makeView()
51
+ view.setActiveFromBridge(false)
52
+ view.setActiveFromBridge(true)
53
+ assertTrue(view.active)
54
+ view.setActiveFromBridge(false)
55
+ assertFalse(view.active)
56
+ }
57
+ }
@@ -140,16 +140,18 @@ import ShortKitSDK
140
140
  timeCoalesceTimer?.invalidate()
141
141
  timeCoalesceTimer = nil
142
142
 
143
- // Reset cached state so recycled cells don't flash stale values from
144
- // the previous item's player. Player-owned values (mute, rate, captions)
145
- // are also reset here; the new player's publishers will re-emit their
146
- // current values after attach(), so the defaults are only visible for
147
- // the single frame between configure() and the first publisher tick.
143
+ // Reset item-owned cached state so recycled cells don't flash stale
144
+ // values from the previous item. Player-owned values (mute, rate,
145
+ // captions) are NOT reset here: the player is a single global
146
+ // instance with session-wide state, and its `CurrentValueSubject`
147
+ // publishers are subscribed to ONCE in `attach(player:)` (gated by
148
+ // `customOverlay == nil` in FeedCell). On cell reuse, attach() does
149
+ // not re-run, so the subscription does not re-emit the publisher's
150
+ // current value to overwrite a reset cache. Resetting here would
151
+ // leave the cache stuck at the default until the user manually
152
+ // toggles the value again.
148
153
  cachedTime = (0, 0, 0)
149
154
  cachedPlayerState = "idle"
150
- cachedIsMuted = true
151
- cachedPlaybackRate = 1.0
152
- cachedCaptionsEnabled = false
153
155
  cachedActiveCue = nil
154
156
  cachedActiveCueJson = nil
155
157
  cachedFeedScrollPhase = nil
@@ -97,13 +97,14 @@ import ShortKitSDK
97
97
  timeDirty = false
98
98
  timeCoalesceTimer?.invalidate()
99
99
  timeCoalesceTimer = nil
100
- // Reset cached state so recycled cells don't flash stale values from
101
- // the previous item's player. The new player's publishers will re-emit
102
- // current values after attach(), so the defaults are only visible for
103
- // the single frame between configure() and the first publisher tick.
100
+ // Reset item-owned cached state so recycled cells don't flash stale
101
+ // values from the previous item. Player-owned values (mute) are
102
+ // NOT reset here: the player's `CurrentValueSubject` publishers are
103
+ // subscribed to ONCE in `attach(player:)`, which doesn't re-run on
104
+ // cell reuse, so a reset would not be refreshed by a subsequent
105
+ // publisher tick — the cache would be stuck at the default.
104
106
  cachedTime = (0, 0, 0)
105
107
  cachedPlayerState = "idle"
106
- cachedIsMuted = true
107
108
 
108
109
  createSurfaceIfNeeded()
109
110
 
@@ -147,11 +148,12 @@ import ShortKitSDK
147
148
  "playerState": "idle",
148
149
  "isMuted": cachedIsMuted,
149
150
  ]
150
- if let firstVideo = item.videos.first,
151
- 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]),
152
154
  let videoJSON = String(data: videoData, encoding: .utf8) {
153
155
  props["activeVideo"] = videoJSON
154
- props["activeVideoIndex"] = 0
156
+ props["activeVideoIndex"] = initialIndex
155
157
  }
156
158
  return props
157
159
  }
@@ -159,16 +161,17 @@ import ShortKitSDK
159
161
  /// Emit onVideoCarouselItemChanged with full initial state for the new item.
160
162
  /// Replaces setProperties() on cell reuse, avoiding a full Fabric root remount.
161
163
  private func emitItemChanged(item: VideoCarouselItem, json: String) {
164
+ let initialIndex = item.clampedInitialPageIndex
162
165
  var body: [String: Any] = [
163
166
  "surfaceId": surfaceId,
164
167
  "carouselItem": json,
165
168
  "isActive": false,
166
169
  "playerState": "idle",
167
170
  "isMuted": cachedIsMuted,
168
- "activeVideoIndex": 0,
171
+ "activeVideoIndex": initialIndex,
169
172
  ]
170
- if let firstVideo = item.videos.first,
171
- let videoData = try? JSONEncoder().encode(firstVideo),
173
+ if item.videos.indices.contains(initialIndex),
174
+ let videoData = try? JSONEncoder().encode(item.videos[initialIndex]),
172
175
  let videoJSON = String(data: videoData, encoding: .utf8) {
173
176
  body["activeVideo"] = videoJSON
174
177
  } else {
@@ -100,6 +100,20 @@ import ShortKitSDK
100
100
  ])
101
101
  }
102
102
 
103
+ // Wire per-feed video-carousel cell long-press event. Recognizer is
104
+ // owned by the cell and dies with the cell — no bridge bookkeeping
105
+ // needed. State is forwarded as a string ("began"/"ended"/"cancelled")
106
+ // because the underlying enum is `String`-backed.
107
+ vc.onVideoCarouselCellLongPress = { [weak self] payload in
108
+ self?.emitOnMain("onVideoCarouselCellLongPress", body: [
109
+ "feedId": id,
110
+ "id": payload.id,
111
+ "index": payload.index,
112
+ "pageIndex": payload.pageIndex,
113
+ "state": payload.state.rawValue,
114
+ ])
115
+ }
116
+
103
117
  // Wire per-feed transition event. The FVC fires this closure from
104
118
  // handleSwipe(to:) — one per transition, bound to this feed. This
105
119
  // replaces the global `player.feedTransition` subscription pattern
@@ -337,6 +351,10 @@ import ShortKitSDK
337
351
  shortKit?.player.setCaptionsEnabled(enabled)
338
352
  }
339
353
 
354
+ @objc public func setFeedScrollEnabled(_ enabled: Bool) {
355
+ shortKit?.player.setFeedScrollEnabled(enabled)
356
+ }
357
+
340
358
  @objc public func selectCaptionTrack(_ language: String) {
341
359
  shortKit?.player.selectCaptionTrack(language: language)
342
360
  }
@@ -72,6 +72,7 @@ RCT_EXPORT_MODULE(ShortKitModule)
72
72
  @"onDismiss",
73
73
  @"onFeedReady",
74
74
  @"onVideoCarouselCellTap",
75
+ @"onVideoCarouselCellLongPress",
75
76
  @"onRefreshStateChanged",
76
77
  @"onRefreshStateChangedPerFeed",
77
78
  @"onDidFetchContentItems",
@@ -248,6 +249,10 @@ RCT_EXPORT_METHOD(setCaptionsEnabled:(BOOL)enabled) {
248
249
  [_shortKitBridge setCaptionsEnabled:enabled];
249
250
  }
250
251
 
252
+ RCT_EXPORT_METHOD(setFeedScrollEnabled:(BOOL)enabled) {
253
+ [_shortKitBridge setFeedScrollEnabled:enabled];
254
+ }
255
+
251
256
  RCT_EXPORT_METHOD(selectCaptionTrack:(NSString *)language) {
252
257
  [_shortKitBridge selectCaptionTrack:language];
253
258
  }
@@ -48,6 +48,12 @@ import ShortKitSDK
48
48
  private var playerVC: ShortKitPlayerViewController?
49
49
  private var parsedConfig: PlayerConfig?
50
50
  private var parsedFeedItems: [FeedInput] = []
51
+ /// Parsed feedMask mode pulled out of the serialized config JSON in
52
+ /// `applyConfig()`. Stored separately because the SDK's
53
+ /// `ShortKitPlayerViewController.feedMask` is a top-level property,
54
+ /// not part of `PlayerConfig`. Mirrors the same pattern in the
55
+ /// widget bridge.
56
+ private var parsedFeedMask: FeedMaskMode = .none
51
57
 
52
58
  // MARK: - Lifecycle
53
59
 
@@ -112,6 +118,13 @@ import ShortKitSDK
112
118
  vc.setFeedItems(parsedFeedItems)
113
119
  }
114
120
 
121
+ // Forward the optional host-supplied feed mask. The SDK's
122
+ // `feedMask` lives as a top-level VC property (not in
123
+ // PlayerConfig), so it has to be set after construction. The
124
+ // factory closure inside the mode is invoked by the SDK each
125
+ // time the feed expansion runs.
126
+ vc.feedMask = parsedFeedMask
127
+
115
128
  parentVC.addChild(vc)
116
129
  vc.view.frame = bounds
117
130
  vc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
@@ -152,6 +165,11 @@ import ShortKitSDK
152
165
  private func applyConfig() {
153
166
  guard let json = config else { return }
154
167
  parsedConfig = Self.parsePlayerConfig(json)
168
+ // FeedMask is stored as a sibling field in the serialized
169
+ // config JSON; extract it here so it's available when the VC
170
+ // gets (re)constructed in embedPlayerVCIfNeeded. Mirrors the
171
+ // widget bridge.
172
+ parsedFeedMask = Self.extractFeedMask(json)
155
173
  // Config changes require re-embedding
156
174
  if playerVC != nil {
157
175
  destroyPlayerVC()
@@ -159,6 +177,24 @@ import ShortKitSDK
159
177
  }
160
178
  }
161
179
 
180
+ /// Pulls the `feedMask` field out of the raw config JSON without
181
+ /// re-parsing the rest of `PlayerConfig`. Returns `.none` if the
182
+ /// JSON is malformed or omits `feedMask`. Mirrors how the
183
+ /// equivalent value is read on `ShortKitWidgetNativeView`.
184
+ private static func extractFeedMask(_ json: String) -> FeedMaskMode {
185
+ guard let data = json.data(using: .utf8),
186
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
187
+ return .none
188
+ }
189
+ if let mask = obj["feedMask"] {
190
+ if let data = try? JSONSerialization.data(withJSONObject: mask),
191
+ let json = String(data: data, encoding: .utf8) {
192
+ return ShortKitBridge.parseFeedMask(json)
193
+ }
194
+ }
195
+ return .none
196
+ }
197
+
162
198
  private func applyContentItem() {
163
199
  guard let json = contentItem, let item = Self.parseContentItem(json) else { return }
164
200
  playerVC?.configure(with: item)