@shortkitsdk/react-native 0.2.12 → 0.2.15

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 (65) hide show
  1. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +47 -4
  2. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +431 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +117 -2
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +7 -5
  5. package/ios/ReactCarouselOverlayHost.swift +67 -35
  6. package/ios/ReactOverlayHost.swift +85 -75
  7. package/ios/ReactVideoCarouselOverlayHost.swift +283 -0
  8. package/ios/ShortKitBridge.swift +122 -20
  9. package/ios/ShortKitModule.mm +15 -5
  10. package/ios/ShortKitSDK.xcframework/Info.plist +5 -4
  11. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -0
  12. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +5 -1
  13. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +2488 -281
  14. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +65 -5
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +65 -5
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  19. package/ios/{ShortKitSDK.xcframework.bak/ios-arm64-simulator → ShortKitSDK.xcframework/ios-arm64_x86_64-simulator}/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +2 -1
  20. package/ios/ShortKitSDK.xcframework/{ios-arm64-simulator → ios-arm64_x86_64-simulator}/ShortKitSDK.framework/Info.plist +5 -1
  21. package/ios/{ShortKitSDK.xcframework.bak/ios-arm64-simulator → ShortKitSDK.xcframework/ios-arm64_x86_64-simulator}/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +8233 -3592
  22. package/ios/{ShortKitSDK.xcframework.bak/ios-arm64-simulator → ShortKitSDK.xcframework/ios-arm64_x86_64-simulator}/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +120 -19
  23. package/ios/{ShortKitSDK.xcframework.bak/ios-arm64-simulator → ShortKitSDK.xcframework/ios-arm64_x86_64-simulator}/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  24. package/ios/{ShortKitSDK.xcframework.bak/ios-arm64-simulator → ShortKitSDK.xcframework/ios-arm64_x86_64-simulator}/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +120 -19
  25. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +33558 -0
  26. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +925 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +925 -0
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +212 -0
  31. package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
  32. package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +7338 -3272
  33. package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +106 -15
  34. package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  35. package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +106 -15
  36. package/ios/ShortKitSDK.xcframework.dev-backup/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  37. package/ios/ShortKitSDK.xcframework.dev-backup/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  38. package/ios/{ShortKitSDK.xcframework → ShortKitSDK.xcframework.dev-backup}/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +1838 -206
  39. package/ios/{ShortKitSDK.xcframework → ShortKitSDK.xcframework.dev-backup}/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +51 -1
  40. package/ios/{ShortKitSDK.xcframework → ShortKitSDK.xcframework.dev-backup}/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  41. package/ios/{ShortKitSDK.xcframework → ShortKitSDK.xcframework.dev-backup}/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +51 -1
  42. package/ios/ShortKitSDK.xcframework.dev-backup/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  43. package/ios/ShortKitSDK.xcframework.dev-backup/ios-arm64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  44. package/package.json +1 -1
  45. package/src/ShortKitCarouselOverlaySurface.tsx +57 -2
  46. package/src/ShortKitContext.ts +11 -0
  47. package/src/ShortKitFeed.tsx +25 -10
  48. package/src/ShortKitOverlaySurface.tsx +24 -11
  49. package/src/ShortKitProvider.tsx +4 -2
  50. package/src/ShortKitVideoCarouselOverlaySurface.tsx +156 -0
  51. package/src/ShortKitWidget.tsx +3 -3
  52. package/src/index.ts +5 -1
  53. package/src/serialization.ts +7 -0
  54. package/src/specs/NativeShortKitModule.ts +18 -2
  55. package/src/types.ts +48 -3
  56. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  57. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  58. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  59. package/ios/ShortKitSDK.xcframework/{ios-arm64-simulator → ios-arm64_x86_64-simulator}/ShortKitSDK.framework/Modules/module.modulemap +0 -0
  60. package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/Info.plist +4 -4
  61. /package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64/ShortKitSDK.framework/Info.plist +0 -0
  62. /package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +0 -0
  63. /package/ios/{ShortKitSDK.xcframework → ShortKitSDK.xcframework.dev-backup}/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +0 -0
  64. /package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +0 -0
  65. /package/ios/{ShortKitSDK.xcframework.bak → ShortKitSDK.xcframework.dev-backup}/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +0 -0
