@shortkitsdk/react-native 0.2.11 → 0.2.14

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 (57) hide show
  1. package/android/build.gradle.kts +13 -1
  2. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +157 -54
  3. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +67 -56
  4. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +431 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +154 -26
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +160 -35
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +5 -0
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +45 -10
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +9 -0
  10. package/ios/ReactCarouselOverlayHost.swift +37 -17
  11. package/ios/ReactOverlayHost.swift +33 -35
  12. package/ios/ReactVideoCarouselOverlayHost.swift +283 -0
  13. package/ios/ShortKitBridge.swift +78 -2
  14. package/ios/ShortKitFeedView.swift +24 -3
  15. package/ios/ShortKitModule.mm +6 -2
  16. package/ios/ShortKitSDK.xcframework/Info.plist +4 -4
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +2597 -389
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +69 -5
  19. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  20. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +69 -5
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  22. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  23. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +2597 -389
  24. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +69 -5
  25. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +69 -5
  27. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  28. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  29. package/ios/ShortKitSDK.xcframework.bak2/Info.plist +43 -0
  30. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  31. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
  32. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +31351 -0
  33. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +865 -0
  34. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  35. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +865 -0
  36. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  37. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  38. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  39. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
  40. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +31351 -0
  41. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +865 -0
  42. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  43. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +865 -0
  44. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  45. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  46. package/package.json +1 -1
  47. package/src/ShortKitCarouselOverlaySurface.tsx +57 -2
  48. package/src/ShortKitContext.ts +2 -1
  49. package/src/ShortKitFeed.tsx +19 -1
  50. package/src/ShortKitOverlaySurface.tsx +148 -41
  51. package/src/ShortKitPlayer.tsx +25 -3
  52. package/src/ShortKitProvider.tsx +4 -2
  53. package/src/ShortKitVideoCarouselOverlaySurface.tsx +156 -0
  54. package/src/index.ts +8 -1
  55. package/src/serialization.ts +8 -0
  56. package/src/specs/NativeShortKitModule.ts +31 -1
  57. package/src/types.ts +45 -1
@@ -205,13 +205,13 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
205
205
  }
206
206
 
207
207
  @ReactMethod
