@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
@@ -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
 
@@ -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)
@@ -11,9 +11,9 @@
11
11
  <key>CFBundlePackageType</key>
12
12
  <string>FMWK</string>
13
13
  <key>CFBundleVersion</key>
14
- <string>0.2.34</string>
14
+ <string>0.2.36</string>
15
15
  <key>CFBundleShortVersionString</key>
16
- <string>0.2.34</string>
16
+ <string>0.2.36</string>
17
17
  <key>MinimumOSVersion</key>
18
18
  <string>16.0</string>
19
19
  </dict>