@@ -46,7 +46,9 @@ import ShortKitSDK
46
46
  private var cachedPlaybackRate: Double = 1.0
47
47
  private var cachedCaptionsEnabled: Bool = false
48
48
  private var cachedActiveCue: [String: Any]? = nil
49
- private var cachedFeedScrollPhase: String? = nil
49
+ private var cachedActiveCueJson: String? = nil // pre-serialized for bridge
50
+ private var cachedFeedScrollPhase: String? = nil // already a JSON string
51
+ private var isDragging: Bool = false // true while scroll phase is .dragging
50
52
 
51
53
  // Time coalescing
52
54
  private var cachedTime: (current: Double, duration: Double, buffered: Double) = (0, 0, 0)
@@ -126,6 +128,7 @@ import ShortKitSDK
126
128
  }
127
129
 
128
130
  public func configure(with item: ContentItem) {
131
+ let isSameItem = item.id == currentItem?.id
129
132
  currentItem = item
130
133
  isActive = false
131
134
  timeDirty = false
@@ -136,14 +139,18 @@ import ShortKitSDK
136
139
  cachedTime = (0, 0, 0)
137
140
  cachedPlayerState = "idle"
138
141
  cachedActiveCue = nil
142
+ cachedActiveCueJson = nil
139
143
  cachedFeedScrollPhase = nil
144
+ isDragging = false
140
145
 
141
146
  createSurfaceIfNeeded()
142
147
 
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()
148
+ // Only push properties if the surface already exists.
149
+ // If the surface is still being created asynchronously,
150
+ // installSurface() will call pushInitialProperties() when ready.
151
+ if surface != nil && !isSameItem {
152
+ pushInitialProperties()
153
+ }
147
154
  }
148
155
 
149
156
  public func activatePlayback() {
@@ -162,30 +169,21 @@ import ShortKitSDK
162
169
  }
163
170
  }
164
171
 
165
- /// Emit all cached state as individual events. Called from activatePlayback()
166
- /// (deferred) and can be called whenever we need to synchronize JS state.
172
+ /// Emit all cached state as a single batched event. Called from
173
+ /// activatePlayback() (deferred) and on drag→settled transitions.
174
+ /// Uses pre-serialized JSON strings to avoid JSONSerialization on the
175
+ /// main thread during the swipe-settle critical path.
167
176
  private func emitFullState() {
168
- var body: [String: Any] = [
177
+ bridge?.emit("onOverlayFullState", body: [
169
178
  "surfaceId": surfaceId,
170
179
  "isActive": true,
171
180
  "playerState": cachedPlayerState,
172
181
  "isMuted": cachedIsMuted,
173
182
  "playbackRate": cachedPlaybackRate,
174
183
  "captionsEnabled": cachedCaptionsEnabled,
175
- ]
176
- if let cue = cachedActiveCue,
177
- let cueData = try? JSONSerialization.data(withJSONObject: cue),
178
- let cueJson = String(data: cueData, encoding: .utf8) {
179
- body["activeCue"] = cueJson
180
- } else {
181
- body["activeCue"] = NSNull()
182
- }
183
- if let scrollPhase = cachedFeedScrollPhase {
184
- body["feedScrollPhase"] = scrollPhase
185
- } else {
186
- body["feedScrollPhase"] = NSNull()
187
- }
188
- bridge?.emit("onOverlayFullState", body: body)
184
+ "activeCue": cachedActiveCueJson ?? NSNull(),
185
+ "feedScrollPhase": cachedFeedScrollPhase ?? NSNull(),
186
+ ])
189
187
  }
190
188
 
191
189
  // MARK: - Surface Creation
@@ -226,6 +224,15 @@ import ShortKitSDK
226
224
 
227
225
  // Push any pending properties now that the surface exists
228
226
  pushInitialProperties()
227
+
228
+ // If activatePlayback() was called before the surface was ready,
229
+ // emit the full state now that JS can receive events.
230
+ if isActive {
231
+ DispatchQueue.main.async { [weak self] in
232
+ guard let self, self.isActive else { return }
233
+ self.emitFullState()
234
+ }
235
+ }
229
236
  }
230
237
 
231
238
  // MARK: - Layout
