@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.
- package/ShortKitReactNative.podspec +1 -0
- package/android/build.gradle.kts +5 -1
- package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +319 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactLoadingHost.kt +40 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +559 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +984 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +88 -220
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +12 -3
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +123 -741
- package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +2 -2
- package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +2 -2
- package/ios/ReactCarouselOverlayHost.swift +177 -0
- package/ios/ReactLoadingHost.swift +38 -0
- package/ios/ReactOverlayHost.swift +458 -0
- package/ios/SKFabricSurfaceWrapper.h +18 -0
- package/ios/SKFabricSurfaceWrapper.mm +57 -0
- package/ios/ShortKitBridge.swift +186 -63
- package/ios/ShortKitFeedView.swift +62 -229
- package/ios/ShortKitFeedViewManager.mm +3 -2
- package/ios/ShortKitModule.mm +66 -37
- package/ios/ShortKitPlayerNativeView.swift +39 -8
- package/ios/ShortKitReactNative-Bridging-Header.h +2 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +2380 -522
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +39 -12
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +39 -12
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +2380 -522
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +39 -12
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +39 -12
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework.bak/Info.plist +43 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +28917 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +824 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +824 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +28917 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +824 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +824 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitWidgetNativeView.swift +3 -3
- package/package.json +1 -1
- package/src/ShortKitCarouselOverlaySurface.tsx +55 -0
- package/src/ShortKitCommands.ts +31 -0
- package/src/ShortKitContext.ts +6 -25
- package/src/ShortKitFeed.tsx +110 -41
- package/src/ShortKitLoadingSurface.tsx +24 -0
- package/src/ShortKitOverlaySurface.tsx +205 -0
- package/src/ShortKitPlayer.tsx +6 -7
- package/src/ShortKitProvider.tsx +27 -286
- package/src/index.ts +5 -3
- package/src/serialization.ts +19 -39
- package/src/specs/NativeShortKitModule.ts +58 -46
- package/src/specs/ShortKitFeedViewNativeComponent.ts +3 -2
- package/src/types.ts +78 -16
- package/src/useShortKit.ts +1 -3
- package/src/useShortKitPlayer.ts +7 -7
- package/android/src/main/java/com/shortkit/reactnative/ShortKitCarouselOverlayBridge.kt +0 -48
- package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +0 -128
- package/ios/ShortKitCarouselOverlayBridge.swift +0 -219
- package/ios/ShortKitOverlayBridge.swift +0 -111
- package/src/CarouselOverlayManager.tsx +0 -70
- package/src/OverlayManager.tsx +0 -87
- 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
|