@shortkitsdk/react-native 0.2.6 → 0.2.12

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 +17 -1
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +379 -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 +570 -0
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +1029 -0
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +212 -219
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +17 -3
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +157 -742
  10. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +11 -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 +444 -0
  15. package/ios/SKFabricSurfaceWrapper.h +18 -0
  16. package/ios/SKFabricSurfaceWrapper.mm +57 -0
  17. package/ios/ShortKitBridge.swift +220 -63
  18. package/ios/ShortKitFeedView.swift +82 -228
  19. package/ios/ShortKitFeedViewManager.mm +3 -2
  20. package/ios/ShortKitModule.mm +69 -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 +3683 -1249
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +56 -15
  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 +56 -15
  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 +3683 -1249
  31. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +56 -15
  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 +56 -15
  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 -24
  57. package/src/ShortKitFeed.tsx +124 -41
  58. package/src/ShortKitLoadingSurface.tsx +24 -0
  59. package/src/ShortKitOverlaySurface.tsx +313 -0
  60. package/src/ShortKitPlayer.tsx +30 -9
  61. package/src/ShortKitProvider.tsx +28 -285
  62. package/src/index.ts +9 -3
  63. package/src/serialization.ts +20 -39
  64. package/src/specs/NativeShortKitModule.ts +74 -45
  65. package/src/specs/ShortKitFeedViewNativeComponent.ts +3 -2
  66. package/src/types.ts +84 -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,444 @@
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
+ var body: [String: Any] = [
169
+ "surfaceId": surfaceId,
170
+ "isActive": true,
171
+ "playerState": cachedPlayerState,
172
+ "isMuted": cachedIsMuted,
173
+ "playbackRate": cachedPlaybackRate,
174
+ "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)
189
+ }
190
+
191
+ // MARK: - Surface Creation
192
+
193
+ private func createSurfaceIfNeeded() {
194
+ guard surface == nil, let presenter = surfacePresenter else { return }
195
+
196
+ // RCTFabricSurface.view requires the main *dispatch queue* (checked via
197
+ // dispatch_get_specific), but UICollectionViewDiffableDataSource calls
198
+ // cell providers on "com.apple.uikit.datasource.diffing" — which runs
199
+ // on the main thread but is NOT the main dispatch queue. Always dispatch
200
+ // to main queue to satisfy RCTAssertMainQueue().
201
+ DispatchQueue.main.async { [weak self] in
202
+ guard let self, self.surface == nil else { return }
203
+ self.installSurface(presenter: presenter)
204
+ }
205
+ }
206
+
207
+ private func installSurface(presenter: AnyObject) {
208
+ guard let surf = SKFabricSurfaceWrapper(
209
+ presenter: presenter,
210
+ moduleName: overlayModuleName,
211
+ initialProperties: [:]
212
+ ) else { return }
213
+ surf.start()
214
+
215
+ let view = surf.view
216
+ view.translatesAutoresizingMaskIntoConstraints = false
217
+ addSubview(view)
218
+ NSLayoutConstraint.activate([
219
+ view.topAnchor.constraint(equalTo: topAnchor),
220
+ view.leadingAnchor.constraint(equalTo: leadingAnchor),
221
+ view.trailingAnchor.constraint(equalTo: trailingAnchor),
222
+ view.bottomAnchor.constraint(equalTo: bottomAnchor),
223
+ ])
224
+ surfaceView = view
225
+ surface = surf
226
+
227
+ // Push any pending properties now that the surface exists
228
+ pushInitialProperties()
229
+ }
230
+
231
+ // MARK: - Layout
232
+
233
+ public override func layoutSubviews() {
234
+ super.layoutSubviews()
235
+ guard let surface else { return }
236
+ let size = bounds.size
237
+ guard size.width > 0, size.height > 0 else { return }
238
+
239
+ surface.setMinimumSize(size)
240
+ surface.setMaximumSize(size)
241
+ }
242
+
243
+ // MARK: - Player Subscriptions
244
+
245
+ private func subscribeToPlayer(_ player: ShortKitPlayer) {
246
+ player.playerState
247
+ .receive(on: DispatchQueue.main)
248
+ .sink { [weak self] state in
249
+ guard let self else { return }
250
+ 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
+ }
257
+ }
258
+ .store(in: &cancellables)
259
+
260
+ player.isMuted
261
+ .receive(on: DispatchQueue.main)
262
+ .sink { [weak self] muted in
263
+ guard let self else { return }
264
+ self.cachedIsMuted = muted
265
+ if self.isActive {
266
+ self.bridge?.emit("onOverlayMutedChanged", body: [
267
+ "surfaceId": self.surfaceId,
268
+ "isMuted": self.cachedIsMuted
269
+ ])
270
+ }
271
+ }
272
+ .store(in: &cancellables)
273
+
274
+ player.playbackRate
275
+ .receive(on: DispatchQueue.main)
276
+ .sink { [weak self] rate in
277
+ guard let self else { return }
278
+ self.cachedPlaybackRate = Double(rate)
279
+ if self.isActive {
280
+ self.bridge?.emit("onOverlayPlaybackRateChanged", body: [
281
+ "surfaceId": self.surfaceId,
282
+ "playbackRate": self.cachedPlaybackRate
283
+ ])
284
+ }
285
+ }
286
+ .store(in: &cancellables)
287
+
288
+ player.captionsEnabled
289
+ .receive(on: DispatchQueue.main)
290
+ .sink { [weak self] enabled in
291
+ guard let self else { return }
292
+ self.cachedCaptionsEnabled = enabled
293
+ if self.isActive {
294
+ self.bridge?.emit("onOverlayCaptionsEnabledChanged", body: [
295
+ "surfaceId": self.surfaceId,
296
+ "captionsEnabled": self.cachedCaptionsEnabled
297
+ ])
298
+ }
299
+ }
300
+ .store(in: &cancellables)
301
+
302
+ player.activeCue
303
+ .receive(on: DispatchQueue.main)
304
+ .sink { [weak self] cue in
305
+ guard let self else { return }
306
+ if let cue {
307
+ self.cachedActiveCue = [
308
+ "text": cue.text,
309
+ "startTime": cue.startTime,
310
+ "endTime": cue.endTime,
311
+ ]
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),
318
+ 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
+ ])
328
+ }
329
+ }
330
+ }
331
+ .store(in: &cancellables)
332
+
333
+ player.feedScrollPhase
334
+ .receive(on: DispatchQueue.main)
335
+ .sink { [weak self] phase in
336
+ guard let self else { return }
337
+ switch phase {
338
+ case .dragging(let from):
339
+ let dict: [String: Any] = ["phase": "dragging", "fromId": from]
340
+ if let data = try? JSONSerialization.data(withJSONObject: dict),
341
+ let json = String(data: data, encoding: .utf8) {
342
+ self.cachedFeedScrollPhase = json
343
+ }
344
+ 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
349
+ }
350
+ }
351
+ if self.isActive {
352
+ self.bridge?.emit("onOverlayFeedScrollPhaseChanged", body: [
353
+ "surfaceId": self.surfaceId,
354
+ "feedScrollPhase": self.cachedFeedScrollPhase ?? NSNull()
355
+ ])
356
+ }
357
+ }
358
+ .store(in: &cancellables)
359
+
360
+ player.time
361
+ .receive(on: DispatchQueue.main)
362
+ .sink { [weak self] time in
363
+ guard let self, self.isActive else { return }
364
+ self.cachedTime = (time.current, time.duration, time.buffered)
365
+ self.timeDirty = true
366
+ }
367
+ .store(in: &cancellables)
368
+ }
369
+
370
+ // MARK: - Time Coalescing
371
+
372
+ private func startTimeCoalescing() {
373
+ timeCoalesceTimer?.invalidate()
374
+ timeCoalesceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
375
+ guard let self, self.timeDirty else { return }
376
+ self.timeDirty = false
377
+ self.bridge?.emit("onOverlayTimeUpdate", body: [
378
+ "surfaceId": self.surfaceId,
379
+ "current": self.cachedTime.current,
380
+ "duration": self.cachedTime.duration,
381
+ "buffered": self.cachedTime.buffered
382
+ ])
383
+ }
384
+ }
385
+
386
+ // MARK: - Initial Properties
387
+
388
+ /// Push initial properties to the surface. Called once per item in configure()
389
+ /// and once in installSurface() (for async surface creation).
390
+ /// This is the ONLY place setProperties() is called — all subsequent updates
391
+ /// use event emission to avoid Fabric remounts.
392
+ private func pushInitialProperties() {
393
+ guard let surface,
394
+ let item = currentItem else { return }
395
+
396
+ var props: [String: Any] = [:]
397
+
398
+ // Surface ID for event routing (stable for life of this host)
399
+ props["surfaceId"] = surfaceId
400
+
401
+ // Serialize ContentItem as JSON string
402
+ if let data = try? JSONEncoder().encode(item),
403
+ let json = String(data: data, encoding: .utf8) {
404
+ props["item"] = json
405
+ }
406
+
407
+ // Initial values — may 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"
411
+ props["isMuted"] = cachedIsMuted
412
+ props["playbackRate"] = cachedPlaybackRate
413
+ props["captionsEnabled"] = cachedCaptionsEnabled
414
+
415
+ if let cue = cachedActiveCue,
416
+ let cueData = try? JSONSerialization.data(withJSONObject: cue),
417
+ let cueJson = String(data: cueData, encoding: .utf8) {
418
+ props["activeCue"] = cueJson
419
+ }
420
+
421
+ if let scrollPhase = cachedFeedScrollPhase {
422
+ props["feedScrollPhase"] = scrollPhase
423
+ }
424
+
425
+ surface.setProperties(props as [AnyHashable: Any])
426
+ }
427
+
428
+ // MARK: - Helpers
429
+
430
+ private static func playerStateString(_ state: PlayerState) -> String {
431
+ switch state {
432
+ case .idle: return "idle"
433
+ case .loading: return "loading"
434
+ case .ready: return "ready"
435
+ case .playing: return "playing"
436
+ case .paused: return "paused"
437
+ case .seeking: return "seeking"
438
+ case .buffering: return "buffering"
439
+ case .ended: return "ended"
440
+ case .error(_): return "error"
441
+ }
442
+ }
443
+ }
444
+
@@ -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