@shortkitsdk/react-native 0.2.6 → 0.2.11

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 (75) hide show
  1. package/ShortKitReactNative.podspec +1 -0
  2. package/android/build.gradle.kts +5 -1
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +319 -0
  4. package/android/src/main/java/com/shortkit/reactnative/ReactLoadingHost.kt +40 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +559 -0
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +984 -0
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +88 -220
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +12 -3
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +123 -741
  10. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +2 -2
  11. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +2 -2
  12. package/ios/ReactCarouselOverlayHost.swift +177 -0
  13. package/ios/ReactLoadingHost.swift +38 -0
  14. package/ios/ReactOverlayHost.swift +458 -0
  15. package/ios/SKFabricSurfaceWrapper.h +18 -0
  16. package/ios/SKFabricSurfaceWrapper.mm +57 -0
  17. package/ios/ShortKitBridge.swift +186 -63
  18. package/ios/ShortKitFeedView.swift +62 -229
  19. package/ios/ShortKitFeedViewManager.mm +3 -2
  20. package/ios/ShortKitModule.mm +66 -37
  21. package/ios/ShortKitPlayerNativeView.swift +39 -8
  22. package/ios/ShortKitReactNative-Bridging-Header.h +2 -0
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
  24. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +2380 -522
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +39 -12
  26. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +39 -12
  28. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  29. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
  30. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +2380 -522
  31. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +39 -12
  32. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  33. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +39 -12
  34. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  35. package/ios/ShortKitSDK.xcframework.bak/Info.plist +43 -0
  36. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  37. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
  38. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +28917 -0
  39. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +824 -0
  40. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  41. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +824 -0
  42. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  43. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  44. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  45. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
  46. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +28917 -0
  47. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +824 -0
  48. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  49. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +824 -0
  50. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  51. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  52. package/ios/ShortKitWidgetNativeView.swift +3 -3
  53. package/package.json +1 -1
  54. package/src/ShortKitCarouselOverlaySurface.tsx +55 -0
  55. package/src/ShortKitCommands.ts +31 -0
  56. package/src/ShortKitContext.ts +6 -25
  57. package/src/ShortKitFeed.tsx +110 -41
  58. package/src/ShortKitLoadingSurface.tsx +24 -0
  59. package/src/ShortKitOverlaySurface.tsx +205 -0
  60. package/src/ShortKitPlayer.tsx +6 -7
  61. package/src/ShortKitProvider.tsx +27 -286
  62. package/src/index.ts +5 -3
  63. package/src/serialization.ts +19 -39
  64. package/src/specs/NativeShortKitModule.ts +58 -46
  65. package/src/specs/ShortKitFeedViewNativeComponent.ts +3 -2
  66. package/src/types.ts +78 -16
  67. package/src/useShortKit.ts +1 -3
  68. package/src/useShortKitPlayer.ts +7 -7
  69. package/android/src/main/java/com/shortkit/reactnative/ShortKitCarouselOverlayBridge.kt +0 -48
  70. package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +0 -128
  71. package/ios/ShortKitCarouselOverlayBridge.swift +0 -219
  72. package/ios/ShortKitOverlayBridge.swift +0 -111
  73. package/src/CarouselOverlayManager.tsx +0 -70
  74. package/src/OverlayManager.tsx +0 -87
  75. package/src/useShortKitCarousel.ts +0 -29