208
- override fun preloadFeed(configJSON: String, promise: Promise) {
208
+ override fun preloadFeed(configJSON: String, itemsJSON: String?, promise: Promise) {
209
209
  val b = bridge
210
210
  if (b == null) {
211
211
  promise.resolve("")
212
212
  return
213
213
  }
214
- b.preloadFeed(configJSON) { result -> promise.resolve(result) }
214
+ b.preloadFeed(configJSON, itemsJSON) { result -> promise.resolve(result) }
215
215
  }
216
216
 
217
217
  // -----------------------------------------------------------------
@@ -238,15 +238,50 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
238
238
  // -----------------------------------------------------------------
239
239
 
240
240
  private fun sendEvent(name: String, params: WritableMap) {
241
- // NOTE: In RN 0.78 new architecture, the codegen EventEmitter<T> does
242
- // NOT call the legacy addListener/removeListeners methods, so the
243
- // hasListeners flag stays false forever. We skip the guard and always
244
- // emit. The try/catch handles the case where the catalyst instance
245
- // is torn down.
241
+ // Use the codegen-generated emitOn* methods which route through
242
+ // mEventEmitterCallback (JSI direct channel). The legacy
243
+ // DeviceEventManagerModule.RCTDeviceEventEmitter path does NOT
244
+ // reach codegen EventEmitter<T> subscribers in RN 0.78 new arch.
246
245
  try {
247
- reactApplicationContext
248
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
249
- .emit(name, params)
246
+ when (name) {
247
+ "onPlayerStateChanged" -> emitOnPlayerStateChanged(params)
248
+ "onCurrentItemChanged" -> emitOnCurrentItemChanged(params)
249
+ "onTimeUpdate" -> emitOnTimeUpdate(params)
250
+ "onMutedChanged" -> emitOnMutedChanged(params)
251
+ "onPlaybackRateChanged" -> emitOnPlaybackRateChanged(params)
252
+ "onCaptionsEnabledChanged" -> emitOnCaptionsEnabledChanged(params)
253
+ "onActiveCaptionTrackChanged" -> emitOnActiveCaptionTrackChanged(params)
254
+ "onActiveCueChanged" -> emitOnActiveCueChanged(params)
255
+ "onDidLoop" -> emitOnDidLoop(params)
256
+ "onFeedTransition" -> emitOnFeedTransition(params)
257
+ "onFeedScrollPhase" -> emitOnFeedScrollPhase(params)
258
+ "onFormatChange" -> emitOnFormatChange(params)
259
+ "onPrefetchedAheadCountChanged" -> emitOnPrefetchedAheadCountChanged(params)
260
+ "onRemainingContentCountChanged" -> emitOnRemainingContentCountChanged(params)
261
+ "onSurveyResponse" -> emitOnSurveyResponse(params)
262
+ "onContentTapped" -> emitOnContentTapped(params)
263
+ "onDismiss" -> emitOnDismiss(params)
264
+ "onRefreshRequested" -> emitOnRefreshRequested(params)
265
+ "onDidFetchContentItems" -> emitOnDidFetchContentItems(params)
266
+ "onFeedReady" -> emitOnFeedReady(params)
267
+ "onOverlayActiveChanged" -> emitOnOverlayActiveChanged(params)
268
+ "onOverlayPlayerStateChanged" -> emitOnOverlayPlayerStateChanged(params)
269
+ "onOverlayMutedChanged" -> emitOnOverlayMutedChanged(params)
270
+ "onOverlayPlaybackRateChanged" -> emitOnOverlayPlaybackRateChanged(params)
271
+ "onOverlayCaptionsEnabledChanged" -> emitOnOverlayCaptionsEnabledChanged(params)
272
+ "onOverlayActiveCueChanged" -> emitOnOverlayActiveCueChanged(params)
273
+ "onOverlayFeedScrollPhaseChanged" -> emitOnOverlayFeedScrollPhaseChanged(params)
274
+ "onOverlayTimeUpdate" -> emitOnOverlayTimeUpdate(params)
275
+ "onOverlayFullState" -> emitOnOverlayFullState(params)
276
+ "onCarouselActiveImageChanged" -> emitOnCarouselActiveImageChanged(params)
277
+ "onVideoCarouselActiveVideoChanged" -> emitOnVideoCarouselActiveVideoChanged(params)
278
+ else -> {
279
+ android.util.Log.w("SK:Module", "sendEvent: unknown event name '$name', using legacy emitter")
280
+ reactApplicationContext
281
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
282
+ .emit(name, params)
283
+ }
284
+ }
250
285
  } catch (e: Exception) {
251
286
  android.util.Log.e("SK:Module", "sendEvent($name) EXCEPTION: ${e.message}", e)
252
287
  }
@@ -1,6 +1,7 @@
1
1
  package com.shortkit.reactnative
2
2
 
3
3
  import android.content.Context
4
+ import android.view.MotionEvent
4
5
  import android.widget.FrameLayout
5
6
  import com.shortkit.sdk.config.PlayerClickAction
6
7
  import com.shortkit.sdk.config.PlayerConfig
@@ -42,6 +43,14 @@ class ShortKitPlayerNativeView(context: Context) : FrameLayout(context) {
42
43
  applyActive()
43
44
  }
44
45
 
46
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
47
+ return super.onInterceptTouchEvent(ev)
48
+ }
49
+
50
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
51
+ return super.dispatchTouchEvent(ev)
52
+ }
53
+
45
54
  override fun onAttachedToWindow() {
46
55
  super.onAttachedToWindow()
47
56
  rebuildIfNeeded()
@@ -5,8 +5,8 @@ import ShortKitSDK
5
5
  /// for rendering the developer's React carousel overlay component inside a feed cell.
6
6
  ///
7
7
  /// Unlike ReactOverlayHost, this does not subscribe to player state (carousels
8
- /// are image-based). It emits image URLs to JS for Image.prefetch() and pushes
9
- /// ImageCarouselItem data as appProperties.
8
+ /// are image-based). It delivers `isActive` and `activeImageIndex` via bridge
9
+ /// events (routed by surfaceId) to avoid Fabric remounts.
10
10
  @objc public class ReactCarouselOverlayHost: UIView, @unchecked Sendable, CarouselOverlay {
11
11
 
12
12
  // MARK: - Configuration
@@ -20,9 +20,6 @@ import ShortKitSDK
20
20
 
21
21
  // MARK: - Eager Surface Creation
22
22
 
23
- /// Eagerly create the RN surface so it's ready before the first configure().
24
- /// Called by the overlay factory right after setting surfacePresenter and moduleName.
25
- /// Mirrors ReactOverlayHost.attach(player:) which does the same for video overlays.
26
23
  func prepareSurface() {
27
24
  createSurfaceIfNeeded()
28
25
  }
@@ -37,6 +34,11 @@ import ShortKitSDK
37
34
  private var surface: SKFabricSurfaceWrapper?
38
35
  private var surfaceView: UIView?
39
36
  private var pendingProps: [String: Any]?
37
+ private var isActive = false
38
+ private var activeImageIndex = 0
39
+
40
+ /// Unique identifier for this overlay instance, used for event routing.
41
+ let surfaceId = UUID().uuidString
40
42
 
41
43
  // MARK: - Init
42
44
 
@@ -57,11 +59,11 @@ import ShortKitSDK
57
59
  // MARK: - CarouselOverlay
58
60
 
59
61
  public func configure(with item: ImageCarouselItem) {
62
+ isActive = false
63
+ activeImageIndex = 0
60
64
  createSurfaceIfNeeded()
61
65
 
62
66
  // Replace remote URLs with local file URLs for any natively-cached images.
63
- // The SDK's PrefetchManager downloads images into NSCache<NSString, UIImage>
64
- // which RN can't access. Writing to temp files lets RN load instantly from disk.
65
67
  var modifiedItem = item
66
68
  if let cachedImage {
67
69
  var localImages: [CarouselImage] = []
@@ -85,10 +87,12 @@ import ShortKitSDK
85
87
  )
86
88
  }
87
89
 
88
- // Build props — push now if surface exists, otherwise store for later
89
90
  if let data = try? JSONEncoder().encode(modifiedItem),
90
91
  let json = String(data: data, encoding: .utf8) {
91
- let props: [String: Any] = ["item": json]
92
+ let props: [String: Any] = [
93
+ "surfaceId": surfaceId,
94
+ "item": json,
95
+ ]
92
96
  if let surface {
93
97
  surface.setProperties(props)
94
98
  } else {
@@ -97,14 +101,35 @@ import ShortKitSDK
97
101
  }
98
102
  }
99
103
 
104
+ public func activatePlayback() {
105
+ isActive = true
106
+ bridge?.emit("onOverlayFullState", body: [
107
+ "surfaceId": surfaceId,
108
+ "isActive": true,
109
+ // Include required fields for the full state event
110
+ "playerState": "idle",
111
+ "isMuted": true,
112
+ "playbackRate": 1.0,
113
+ "captionsEnabled": false,
114
+ "activeCue": NSNull(),
115
+ "feedScrollPhase": NSNull(),
116
+ ])
117
+ }
118
+
119
+ public func updateActiveImage(index: Int) {
120
+ activeImageIndex = index
121
+ bridge?.emit("onCarouselActiveImageChanged", body: [
122
+ "surfaceId": surfaceId,
123
+ "activeImageIndex": index,
124
+ ])
125
+ }
126
+
100
127
  /// Write a UIImage to the temp directory, returning a file:// URL string.
101
- /// Uses a hash of the remote URL as the filename to avoid duplicates.
102
128
  private func writeTempImage(_ image: UIImage, for remoteURL: String) -> String? {
103
129
  let hash = remoteURL.hash
104
130
  let fileName = "sk-carousel-\(abs(hash)).jpg"
105
131
  let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(fileName)
106
132
 
107
- // Skip if already written
108
133
  if FileManager.default.fileExists(atPath: fileURL.path) {
109
134
  return fileURL.absoluteString
110
135
  }
@@ -119,10 +144,7 @@ import ShortKitSDK
119
144
  }
120
145
 
121
146
  public func resetState() {
122
- // Don't clear surface props — matches video overlay (ReactOverlayHost) pattern.
123
- // configure() will overwrite with new props directly, letting React do an
124
- // in-place re-render (old→new) instead of an unmount+remount (old→empty→new).
125
- // The overlay's synchronous reset pattern handles state transitions.
147
+ // Don't clear surface props — configure() will overwrite.
126
148
  }
127
149
 
128
150
  // MARK: - Surface Creation
@@ -130,7 +152,6 @@ import ShortKitSDK
130
152
  private func createSurfaceIfNeeded() {
131
153
  guard surface == nil, let presenter = surfacePresenter else { return }
132
154
 
133
- // Must always dispatch to main queue — see ReactOverlayHost for details.
134
155
  DispatchQueue.main.async { [weak self] in
135
156
  guard let self, self.surface == nil else { return }
136
157
  self.installSurface(presenter: presenter)
@@ -157,7 +178,6 @@ import ShortKitSDK
157
178
  surfaceView = view
158
179
  surface = surf
159
180
 
160
- // Flush any props that arrived before surface was ready
161
181
  if let pending = pendingProps {
162
182
  surf.setProperties(pending)
163
183
  pendingProps = nil
@@ -126,6 +126,7 @@ import ShortKitSDK
126
126
  }
127
127
 
128
128
  public func configure(with item: ContentItem) {
129
+ let isSameItem = item.id == currentItem?.id
129
130
  currentItem = item
130
131
  isActive = false
131
132
  timeDirty = false
@@ -140,10 +141,12 @@ import ShortKitSDK
140
141
 
141
142
  createSurfaceIfNeeded()
142
143
 
143
- // Set surface properties ONCE per item. This triggers a Fabric remount,
144
- // which is desired on cell reuse (new content, fresh React state).
145
- // All subsequent dynamic updates go through events, not setProperties().
146
- pushInitialProperties()
144
+ // Only push properties if the surface already exists.
145
+ // If the surface is still being created asynchronously,
146
+ // installSurface() will call pushInitialProperties() when ready.
147
+ if surface != nil && !isSameItem {
148
+ pushInitialProperties()
149
+ }
147
150
  }
148
151
 
149
152
  public func activatePlayback() {
@@ -165,41 +168,27 @@ import ShortKitSDK
165
168
  /// Emit all cached state as individual events. Called from activatePlayback()
166
169
  /// (deferred) and can be called whenever we need to synchronize JS state.
167
170
  private func emitFullState() {
168
- bridge?.emit("onOverlayActiveChanged", body: [
169
- "surfaceId": surfaceId, "isActive": true
170
- ])
171
- bridge?.emit("onOverlayPlayerStateChanged", body: [
172
- "surfaceId": surfaceId, "playerState": cachedPlayerState
173
- ])
174
- bridge?.emit("onOverlayMutedChanged", body: [
175
- "surfaceId": surfaceId, "isMuted": cachedIsMuted
176
- ])
177
- bridge?.emit("onOverlayPlaybackRateChanged", body: [
178
- "surfaceId": surfaceId, "playbackRate": cachedPlaybackRate
179
- ])
180
- bridge?.emit("onOverlayCaptionsEnabledChanged", body: [
181
- "surfaceId": surfaceId, "captionsEnabled": cachedCaptionsEnabled
182
- ])
171
+ var body: [String: Any] = [
172
+ "surfaceId": surfaceId,
173
+ "isActive": true,
174
+ "playerState": cachedPlayerState,
175
+ "isMuted": cachedIsMuted,
176
+ "playbackRate": cachedPlaybackRate,
177
+ "captionsEnabled": cachedCaptionsEnabled,
178
+ ]
183
179
  if let cue = cachedActiveCue,
184
180
  let cueData = try? JSONSerialization.data(withJSONObject: cue),
185
181
  let cueJson = String(data: cueData, encoding: .utf8) {
186
- bridge?.emit("onOverlayActiveCueChanged", body: [
187
- "surfaceId": surfaceId, "activeCue": cueJson
188
- ])
182
+ body["activeCue"] = cueJson
189
183
  } else {
190
- bridge?.emit("onOverlayActiveCueChanged", body: [
191
- "surfaceId": surfaceId, "activeCue": NSNull()
192
- ])
184
+ body["activeCue"] = NSNull()
193
185
  }
194
186
  if let scrollPhase = cachedFeedScrollPhase {
195
- bridge?.emit("onOverlayFeedScrollPhaseChanged", body: [
196
- "surfaceId": surfaceId, "feedScrollPhase": scrollPhase
197
- ])
187
+ body["feedScrollPhase"] = scrollPhase
198
188
  } else {
199
- bridge?.emit("onOverlayFeedScrollPhaseChanged", body: [
200
- "surfaceId": surfaceId, "feedScrollPhase": NSNull()
201
- ])
189
+ body["feedScrollPhase"] = NSNull()
202
190
  }
191
+ bridge?.emit("onOverlayFullState", body: body)
203
192
  }
204
193
 
205
194
  // MARK: - Surface Creation
@@ -240,6 +229,15 @@ import ShortKitSDK
240
229
 
241
230
  // Push any pending properties now that the surface exists
242
231
  pushInitialProperties()
232
+
233
+ // If activatePlayback() was called before the surface was ready,
234
+ // emit the full state now that JS can receive events.
235
+ if isActive {
236
+ DispatchQueue.main.async { [weak self] in
237
+ guard let self, self.isActive else { return }
238
+ self.emitFullState()
239
+ }
240
+ }
243
241
  }
244
242
 
245
243
  // MARK: - Layout
@@ -418,10 +416,10 @@ import ShortKitSDK
418
416
  props["item"] = json
419
417
  }
420
418
 
421
- // Initial valuesmay be stale by the time activatePlayback() fires,
422
- // which is why activatePlayback() emits a full state flush via events.
423
- props["isActive"] = false
424
- props["playerState"] = "idle"
419
+ // Use current state if activatePlayback() was called before the
420
+ // surface was created, isActive will already be true.
421
+ props["isActive"] = isActive
422
+ props["playerState"] = cachedPlayerState
425
423
  props["isMuted"] = cachedIsMuted
426
424
  props["playbackRate"] = cachedPlaybackRate
427
425
  props["captionsEnabled"] = cachedCaptionsEnabled
@@ -0,0 +1,283 @@
1
+ import UIKit
2
+ import Combine
3
+ import ShortKitSDK
4
+
5
+ /// A UIView that conforms to `VideoCarouselOverlay` and hosts an `RCTFabricSurface`
6
+ /// for rendering the developer's React video carousel overlay component inside a feed cell.
7
+ ///
8
+ /// Pushes `VideoCarouselItem` and active video data as surface properties, and
9
+ /// emits playback state (isActive, time, playerState, isMuted) via bridge events
10
+ /// using the same event names as `ReactOverlayHost` (routed by `surfaceId`).
11
+ @objc public class ReactVideoCarouselOverlayHost: UIView, @unchecked Sendable, VideoCarouselOverlay {
12
+
13
+ // MARK: - Configuration
14
+
15
+ var surfacePresenter: AnyObject?
16
+ weak var bridge: ShortKitBridge?
17
+
18
+ /// Module name for the RCTFabricSurface. Set by the overlay factory
19
+ /// based on the feed config's video carousel overlay name.
20
+ var videoCarouselOverlayModuleName: String = "ShortKitVideoCarouselOverlay"
21
+
22
+ // MARK: - Eager Surface Creation
23
+
24
+ func prepareSurface() {
25
+ createSurfaceIfNeeded()
26
+ }
27
+
28
+ // MARK: - State
29
+
30
+ private var surface: SKFabricSurfaceWrapper?
31
+ private var surfaceView: UIView?
32
+ private var pendingProps: [String: Any]?
33
+
34
+ /// Unique identifier for this overlay instance, used for event routing.
35
+ let surfaceId = UUID().uuidString
36
+
37
+ /// Cached carouselItem JSON — setProperties replaces all props, doesn't merge.
38
+ private var carouselItemJSON: String?
39
+
40
+ // Player state
41
+ private var player: ShortKitPlayer?
42
+ private var cancellables = Set<AnyCancellable>()
43
+ private var isActive = false
44
+ private var cachedPlayerState: String = "idle"
45
+ private var cachedIsMuted: Bool = true
46
+ private var cachedTime: (current: Double, duration: Double, buffered: Double) = (0, 0, 0)
47
+ private var timeCoalesceTimer: Timer?
48
+ private var timeDirty = false
49
+
50
+ // MARK: - Init
51
+
52
+ override init(frame: CGRect) {
53
+ super.init(frame: frame)
54
+ backgroundColor = .clear
55
+ isUserInteractionEnabled = true
56
+ }
57
+
58
+ required init?(coder: NSCoder) {
59
+ fatalError("init(coder:) is not supported")
60
+ }
61
+
62
+ deinit {
63
+ timeCoalesceTimer?.invalidate()
64
+ surface?.stop()
65
+ }
66
+
67
+ // MARK: - VideoCarouselOverlay
68
+
69
+ public func configure(with item: VideoCarouselItem) {
70
+ isActive = false
71
+ timeDirty = false
72
+ timeCoalesceTimer?.invalidate()
73
+ timeCoalesceTimer = nil
74
+ cachedTime = (0, 0, 0)
75
+ cachedPlayerState = "idle"
76
+
77
+ createSurfaceIfNeeded()
78
+
79
+ guard let data = try? JSONEncoder().encode(item),
80
+ let json = String(data: data, encoding: .utf8) else { return }
81
+ carouselItemJSON = json
82
+
83
+ var props: [String: Any] = [
84
+ "surfaceId": surfaceId,
85
+ "carouselItem": json,
86
+ "isActive": false,
87
+ "playerState": "idle",
88
+ "isMuted": cachedIsMuted,
89
+ ]
90
+ if let firstVideo = item.videos.first,
91
+ let videoData = try? JSONEncoder().encode(firstVideo),
92
+ let videoJSON = String(data: videoData, encoding: .utf8) {
93
+ props["activeVideo"] = videoJSON
94
+ props["activeVideoIndex"] = 0
95
+ }
96
+ if let surface {
97
+ surface.setProperties(props)
98
+ } else {
99
+ pendingProps = props
100
+ }
101
+ }
102
+
103
+ public func updateActiveVideo(index: Int, item: ContentItem) {
104
+ guard let data = try? JSONEncoder().encode(item),
105
+ let json = String(data: data, encoding: .utf8) else { return }
106
+
107
+ // Only emit when active — matches ReactOverlayHost pattern.
108
+ // During initial setup, configure() sets the first video via surface props.
109
+ // Before activation, the AsyncEventEmitter may not be initialized.
110
+ guard isActive else { return }
111
+
112
+ bridge?.emit("onVideoCarouselActiveVideoChanged", body: [
113
+ "surfaceId": surfaceId,
114
+ "activeVideo": json,
115
+ "activeVideoIndex": index,
116
+ ])
117
+ }
118
+
119
+ public func resetState() {
120
+ // Don't clear surface props — configure() will overwrite.
121
+ }
122
+
123
+ public func attach(player: ShortKitPlayer) {
124
+ self.player = player
125
+ subscribeToPlayer(player)
126
+ createSurfaceIfNeeded()
127
+ }
128
+
129
+ public func activatePlayback() {
130
+ isActive = true
131
+ startTimeCoalescing()
132
+
133
+ DispatchQueue.main.async { [weak self] in
134
+ guard let self, self.isActive else { return }
135
+ self.emitFullState()
136
+ }
137
+ }
138
+
139
+ public func deactivatePlayback() {
140
+ isActive = false
141
+ timeDirty = false
142
+ timeCoalesceTimer?.invalidate()
143
+ timeCoalesceTimer = nil
144
+ }
145
+
146
+ // MARK: - Player Subscriptions
147
+
148
+ private func subscribeToPlayer(_ player: ShortKitPlayer) {
149
+ cancellables.removeAll()
150
+ player.playerState
151
+ .receive(on: DispatchQueue.main)
152
+ .sink { [weak self] state in
153
+ guard let self else { return }
154
+ self.cachedPlayerState = Self.playerStateString(state)
155
+ if self.isActive {
156
+ self.bridge?.emit("onOverlayPlayerStateChanged", body: [
157
+ "surfaceId": self.surfaceId,
158
+ "playerState": self.cachedPlayerState
159
+ ])
160
+ }
161
+ }
162
+ .store(in: &cancellables)
163
+
164
+ player.isMuted
165
+ .receive(on: DispatchQueue.main)
166
+ .sink { [weak self] muted in
167
+ guard let self else { return }
168
+ self.cachedIsMuted = muted
169
+ if self.isActive {
170
+ self.bridge?.emit("onOverlayMutedChanged", body: [
171
+ "surfaceId": self.surfaceId,
172
+ "isMuted": self.cachedIsMuted
173
+ ])
174
+ }
175
+ }
176
+ .store(in: &cancellables)
177
+
178
+ player.time
179
+ .receive(on: DispatchQueue.main)
180
+ .sink { [weak self] time in
181
+ guard let self, self.isActive else { return }
182
+ self.cachedTime = (time.current, time.duration, time.buffered)
183
+ self.timeDirty = true
184
+ }
185
+ .store(in: &cancellables)
186
+ }
187
+
188
+ // MARK: - Time Coalescing
189
+
190
+ private func startTimeCoalescing() {
191
+ timeCoalesceTimer?.invalidate()
192
+ timeCoalesceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
193
+ guard let self, self.timeDirty else { return }
194
+ self.timeDirty = false
195
+ self.bridge?.emit("onOverlayTimeUpdate", body: [
196
+ "surfaceId": self.surfaceId,
197
+ "current": self.cachedTime.current,
198
+ "duration": self.cachedTime.duration,
199
+ "buffered": self.cachedTime.buffered
200
+ ])
201
+ }
202
+ }
203
+
204
+ // MARK: - Full State Emission
205
+
206
+ private func emitFullState() {
207
+ bridge?.emit("onOverlayFullState", body: [
208
+ "surfaceId": surfaceId,
209
+ "isActive": true,
210
+ "playerState": cachedPlayerState,
211
+ "isMuted": cachedIsMuted,
212
+ "playbackRate": 1.0,
213
+ "captionsEnabled": false,
214
+ "activeCue": NSNull(),
215
+ "feedScrollPhase": NSNull(),
216
+ ])
217
+ }
218
+
219
+ // MARK: - Surface Creation
220
+
221
+ private func createSurfaceIfNeeded() {
222
+ guard surface == nil, let presenter = surfacePresenter else { return }
223
+
224
+ DispatchQueue.main.async { [weak self] in
225
+ guard let self, self.surface == nil else { return }
226
+ self.installSurface(presenter: presenter)
227
+ }
228
+ }
229
+
230
+ private func installSurface(presenter: AnyObject) {
231
+ guard let surf = SKFabricSurfaceWrapper(
232
+ presenter: presenter,
233
+ moduleName: videoCarouselOverlayModuleName,
234
+ initialProperties: [:]
235
+ ) else { return }
236
+ surf.start()
237
+
238
+ let view = surf.view
239
+ view.translatesAutoresizingMaskIntoConstraints = false
240
+ addSubview(view)
241
+ NSLayoutConstraint.activate([
242
+ view.topAnchor.constraint(equalTo: topAnchor),
243
+ view.leadingAnchor.constraint(equalTo: leadingAnchor),
244
+ view.trailingAnchor.constraint(equalTo: trailingAnchor),
245
+ view.bottomAnchor.constraint(equalTo: bottomAnchor),
246
+ ])
247
+ surfaceView = view
248
+ surface = surf
249
+
250
+ // Flush any props that arrived before surface was ready
251
+ if let pending = pendingProps {
252
+ surf.setProperties(pending)
253
+ pendingProps = nil
254
+ }
255
+ }
256
+
257
+ // MARK: - Layout
258
+
259
+ public override func layoutSubviews() {
260
+ super.layoutSubviews()
261
+ guard let surface else { return }
262
+ let size = bounds.size
263
+ guard size.width > 0, size.height > 0 else { return }
264
+ surface.setMinimumSize(size)
265
+ surface.setMaximumSize(size)
266
+ }
267
+
268
+ // MARK: - Helpers
269
+
270
+ private static func playerStateString(_ state: PlayerState) -> String {
271
+ switch state {
272
+ case .idle: return "idle"
273
+ case .loading: return "loading"
274
+ case .ready: return "ready"
275
+ case .playing: return "playing"
276
+ case .paused: return "paused"
277
+ case .seeking: return "seeking"
278
+ case .buffering: return "buffering"
279
+ case .ended: return "ended"
280
+ case .error(_): return "error"
281
+ }
282
+ }
283
+ }