@@ -243,17 +250,21 @@ import ShortKitSDK
243
250
  // MARK: - Player Subscriptions
244
251
 
245
252
  private func subscribeToPlayer(_ player: ShortKitPlayer) {
253
+ // Player state, muted, playbackRate, captionsEnabled, and activeCue
254
+ // are suppressed during .dragging to avoid unnecessary bridge traffic
255
+ // for an overlay that is scrolling off screen. emitFullState() re-syncs
256
+ // all cached values when the scroll settles (or on cancelled swipe).
257
+
246
258
  player.playerState
247
259
  .receive(on: DispatchQueue.main)
248
260
  .sink { [weak self] state in
249
261
  guard let self else { return }
250
262
  self.cachedPlayerState = Self.playerStateString(state)
251
- if self.isActive {
252
- self.bridge?.emit("onOverlayPlayerStateChanged", body: [
253
- "surfaceId": self.surfaceId,
254
- "playerState": self.cachedPlayerState
255
- ])
256
- }
263
+ guard self.isActive, !self.isDragging else { return }
264
+ self.bridge?.emit("onOverlayPlayerStateChanged", body: [
265
+ "surfaceId": self.surfaceId,
266
+ "playerState": self.cachedPlayerState
267
+ ])
257
268
  }
258
269
  .store(in: &cancellables)
259
270
 
@@ -262,12 +273,11 @@ import ShortKitSDK
262
273
  .sink { [weak self] muted in
263
274
  guard let self else { return }
264
275
  self.cachedIsMuted = muted
265
- if self.isActive {
266
- self.bridge?.emit("onOverlayMutedChanged", body: [
267
- "surfaceId": self.surfaceId,
268
- "isMuted": self.cachedIsMuted
269
- ])
270
- }
276
+ guard self.isActive, !self.isDragging else { return }
277
+ self.bridge?.emit("onOverlayMutedChanged", body: [
278
+ "surfaceId": self.surfaceId,
279
+ "isMuted": self.cachedIsMuted
280
+ ])
271
281
  }
272
282
  .store(in: &cancellables)
273
283
 
@@ -276,12 +286,11 @@ import ShortKitSDK
276
286
  .sink { [weak self] rate in
277
287
  guard let self else { return }
278
288
  self.cachedPlaybackRate = Double(rate)
279
- if self.isActive {
280
- self.bridge?.emit("onOverlayPlaybackRateChanged", body: [
281
- "surfaceId": self.surfaceId,
282
- "playbackRate": self.cachedPlaybackRate
283
- ])
284
- }
289
+ guard self.isActive, !self.isDragging else { return }
290
+ self.bridge?.emit("onOverlayPlaybackRateChanged", body: [
291
+ "surfaceId": self.surfaceId,
292
+ "playbackRate": self.cachedPlaybackRate
293
+ ])
285
294
  }
286
295
  .store(in: &cancellables)
287
296
 
@@ -290,12 +299,11 @@ import ShortKitSDK
290
299
  .sink { [weak self] enabled in
291
300
  guard let self else { return }
292
301
  self.cachedCaptionsEnabled = enabled
293
- if self.isActive {
294
- self.bridge?.emit("onOverlayCaptionsEnabledChanged", body: [
295
- "surfaceId": self.surfaceId,
296
- "captionsEnabled": self.cachedCaptionsEnabled
297
- ])
298
- }
302
+ guard self.isActive, !self.isDragging else { return }
303
+ self.bridge?.emit("onOverlayCaptionsEnabledChanged", body: [
304
+ "surfaceId": self.surfaceId,
305
+ "captionsEnabled": self.cachedCaptionsEnabled
306
+ ])
299
307
  }
300
308
  .store(in: &cancellables)
301
309
 
@@ -303,30 +311,26 @@ import ShortKitSDK
303
311
  .receive(on: DispatchQueue.main)
304
312
  .sink { [weak self] cue in
305
313
  guard let self else { return }
314
+ // Pre-serialize once; reused by emitFullState() and individual emission.
306
315
  if let cue {
307
316
  self.cachedActiveCue = [
308
317
  "text": cue.text,
309
318
  "startTime": cue.startTime,
310
319
  "endTime": cue.endTime,
311
320
  ]
312
- } else {
313
- self.cachedActiveCue = nil
314
- }
315
- if self.isActive {
316
- if let cached = self.cachedActiveCue,
317
- let data = try? JSONSerialization.data(withJSONObject: cached),
321
+ if let data = try? JSONSerialization.data(withJSONObject: self.cachedActiveCue!),
318
322
  let json = String(data: data, encoding: .utf8) {
319
- self.bridge?.emit("onOverlayActiveCueChanged", body: [
320
- "surfaceId": self.surfaceId,
321
- "activeCue": json
322
- ])
323
- } else {
324
- self.bridge?.emit("onOverlayActiveCueChanged", body: [
325
- "surfaceId": self.surfaceId,
326
- "activeCue": NSNull()
327
- ])
323
+ self.cachedActiveCueJson = json
328
324
  }
325
+ } else {
326
+ self.cachedActiveCue = nil
327
+ self.cachedActiveCueJson = nil
329
328
  }
329
+ guard self.isActive, !self.isDragging else { return }
330
+ self.bridge?.emit("onOverlayActiveCueChanged", body: [
331
+ "surfaceId": self.surfaceId,
332
+ "activeCue": self.cachedActiveCueJson ?? NSNull()
333
+ ])
330
334
  }
331
335
  .store(in: &cancellables)
332
336
 
@@ -334,18 +338,26 @@ import ShortKitSDK
334
338
  .receive(on: DispatchQueue.main)
335
339
  .sink { [weak self] phase in
336
340
  guard let self else { return }
341
+ // Pre-serialize once; reused by emitFullState() and individual emission.
337
342
  switch phase {
338
343
  case .dragging(let from):
339
- let dict: [String: Any] = ["phase": "dragging", "fromId": from]
340
- if let data = try? JSONSerialization.data(withJSONObject: dict),
344
+ self.isDragging = true
345
+ if let data = try? JSONSerialization.data(withJSONObject: ["phase": "dragging", "fromId": from]),
341
346
  let json = String(data: data, encoding: .utf8) {
342
347
  self.cachedFeedScrollPhase = json
343
348
  }
344
349
  case .settled:
345
- let dict: [String: Any] = ["phase": "settled"]
346
- if let data = try? JSONSerialization.data(withJSONObject: dict),
347
- let json = String(data: data, encoding: .utf8) {
348
- self.cachedFeedScrollPhase = json
350
+ let wasDragging = self.isDragging
351
+ self.isDragging = false
352
+ self.cachedFeedScrollPhase = "{\"phase\":\"settled\"}"
353
+
354
+ // Re-sync all state that was suppressed during the drag.
355
+ // Handles both normal swipes (new item's activatePlayback
356
+ // will also fire) and cancelled swipes (same item, no
357
+ // activatePlayback — this is the only re-sync path).
358
+ if wasDragging, self.isActive {
359
+ self.emitFullState()
360
+ return // fullState includes feedScrollPhase
349
361
  }
350
362
  }
351
363
  if self.isActive {
@@ -360,7 +372,7 @@ import ShortKitSDK
360
372
  player.time
361
373
  .receive(on: DispatchQueue.main)
362
374
  .sink { [weak self] time in
363
- guard let self, self.isActive else { return }
375
+ guard let self, self.isActive, !self.isDragging else { return }
364
376
  self.cachedTime = (time.current, time.duration, time.buffered)
365
377
  self.timeDirty = true
366
378
  }
@@ -404,17 +416,15 @@ import ShortKitSDK
404
416
  props["item"] = json
405
417
  }
406
418
 
407
- // Initial valuesmay be stale by the time activatePlayback() fires,
408
- // which is why activatePlayback() emits a full state flush via events.
409
- props["isActive"] = false
410
- 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
411
423
  props["isMuted"] = cachedIsMuted
412
424
  props["playbackRate"] = cachedPlaybackRate
413
425
  props["captionsEnabled"] = cachedCaptionsEnabled
414
426
 
415
- if let cue = cachedActiveCue,
416
- let cueData = try? JSONSerialization.data(withJSONObject: cue),
417
- let cueJson = String(data: cueData, encoding: .utf8) {
427
+ if let cueJson = cachedActiveCueJson {
418
428
  props["activeCue"] = cueJson
419
429
  }
420
430
 
@@ -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
+ }