@@ -0,0 +1,458 @@
1
+ import UIKit
2
+ import Combine
3
+ import ShortKitSDK
4
+
5
+ /// A UIView that conforms to `FeedOverlay` and hosts an `RCTFabricSurface`
6
+ /// for rendering the developer's React overlay component inside a feed cell.
7
+ ///
8
+ /// Each cell gets its own instance (via the overlay factory). The surface is
9
+ /// created lazily on the first `configure(with:)` call and reused across cell
10
+ /// reuse cycles.
11
+ ///
12
+ /// Surface properties are set ONCE per item (in configure()) to provide initial
13
+ /// values. All subsequent dynamic state changes flow through native module events
14
+ /// routed by surfaceId, triggering React re-renders instead of Fabric remounts.
15
+ @objc public class ReactOverlayHost: UIView, @unchecked Sendable, FeedOverlay {
16
+
17
+ // MARK: - Configuration
18
+
19
+ /// Surface presenter for creating RCTFabricSurface instances.
20
+ /// Set by the factory closure from ShortKitBridge.shared.
21
+ var surfacePresenter: AnyObject? // RCTSurfacePresenter
22
+
23
+ /// Module name for the RCTFabricSurface. Set by the overlay factory
24
+ /// based on the feed config's overlay name (e.g. "ShortKitOverlay_news").
25
+ var overlayModuleName: String = "ShortKitOverlay"
26
+
27
+ /// Unique identifier for this overlay instance, used for event routing.
28
+ /// Generated once at init, stable across cell reuse.
29
+ let surfaceId = UUID().uuidString
30
+
31
+ /// Bridge reference for emitting events. Set by the overlay factory.
32
+ weak var bridge: ShortKitBridge?
33
+
34
+ // MARK: - State
35
+
36
+ private var surface: SKFabricSurfaceWrapper?
37
+ private var surfaceView: UIView?
38
+ private var player: ShortKitPlayer?
39
+ private var cancellables = Set<AnyCancellable>()
40
+ private var currentItem: ContentItem?
41
+ private var isActive = false
42
+
43
+ // Player state cache
44
+ private var cachedPlayerState: String = "idle"
45
+ private var cachedIsMuted: Bool = true
46
+ private var cachedPlaybackRate: Double = 1.0
47
+ private var cachedCaptionsEnabled: Bool = false
48
+ private var cachedActiveCue: [String: Any]? = nil
49
+ private var cachedFeedScrollPhase: String? = nil
50
+
51
+ // Time coalescing
52
+ private var cachedTime: (current: Double, duration: Double, buffered: Double) = (0, 0, 0)
53
+ private var timeCoalesceTimer: Timer?
54
+ private var timeDirty = false
55
+
56
+ // MARK: - Init
57
+
58
+ /// Height of the scrubber touch area at the bottom of the overlay.
59
+ /// Matches the scrubberTouchArea height in NewsOverlay.tsx.
60
+ private let scrubberTouchHeight: CGFloat = 40
61
+
62
+ override init(frame: CGRect) {
63
+ super.init(frame: frame)
64
+ backgroundColor = .clear
65
+ isUserInteractionEnabled = true
66
+ setupScrubberGestureGuard()
67
+ }
68
+
69
+ required init?(coder: NSCoder) {
70
+ fatalError("init(coder:) is not supported")
71
+ }
72
+
73
+ // MARK: - Scrubber Gesture Guard
74
+
75
+ /// Disables the parent scroll view's gesture recognizer momentarily when
76
+ /// a touch begins in the scrubber region. This prevents the feed's
77
+ /// UICollectionView from stealing the scrub gesture. The recognizer is
78
+ /// re-enabled after a short delay — once it misses the initial touch
79
+ /// dispatch, it cannot retroactively claim the gesture.
80
+ ///
81
+ /// This approach is fully transparent to RN's touch system: no gesture
82
+ /// recognizers are added, and no touch events are consumed.
83
+ private func setupScrubberGestureGuard() {
84
+ // No setup needed — handled in hitTest override
85
+ }
86
+
87
+ public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
88
+ let hit = super.hitTest(point, with: event)
89
+ if hit != nil && point.y >= bounds.height - scrubberTouchHeight {
90
+ if let scrollView = findParentScrollView() {
91
+ // Briefly disable the scroll view's pan recognizer so it doesn't
92
+ // claim this touch. Re-enable on the next run loop — by then RN's
93
+ // PanResponder owns the gesture and the scroll view can't steal it.
94
+ scrollView.panGestureRecognizer.isEnabled = false
95
+ DispatchQueue.main.async {
96
+ scrollView.panGestureRecognizer.isEnabled = true
97
+ }
98
+ }
99
+ }
100
+ return hit
101
+ }
102
+
103
+ private func findParentScrollView() -> UIScrollView? {
104
+ var view: UIView? = superview
105
+ while let v = view {
106
+ if let scrollView = v as? UIScrollView { return scrollView }
107
+ view = v.superview
108
+ }
109
+ return nil
110
+ }
111
+
112
+ // MARK: - Cleanup
113
+
114
+ deinit {
115
+ timeCoalesceTimer?.invalidate()
116
+ surface?.stop()
117
+ }
118
+
119
+ // MARK: - FeedOverlay
120
+
121
+ public func attach(player: ShortKitPlayer) {
122
+ self.player = player
123
+ subscribeToPlayer(player)
124
+ // Eagerly create the surface so it's ready before the first configure.
125
+ createSurfaceIfNeeded()
126
+ }
127
+
128
+ public func configure(with item: ContentItem) {
129
+ currentItem = item
130
+ isActive = false
131
+ timeDirty = false
132
+ timeCoalesceTimer?.invalidate()
133
+ timeCoalesceTimer = nil
134
+
135
+ // Reset cached state so recycled cells don't flash stale values
136
+ cachedTime = (0, 0, 0)
137
+ cachedPlayerState = "idle"
138
+ cachedActiveCue = nil
139
+ cachedFeedScrollPhase = nil
140
+
141
+ createSurfaceIfNeeded()
142
+
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()
147
+ }
148
+
149
+ public func activatePlayback() {
150
+ isActive = true
151
+ startTimeCoalescing()
152
+
153
+ // Defer the event burst to the next tick. The JS surface needs time to
154
+ // mount and establish event subscriptions (useEffect runs after render).
155
+ // Emitting before subscriptions are established crashes the codegen's
156
+ // AsyncEventEmitter (null shared_ptr). Initial values come from surface
157
+ // properties, so the one-tick delay only affects values that changed
158
+ // between configure() and activatePlayback().
159
+ DispatchQueue.main.async { [weak self] in
160
+ guard let self, self.isActive else { return }
161
+ self.emitFullState()
162
+ }
163
+ }
164
+
165
+ /// Emit all cached state as individual events. Called from activatePlayback()
166
+ /// (deferred) and can be called whenever we need to synchronize JS state.
167
+ 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
+ ])
183
+ if let cue = cachedActiveCue,
184
+ let cueData = try? JSONSerialization.data(withJSONObject: cue),
185
+ let cueJson = String(data: cueData, encoding: .utf8) {
186
+ bridge?.emit("onOverlayActiveCueChanged", body: [
187
+ "surfaceId": surfaceId, "activeCue": cueJson
188
+ ])
189
+ } else {
190
+ bridge?.emit("onOverlayActiveCueChanged", body: [
191
+ "surfaceId": surfaceId, "activeCue": NSNull()
192
+ ])
193
+ }
194
+ if let scrollPhase = cachedFeedScrollPhase {
195
+ bridge?.emit("onOverlayFeedScrollPhaseChanged", body: [
196
+ "surfaceId": surfaceId, "feedScrollPhase": scrollPhase
197
+ ])
198
+ } else {
199
+ bridge?.emit("onOverlayFeedScrollPhaseChanged", body: [
200
+ "surfaceId": surfaceId, "feedScrollPhase": NSNull()
201
+ ])
202
+ }
203
+ }
204
+
205
+ // MARK: - Surface Creation
206
+
207
+ private func createSurfaceIfNeeded() {
208
+ guard surface == nil, let presenter = surfacePresenter else { return }
209
+
210
+ // RCTFabricSurface.view requires the main *dispatch queue* (checked via
211
+ // dispatch_get_specific), but UICollectionViewDiffableDataSource calls
212
+ // cell providers on "com.apple.uikit.datasource.diffing" — which runs
213
+ // on the main thread but is NOT the main dispatch queue. Always dispatch
214
+ // to main queue to satisfy RCTAssertMainQueue().
215
+ DispatchQueue.main.async { [weak self] in
216
+ guard let self, self.surface == nil else { return }
217
+ self.installSurface(presenter: presenter)
218
+ }
219
+ }
220
+
221
+ private func installSurface(presenter: AnyObject) {
222
+ guard let surf = SKFabricSurfaceWrapper(
223
+ presenter: presenter,
224
+ moduleName: overlayModuleName,
225
+ initialProperties: [:]
226
+ ) else { return }
227
+ surf.start()
228
+
229
+ let view = surf.view
230
+ view.translatesAutoresizingMaskIntoConstraints = false
231
+ addSubview(view)
232
+ NSLayoutConstraint.activate([
233
+ view.topAnchor.constraint(equalTo: topAnchor),
234
+ view.leadingAnchor.constraint(equalTo: leadingAnchor),
235
+ view.trailingAnchor.constraint(equalTo: trailingAnchor),
236
+ view.bottomAnchor.constraint(equalTo: bottomAnchor),
237
+ ])
238
+ surfaceView = view
239
+ surface = surf
240
+
241
+ // Push any pending properties now that the surface exists
242
+ pushInitialProperties()
243
+ }
244
+
245
+ // MARK: - Layout
246
+
247
+ public override func layoutSubviews() {
248
+ super.layoutSubviews()
249
+ guard let surface else { return }
250
+ let size = bounds.size
251
+ guard size.width > 0, size.height > 0 else { return }
252
+
253
+ surface.setMinimumSize(size)
254
+ surface.setMaximumSize(size)
255
+ }
256
+
257
+ // MARK: - Player Subscriptions
258
+
259
+ private func subscribeToPlayer(_ player: ShortKitPlayer) {
260
+ player.playerState
261
+ .receive(on: DispatchQueue.main)
262
+ .sink { [weak self] state in
263
+ guard let self else { return }
264
+ self.cachedPlayerState = Self.playerStateString(state)
265
+ if self.isActive {
266
+ self.bridge?.emit("onOverlayPlayerStateChanged", body: [
267
+ "surfaceId": self.surfaceId,
268
+ "playerState": self.cachedPlayerState
269
+ ])
270
+ }
271
+ }
272
+ .store(in: &cancellables)
273
+
274
+ player.isMuted
275
+ .receive(on: DispatchQueue.main)
276
+ .sink { [weak self] muted in
277
+ guard let self else { return }
278
+ self.cachedIsMuted = muted
279
+ if self.isActive {
280
+ self.bridge?.emit("onOverlayMutedChanged", body: [
281
+ "surfaceId": self.surfaceId,
282
+ "isMuted": self.cachedIsMuted
283
+ ])
284
+ }
285
+ }
286
+ .store(in: &cancellables)
287
+
288
+ player.playbackRate
289
+ .receive(on: DispatchQueue.main)
290
+ .sink { [weak self] rate in
291
+ guard let self else { return }
292
+ self.cachedPlaybackRate = Double(rate)
293
+ if self.isActive {
294
+ self.bridge?.emit("onOverlayPlaybackRateChanged", body: [
295
+ "surfaceId": self.surfaceId,
296
+ "playbackRate": self.cachedPlaybackRate
297
+ ])
298
+ }
299
+ }
300
+ .store(in: &cancellables)
301
+
302
+ player.captionsEnabled
303
+ .receive(on: DispatchQueue.main)
304
+ .sink { [weak self] enabled in
305
+ guard let self else { return }
306
+ self.cachedCaptionsEnabled = enabled
307
+ if self.isActive {
308
+ self.bridge?.emit("onOverlayCaptionsEnabledChanged", body: [
309
+ "surfaceId": self.surfaceId,
310
+ "captionsEnabled": self.cachedCaptionsEnabled
311
+ ])
312
+ }
313
+ }
314
+ .store(in: &cancellables)
315
+
316
+ player.activeCue
317
+ .receive(on: DispatchQueue.main)
318
+ .sink { [weak self] cue in
319
+ guard let self else { return }
320
+ if let cue {
321
+ self.cachedActiveCue = [
322
+ "text": cue.text,
323
+ "startTime": cue.startTime,
324
+ "endTime": cue.endTime,
325
+ ]
326
+ } else {
327
+ self.cachedActiveCue = nil
328
+ }
329
+ if self.isActive {
330
+ if let cached = self.cachedActiveCue,
331
+ let data = try? JSONSerialization.data(withJSONObject: cached),
332
+ let json = String(data: data, encoding: .utf8) {
333
+ self.bridge?.emit("onOverlayActiveCueChanged", body: [
334
+ "surfaceId": self.surfaceId,
335
+ "activeCue": json
336
+ ])
337
+ } else {
338
+ self.bridge?.emit("onOverlayActiveCueChanged", body: [
339
+ "surfaceId": self.surfaceId,
340
+ "activeCue": NSNull()
341
+ ])
342
+ }
343
+ }
344
+ }
345
+ .store(in: &cancellables)
346
+
347
+ player.feedScrollPhase
348
+ .receive(on: DispatchQueue.main)
349
+ .sink { [weak self] phase in
350
+ guard let self else { return }
351
+ switch phase {
352
+ case .dragging(let from):
353
+ let dict: [String: Any] = ["phase": "dragging", "fromId": from]
354
+ if let data = try? JSONSerialization.data(withJSONObject: dict),
355
+ let json = String(data: data, encoding: .utf8) {
356
+ self.cachedFeedScrollPhase = json
357
+ }
358
+ case .settled:
359
+ let dict: [String: Any] = ["phase": "settled"]
360
+ if let data = try? JSONSerialization.data(withJSONObject: dict),
361
+ let json = String(data: data, encoding: .utf8) {
362
+ self.cachedFeedScrollPhase = json
363
+ }
364
+ }
365
+ if self.isActive {
366
+ self.bridge?.emit("onOverlayFeedScrollPhaseChanged", body: [
367
+ "surfaceId": self.surfaceId,
368
+ "feedScrollPhase": self.cachedFeedScrollPhase ?? NSNull()
369
+ ])
370
+ }
371
+ }
372
+ .store(in: &cancellables)
373
+
374
+ player.time
375
+ .receive(on: DispatchQueue.main)
376
+ .sink { [weak self] time in
377
+ guard let self, self.isActive else { return }
378
+ self.cachedTime = (time.current, time.duration, time.buffered)
379
+ self.timeDirty = true
380
+ }
381
+ .store(in: &cancellables)
382
+ }
383
+
384
+ // MARK: - Time Coalescing
385
+
386
+ private func startTimeCoalescing() {
387
+ timeCoalesceTimer?.invalidate()
388
+ timeCoalesceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
389
+ guard let self, self.timeDirty else { return }
390
+ self.timeDirty = false
391
+ self.bridge?.emit("onOverlayTimeUpdate", body: [
392
+ "surfaceId": self.surfaceId,
393
+ "current": self.cachedTime.current,
394
+ "duration": self.cachedTime.duration,
395
+ "buffered": self.cachedTime.buffered
396
+ ])
397
+ }
398
+ }
399
+
400
+ // MARK: - Initial Properties
401
+
402
+ /// Push initial properties to the surface. Called once per item in configure()
403
+ /// and once in installSurface() (for async surface creation).
404
+ /// This is the ONLY place setProperties() is called — all subsequent updates
405
+ /// use event emission to avoid Fabric remounts.
406
+ private func pushInitialProperties() {
407
+ guard let surface,
408
+ let item = currentItem else { return }
409
+
410
+ var props: [String: Any] = [:]
411
+
412
+ // Surface ID for event routing (stable for life of this host)
413
+ props["surfaceId"] = surfaceId
414
+
415
+ // Serialize ContentItem as JSON string
416
+ if let data = try? JSONEncoder().encode(item),
417
+ let json = String(data: data, encoding: .utf8) {
418
+ props["item"] = json
419
+ }
420
+
421
+ // Initial values — may 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"
425
+ props["isMuted"] = cachedIsMuted
426
+ props["playbackRate"] = cachedPlaybackRate
427
+ props["captionsEnabled"] = cachedCaptionsEnabled
428
+
429
+ if let cue = cachedActiveCue,
430
+ let cueData = try? JSONSerialization.data(withJSONObject: cue),
431
+ let cueJson = String(data: cueData, encoding: .utf8) {
432
+ props["activeCue"] = cueJson
433
+ }
434
+
435
+ if let scrollPhase = cachedFeedScrollPhase {
436
+ props["feedScrollPhase"] = scrollPhase
437
+ }
438
+
439
+ surface.setProperties(props as [AnyHashable: Any])
440
+ }
441
+
442
+ // MARK: - Helpers
443
+
444
+ private static func playerStateString(_ state: PlayerState) -> String {
445
+ switch state {
446
+ case .idle: return "idle"
447
+ case .loading: return "loading"
448
+ case .ready: return "ready"
449
+ case .playing: return "playing"
450
+ case .paused: return "paused"
451
+ case .seeking: return "seeking"
452
+ case .buffering: return "buffering"
453
+ case .ended: return "ended"
454
+ case .error(_): return "error"
455
+ }
456
+ }
457
+ }
458
+
@@ -0,0 +1,18 @@
1
+ #import <UIKit/UIKit.h>
2
+
3
+ /// ObjC wrapper around RCTFabricSurface to avoid exposing C++ headers to Swift.
4
+ @interface SKFabricSurfaceWrapper : NSObject
5
+
6
+ - (nullable instancetype)initWithPresenter:(nonnull id)surfacePresenter
7
+ moduleName:(nonnull NSString *)moduleName
8
+ initialProperties:(nonnull NSDictionary *)properties;
9
+
10
+ @property (nonatomic, readonly, nonnull) UIView *view;
11
+
12
+ - (void)start;
13
+ - (void)stop;
14
+ - (void)setProperties:(nonnull NSDictionary *)properties;
15
+ - (void)setMinimumSize:(CGSize)size;
16
+ - (void)setMaximumSize:(CGSize)size;
17
+
18
+ @end
@@ -0,0 +1,57 @@
1
+ #import "SKFabricSurfaceWrapper.h"
2
+ #import <React-RCTFabric/React/RCTFabricSurface.h>
3
+ #import <React-RCTFabric/React/RCTSurfacePresenter.h>
4
+
5
+ @implementation SKFabricSurfaceWrapper {
6
+ RCTFabricSurface *_surface;
7
+ NSDictionary *_lastProperties;
8
+ }
9
+
10
+ - (nullable instancetype)initWithPresenter:(nonnull id)surfacePresenter
11
+ moduleName:(nonnull NSString *)moduleName
12
+ initialProperties:(nonnull NSDictionary *)properties {
13
+ if (![surfacePresenter isKindOfClass:[RCTSurfacePresenter class]]) {
14
+ return nil;
15
+ }
16
+ self = [super init];
17
+ if (self) {
18
+ _surface = [[RCTFabricSurface alloc] initWithSurfacePresenter:(RCTSurfacePresenter *)surfacePresenter
19
+ moduleName:moduleName
20
+ initialProperties:properties];
21
+ }
22
+ return self;
23
+ }
24
+
25
+ - (UIView *)view {
26
+ return (UIView *)[_surface view];
27
+ }
28
+
29
+ - (void)start {
30
+ [_surface start];
31
+ }
32
+
33
+ - (void)stop {
34
+ [_surface stop];
35
+ }
36
+
37
+ - (void)setProperties:(NSDictionary *)properties {
38
+ // Only push to the surface if properties actually changed.
39
+ // RCTFabricSurface.properties setter triggers a full root remount,
40
+ // so we must avoid setting identical properties.
41
+ if ([_lastProperties isEqualToDictionary:properties]) {
42
+ return;
43
+ }
44
+ _lastProperties = [properties copy];
45
+ _surface.properties = properties;
46
+ }
47
+
48
+ - (void)setMinimumSize:(CGSize)size {
49
+ // RCTFabricSurface min/max are readonly; use setSize: to set both.
50
+ [_surface setSize:size];
51
+ }
52
+
53
+ - (void)setMaximumSize:(CGSize)size {
54
+ // Already set via setMinimumSize → setSize:. No-op here.
55
+ }
56
+
57
+ @end