@shortkitsdk/react-native 0.2.24 → 0.2.26
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/README.md +151 -0
- package/android/libs/shortkit-release.aar +0 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +19 -1
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +43 -0
- package/ios/ReactCarouselOverlayHost.swift +51 -3
- package/ios/ReactOverlayHost.swift +67 -7
- package/ios/ReactVideoCarouselOverlayHost.swift +181 -19
- package/ios/SKFabricSurfaceWrapper.mm +7 -1
- package/ios/ShortKitBridge.swift +85 -5
- package/ios/ShortKitFeedView.swift +70 -4
- package/ios/ShortKitFeedViewManager.mm +3 -0
- package/ios/ShortKitModule.mm +46 -3
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +5273 -337
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +151 -7
- 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 +151 -7
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +5273 -337
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +151 -7
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +151 -7
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +5273 -337
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +151 -7
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +151 -7
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
- package/package.json +1 -1
- package/src/ShortKitCarouselOverlaySurface.tsx +38 -10
- package/src/ShortKitCommands.ts +4 -0
- package/src/ShortKitFeed.tsx +65 -10
- package/src/ShortKitOverlaySurface.tsx +59 -23
- package/src/ShortKitProvider.tsx +2 -1
- package/src/ShortKitVideoCarouselOverlaySurface.tsx +51 -5
- package/src/index.ts +2 -0
- package/src/serialization.ts +37 -1
- package/src/specs/NativeShortKitModule.ts +68 -3
- package/src/specs/ShortKitFeedViewNativeComponent.ts +11 -0
- package/src/types.ts +85 -3
- package/src/useShortKitCarousel.ts +80 -0
|
@@ -13,7 +13,9 @@ import ShortKitSDK
|
|
|
13
13
|
// MARK: - Configuration
|
|
14
14
|
|
|
15
15
|
var surfacePresenter: AnyObject?
|
|
16
|
-
weak var bridge: ShortKitBridge?
|
|
16
|
+
weak var bridge: ShortKitBridge? {
|
|
17
|
+
didSet { subscribeToCarouselCompletion() }
|
|
18
|
+
}
|
|
17
19
|
|
|
18
20
|
/// Module name for the RCTFabricSurface. Set by the overlay factory
|
|
19
21
|
/// based on the feed config's video carousel overlay name.
|
|
@@ -37,16 +39,36 @@ import ShortKitSDK
|
|
|
37
39
|
/// Cached carouselItem JSON — setProperties replaces all props, doesn't merge.
|
|
38
40
|
private var carouselItemJSON: String?
|
|
39
41
|
|
|
42
|
+
/// Currently configured carousel item — used to detect item transitions
|
|
43
|
+
/// so we can emit an event instead of triggering a full Fabric remount.
|
|
44
|
+
private var currentCarouselItem: VideoCarouselItem?
|
|
45
|
+
|
|
46
|
+
/// Whether initial props have been pushed to the surface at least once.
|
|
47
|
+
/// First mount must go through setProperties (for item + surfaceId + initial
|
|
48
|
+
/// state). Subsequent item changes use the event path.
|
|
49
|
+
private var hasPushedInitialProps: Bool = false
|
|
50
|
+
|
|
40
51
|
// Player state
|
|
41
52
|
private var player: ShortKitPlayer?
|
|
42
53
|
private var cancellables = Set<AnyCancellable>()
|
|
54
|
+
/// Separate cancellable set for the carousel-completion subscription.
|
|
55
|
+
/// Kept out of `cancellables` so `subscribeToPlayer`'s removeAll() doesn't
|
|
56
|
+
/// wipe it out — the completion subscription is wired once at bridge didSet
|
|
57
|
+
/// time and must survive player re-attachments.
|
|
58
|
+
private var carouselCompletionCancellables = Set<AnyCancellable>()
|
|
43
59
|
private var isActive = false
|
|
60
|
+
private var isDragging: Bool = false // FIX: track drag phase
|
|
44
61
|
private var cachedPlayerState: String = "idle"
|
|
45
62
|
private var cachedIsMuted: Bool = true
|
|
46
63
|
private var cachedTime: (current: Double, duration: Double, buffered: Double) = (0, 0, 0)
|
|
47
64
|
private var timeCoalesceTimer: Timer?
|
|
48
65
|
private var timeDirty = false
|
|
49
66
|
|
|
67
|
+
// Tracks the last bounds.size pushed to the surface. Used in layoutSubviews
|
|
68
|
+
// to skip redundant setSize calls that would otherwise trigger a Fabric
|
|
69
|
+
// layout recalc on every frame during scroll.
|
|
70
|
+
private var lastLayoutSize: CGSize = .zero
|
|
71
|
+
|
|
50
72
|
// MARK: - Init
|
|
51
73
|
|
|
52
74
|
override init(frame: CGRect) {
|
|
@@ -67,12 +89,21 @@ import ShortKitSDK
|
|
|
67
89
|
// MARK: - VideoCarouselOverlay
|
|
68
90
|
|
|
69
91
|
public func configure(with item: VideoCarouselItem) {
|
|
92
|
+
let isSameItem = item.id == currentCarouselItem?.id
|
|
93
|
+
currentCarouselItem = item
|
|
94
|
+
|
|
70
95
|
isActive = false
|
|
96
|
+
isDragging = false
|
|
71
97
|
timeDirty = false
|
|
72
98
|
timeCoalesceTimer?.invalidate()
|
|
73
99
|
timeCoalesceTimer = nil
|
|
100
|
+
// Reset cached state so recycled cells don't flash stale values from
|
|
101
|
+
// the previous item's player. The new player's publishers will re-emit
|
|
102
|
+
// current values after attach(), so the defaults are only visible for
|
|
103
|
+
// the single frame between configure() and the first publisher tick.
|
|
74
104
|
cachedTime = (0, 0, 0)
|
|
75
105
|
cachedPlayerState = "idle"
|
|
106
|
+
cachedIsMuted = true
|
|
76
107
|
|
|
77
108
|
createSurfaceIfNeeded()
|
|
78
109
|
|
|
@@ -80,6 +111,35 @@ import ShortKitSDK
|
|
|
80
111
|
let json = String(data: data, encoding: .utf8) else { return }
|
|
81
112
|
carouselItemJSON = json
|
|
82
113
|
|
|
114
|
+
// Surface lifecycle on item change:
|
|
115
|
+
// - First mount: pushInitialProperties() via setProperties (unavoidable
|
|
116
|
+
// — React needs props at mount time). ALSO emit
|
|
117
|
+
// onVideoCarouselItemChanged so external subscribers that live
|
|
118
|
+
// outside the overlay's React tree (e.g. useShortKitCarousel() hook
|
|
119
|
+
// consumers) see the initial carousel too. The in-surface subscriber
|
|
120
|
+
// at ShortKitVideoCarouselOverlaySurface.tsx:153 is sid-filtered
|
|
121
|
+
// and sets the same JSON-parsed item, so it's a redundant second
|
|
122
|
+
// set with identical value — safe.
|
|
123
|
+
// - Subsequent item change with surface ready: emit event for React
|
|
124
|
+
// diff (no remount).
|
|
125
|
+
// - Same item: no-op.
|
|
126
|
+
if !hasPushedInitialProps {
|
|
127
|
+
let props = buildInitialProps(item: item, json: json)
|
|
128
|
+
if let surface {
|
|
129
|
+
surface.setProperties(props)
|
|
130
|
+
hasPushedInitialProps = true
|
|
131
|
+
} else {
|
|
132
|
+
pendingProps = props
|
|
133
|
+
}
|
|
134
|
+
emitItemChanged(item: item, json: json)
|
|
135
|
+
} else if !isSameItem {
|
|
136
|
+
emitItemChanged(item: item, json: json)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Build the initial-properties dictionary for an item. Used on first mount
|
|
141
|
+
/// (via setProperties) and shadowed by emitItemChanged() on subsequent changes.
|
|
142
|
+
private func buildInitialProps(item: VideoCarouselItem, json: String) -> [String: Any] {
|
|
83
143
|
var props: [String: Any] = [
|
|
84
144
|
"surfaceId": surfaceId,
|
|
85
145
|
"carouselItem": json,
|
|
@@ -93,11 +153,32 @@ import ShortKitSDK
|
|
|
93
153
|
props["activeVideo"] = videoJSON
|
|
94
154
|
props["activeVideoIndex"] = 0
|
|
95
155
|
}
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
return props
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Emit onVideoCarouselItemChanged with full initial state for the new item.
|
|
160
|
+
/// Replaces setProperties() on cell reuse, avoiding a full Fabric root remount.
|
|
161
|
+
private func emitItemChanged(item: VideoCarouselItem, json: String) {
|
|
162
|
+
var body: [String: Any] = [
|
|
163
|
+
"surfaceId": surfaceId,
|
|
164
|
+
"carouselItem": json,
|
|
165
|
+
"isActive": false,
|
|
166
|
+
"playerState": "idle",
|
|
167
|
+
"isMuted": cachedIsMuted,
|
|
168
|
+
"activeVideoIndex": 0,
|
|
169
|
+
]
|
|
170
|
+
if let firstVideo = item.videos.first,
|
|
171
|
+
let videoData = try? JSONEncoder().encode(firstVideo),
|
|
172
|
+
let videoJSON = String(data: videoData, encoding: .utf8) {
|
|
173
|
+
body["activeVideo"] = videoJSON
|
|
98
174
|
} else {
|
|
99
|
-
|
|
175
|
+
// Match buildInitialProps: omit the key rather than emitting an
|
|
176
|
+
// empty-string sentinel that JS treats as falsy (overlay would
|
|
177
|
+
// disappear on every cell reuse with a zero-video carousel).
|
|
178
|
+
body["activeVideo"] = NSNull()
|
|
100
179
|
}
|
|
180
|
+
|
|
181
|
+
bridge?.emit("onVideoCarouselItemChanged", body: body)
|
|
101
182
|
}
|
|
102
183
|
|
|
103
184
|
public func updateActiveVideo(index: Int, item: ContentItem) {
|
|
@@ -147,17 +228,20 @@ import ShortKitSDK
|
|
|
147
228
|
|
|
148
229
|
private func subscribeToPlayer(_ player: ShortKitPlayer) {
|
|
149
230
|
cancellables.removeAll()
|
|
231
|
+
// FIX: playerState, isMuted, and time are suppressed during .dragging to
|
|
232
|
+
// avoid unnecessary bridge traffic while scrolling. emitFullState() on
|
|
233
|
+
// drag→settled re-syncs cached values.
|
|
234
|
+
|
|
150
235
|
player.playerState
|
|
151
236
|
.receive(on: DispatchQueue.main)
|
|
152
237
|
.sink { [weak self] state in
|
|
153
238
|
guard let self else { return }
|
|
154
239
|
self.cachedPlayerState = Self.playerStateString(state)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
240
|
+
guard self.isActive, !self.isDragging else { return }
|
|
241
|
+
self.bridge?.emit("onOverlayPlayerStateChanged", body: [
|
|
242
|
+
"surfaceId": self.surfaceId,
|
|
243
|
+
"playerState": self.cachedPlayerState
|
|
244
|
+
])
|
|
161
245
|
}
|
|
162
246
|
.store(in: &cancellables)
|
|
163
247
|
|
|
@@ -166,23 +250,90 @@ import ShortKitSDK
|
|
|
166
250
|
.sink { [weak self] muted in
|
|
167
251
|
guard let self else { return }
|
|
168
252
|
self.cachedIsMuted = muted
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
253
|
+
guard self.isActive, !self.isDragging else { return }
|
|
254
|
+
self.bridge?.emit("onOverlayMutedChanged", body: [
|
|
255
|
+
"surfaceId": self.surfaceId,
|
|
256
|
+
"isMuted": self.cachedIsMuted
|
|
257
|
+
])
|
|
175
258
|
}
|
|
176
259
|
.store(in: &cancellables)
|
|
177
260
|
|
|
178
261
|
player.time
|
|
179
262
|
.receive(on: DispatchQueue.main)
|
|
180
263
|
.sink { [weak self] time in
|
|
181
|
-
guard let self, self.isActive else { return }
|
|
264
|
+
guard let self, self.isActive, !self.isDragging else { return }
|
|
182
265
|
self.cachedTime = (time.current, time.duration, time.buffered)
|
|
183
266
|
self.timeDirty = true
|
|
184
267
|
}
|
|
185
268
|
.store(in: &cancellables)
|
|
269
|
+
|
|
270
|
+
// FIX: subscribe to feedScrollPhase so we know when a drag starts/settles.
|
|
271
|
+
// On drag start: invalidate the time coalescing timer.
|
|
272
|
+
// On settle: restart timer and re-sync state via emitFullState().
|
|
273
|
+
player.feedScrollPhase
|
|
274
|
+
.receive(on: DispatchQueue.main)
|
|
275
|
+
.sink { [weak self] phase in
|
|
276
|
+
guard let self else { return }
|
|
277
|
+
switch phase {
|
|
278
|
+
case .dragging:
|
|
279
|
+
self.isDragging = true
|
|
280
|
+
// Stop the time coalescing timer during drags. Time updates
|
|
281
|
+
// are suppressed while isDragging is true, so the timer
|
|
282
|
+
// would just wake the main thread to check timeDirty.
|
|
283
|
+
self.timeCoalesceTimer?.invalidate()
|
|
284
|
+
self.timeCoalesceTimer = nil
|
|
285
|
+
case .settled:
|
|
286
|
+
let wasDragging = self.isDragging
|
|
287
|
+
self.isDragging = false
|
|
288
|
+
if self.isActive {
|
|
289
|
+
self.startTimeCoalescing()
|
|
290
|
+
if wasDragging {
|
|
291
|
+
// Re-sync state that was suppressed during the drag.
|
|
292
|
+
self.emitFullState()
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
.store(in: &cancellables)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// MARK: - Carousel Completion
|
|
301
|
+
|
|
302
|
+
/// Subscribe to the carousel's activeVideoCompleted publisher and emit
|
|
303
|
+
/// `onCarouselActiveVideoCompleted` with this host's surfaceId.
|
|
304
|
+
/// Called via `bridge` didSet so the subscription is wired once per host
|
|
305
|
+
/// instance, mirroring how `onVideoCarouselActiveVideoChanged` is emitted.
|
|
306
|
+
private func subscribeToCarouselCompletion() {
|
|
307
|
+
guard let carousel = bridge?.sdk?.carousel else { return }
|
|
308
|
+
carousel.activeVideoCompleted
|
|
309
|
+
.receive(on: DispatchQueue.main)
|
|
310
|
+
.sink { [weak self] event in
|
|
311
|
+
guard let self, self.isActive,
|
|
312
|
+
event.carouselItem.id == self.currentCarouselItem?.id else { return }
|
|
313
|
+
let contentItemJSON: String
|
|
314
|
+
if let data = try? JSONEncoder().encode(event.contentItem),
|
|
315
|
+
let json = String(data: data, encoding: .utf8) {
|
|
316
|
+
contentItemJSON = json
|
|
317
|
+
} else {
|
|
318
|
+
contentItemJSON = "{}"
|
|
319
|
+
}
|
|
320
|
+
let carouselItemJSON: String
|
|
321
|
+
if let data = try? JSONEncoder().encode(event.carouselItem),
|
|
322
|
+
let json = String(data: data, encoding: .utf8) {
|
|
323
|
+
carouselItemJSON = json
|
|
324
|
+
} else {
|
|
325
|
+
carouselItemJSON = "{}"
|
|
326
|
+
}
|
|
327
|
+
self.bridge?.emit("onCarouselActiveVideoCompleted", body: [
|
|
328
|
+
"surfaceId": self.surfaceId,
|
|
329
|
+
"contentItem": contentItemJSON,
|
|
330
|
+
"indexInCarousel": event.indexInCarousel,
|
|
331
|
+
"carouselItem": carouselItemJSON,
|
|
332
|
+
"wasLast": event.wasLast,
|
|
333
|
+
"willAutoAdvance": event.willAutoAdvance,
|
|
334
|
+
])
|
|
335
|
+
}
|
|
336
|
+
.store(in: &carouselCompletionCancellables)
|
|
186
337
|
}
|
|
187
338
|
|
|
188
339
|
// MARK: - Time Coalescing
|
|
@@ -190,7 +341,10 @@ import ShortKitSDK
|
|
|
190
341
|
private func startTimeCoalescing() {
|
|
191
342
|
timeCoalesceTimer?.invalidate()
|
|
192
343
|
timeCoalesceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
|
|
193
|
-
guard let self
|
|
344
|
+
guard let self else { return }
|
|
345
|
+
// Timer is invalidated on drag start; this guard is belt-and-suspenders.
|
|
346
|
+
if self.isDragging { return }
|
|
347
|
+
guard self.timeDirty else { return }
|
|
194
348
|
self.timeDirty = false
|
|
195
349
|
self.bridge?.emit("onOverlayTimeUpdate", body: [
|
|
196
350
|
"surfaceId": self.surfaceId,
|
|
@@ -251,6 +405,7 @@ import ShortKitSDK
|
|
|
251
405
|
if let pending = pendingProps {
|
|
252
406
|
surf.setProperties(pending)
|
|
253
407
|
pendingProps = nil
|
|
408
|
+
hasPushedInitialProps = true
|
|
254
409
|
}
|
|
255
410
|
}
|
|
256
411
|
|
|
@@ -261,8 +416,15 @@ import ShortKitSDK
|
|
|
261
416
|
guard let surface else { return }
|
|
262
417
|
let size = bounds.size
|
|
263
418
|
guard size.width > 0, size.height > 0 else { return }
|
|
419
|
+
|
|
420
|
+
// Skip setSize when bounds haven't changed. UICollectionView calls
|
|
421
|
+
// layoutSubviews every frame during scroll; without this guard we'd
|
|
422
|
+
// trigger a Fabric layout recalc each time.
|
|
423
|
+
guard size != lastLayoutSize else { return }
|
|
424
|
+
lastLayoutSize = size
|
|
425
|
+
|
|
264
426
|
surface.setMinimumSize(size)
|
|
265
|
-
|
|
427
|
+
// setMaximumSize is a no-op in SKFabricSurfaceWrapper — not called.
|
|
266
428
|
}
|
|
267
429
|
|
|
268
430
|
// MARK: - Helpers
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
@implementation SKFabricSurfaceWrapper {
|
|
6
6
|
RCTFabricSurface *_surface;
|
|
7
7
|
NSDictionary *_lastProperties;
|
|
8
|
+
CGSize _lastSize;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
- (nullable instancetype)initWithPresenter:(nonnull id)surfacePresenter
|
|
@@ -46,7 +47,12 @@
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
- (void)setMinimumSize:(CGSize)size {
|
|
49
|
-
//
|
|
50
|
+
// Skip when size hasn't changed — belt-and-suspenders guard in case a
|
|
51
|
+
// caller other than the overlay hosts invokes this during scroll.
|
|
52
|
+
if (CGSizeEqualToSize(_lastSize, size)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
_lastSize = size;
|
|
50
56
|
[_surface setSize:size];
|
|
51
57
|
}
|
|
52
58
|
|
package/ios/ShortKitBridge.swift
CHANGED
|
@@ -70,6 +70,37 @@ import ShortKitSDK
|
|
|
70
70
|
self?.emitOnMain("onFeedReady", body: ["feedId": id])
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Wire per-feed refresh state callback (scoped by feedId)
|
|
74
|
+
vc.onRefreshStateChanged = { [weak self] state in
|
|
75
|
+
NSLog("[ShortKit Bridge] onRefreshStateChangedPerFeed feedId=%@ status=%@", id, "\(state)")
|
|
76
|
+
var body: [String: Any] = ["feedId": id]
|
|
77
|
+
switch state {
|
|
78
|
+
case .idle:
|
|
79
|
+
body["status"] = "idle"
|
|
80
|
+
body["progress"] = 0.0
|
|
81
|
+
case .pulling(let progress):
|
|
82
|
+
body["status"] = "pulling"
|
|
83
|
+
body["progress"] = progress
|
|
84
|
+
case .triggered:
|
|
85
|
+
body["status"] = "triggered"
|
|
86
|
+
body["progress"] = 0.0
|
|
87
|
+
case .refreshing:
|
|
88
|
+
body["status"] = "refreshing"
|
|
89
|
+
body["progress"] = 0.0
|
|
90
|
+
}
|
|
91
|
+
self?.emitOnMain("onRefreshStateChangedPerFeed", body: body)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Wire per-feed video-carousel cell tap event
|
|
95
|
+
vc.onVideoCarouselCellTap = { [weak self] payload in
|
|
96
|
+
self?.emitOnMain("onVideoCarouselCellTap", body: [
|
|
97
|
+
"feedId": id,
|
|
98
|
+
"id": payload.id,
|
|
99
|
+
"index": payload.index,
|
|
100
|
+
"pageIndex": payload.pageIndex,
|
|
101
|
+
])
|
|
102
|
+
}
|
|
103
|
+
|
|
73
104
|
// Replay buffered operations on the next run-loop tick so the VC's
|
|
74
105
|
// view hierarchy is fully set up after didMoveToWindow returns.
|
|
75
106
|
if let ops = pendingOps.removeValue(forKey: id) {
|
|
@@ -207,6 +238,44 @@ import ShortKitSDK
|
|
|
207
238
|
shortKit?.player.skipToPrevious()
|
|
208
239
|
}
|
|
209
240
|
|
|
241
|
+
// MARK: - Carousel Commands
|
|
242
|
+
|
|
243
|
+
/// Advance the active carousel to the next video.
|
|
244
|
+
/// Returns NSNumber wrapping Bool: @(YES) on success, @(NO) when no carousel active.
|
|
245
|
+
@objc public func carouselNext() -> NSNumber {
|
|
246
|
+
return NSNumber(value: shortKit?.carousel.next() ?? false)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Move the active carousel to the previous video.
|
|
250
|
+
@objc public func carouselPrevious() -> NSNumber {
|
|
251
|
+
return NSNumber(value: shortKit?.carousel.previous() ?? false)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Jump the active carousel to a specific zero-based index.
|
|
255
|
+
///
|
|
256
|
+
/// Accepts `Int` (bridged to ObjC `NSInteger` / encoding `q`), matching
|
|
257
|
+
/// what RN TurboModule codegen emits for a TS `Int32` parameter. Declaring
|
|
258
|
+
/// this with `NSNumber *` crashed at the Swift @objc thunk's ARC retain
|
|
259
|
+
/// because the codegen wrote a raw int into the NSInvocation slot, which
|
|
260
|
+
/// the thunk interpreted as an object pointer.
|
|
261
|
+
@objc public func carouselSetActiveIndex(_ index: Int) -> NSNumber {
|
|
262
|
+
return NSNumber(value: shortKit?.carousel.setActiveIndex(index) ?? false)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// MARK: - Carousel Accessors
|
|
266
|
+
|
|
267
|
+
/// Zero-based index of the currently playing carousel video, or -1 when no carousel is active.
|
|
268
|
+
@objc public func getCarouselActiveIndex() -> NSNumber {
|
|
269
|
+
guard let idx = shortKit?.carousel.activeIndexValue else { return NSNumber(value: -1) }
|
|
270
|
+
return NSNumber(value: idx)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// Total number of videos in the active carousel, or 0 when no carousel is active.
|
|
274
|
+
@objc public func getCarouselVideoCount() -> NSNumber {
|
|
275
|
+
guard let count = shortKit?.carousel.videoCountValue else { return NSNumber(value: 0) }
|
|
276
|
+
return NSNumber(value: count)
|
|
277
|
+
}
|
|
278
|
+
|
|
210
279
|
@objc public func setMuted(_ muted: Bool) {
|
|
211
280
|
shortKit?.player.setMuted(muted)
|
|
212
281
|
}
|
|
@@ -234,20 +303,27 @@ import ShortKitSDK
|
|
|
234
303
|
|
|
235
304
|
// MARK: - Custom Feed
|
|
236
305
|
|
|
237
|
-
@objc public func setFeedItems(_ feedId: String, items json: String) {
|
|
306
|
+
@objc public func setFeedItems(_ feedId: String, items json: String, startAtId: String?) {
|
|
238
307
|
guard let items = Self.parseFeedInputs(json) else { return }
|
|
239
308
|
DispatchQueue.main.async { [weak self] in
|
|
240
309
|
guard let self else { return }
|
|
241
310
|
if let vc = self.feedViewController(for: feedId) {
|
|
242
|
-
vc.setFeedItems(items)
|
|
311
|
+
vc.setFeedItems(items, startAtId: startAtId)
|
|
243
312
|
} else {
|
|
244
313
|
self.pendingOps[feedId, default: []].append { vc in
|
|
245
|
-
vc.setFeedItems(items)
|
|
314
|
+
vc.setFeedItems(items, startAtId: startAtId)
|
|
246
315
|
}
|
|
247
316
|
}
|
|
248
317
|
}
|
|
249
318
|
}
|
|
250
319
|
|
|
320
|
+
@objc public func scrollFeedToItem(_ feedId: String, id: String, animated: Bool) {
|
|
321
|
+
DispatchQueue.main.async { [weak self] in
|
|
322
|
+
guard let self else { return }
|
|
323
|
+
self.feedViewController(for: feedId)?.scrollToItem(id: id, animated: animated)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
251
327
|
@objc public func appendFeedItems(_ feedId: String, items json: String) {
|
|
252
328
|
guard let items = Self.parseFeedInputs(json) else { return }
|
|
253
329
|
DispatchQueue.main.async { [weak self] in
|
|
@@ -552,6 +628,9 @@ import ShortKitSDK
|
|
|
552
628
|
.store(in: &cancellables)
|
|
553
629
|
|
|
554
630
|
// Remaining content count — now handled per-feed via onRemainingContentCountChange callback
|
|
631
|
+
|
|
632
|
+
// Carousel video completion is emitted from ReactVideoCarouselOverlayHost
|
|
633
|
+
// using that host's surfaceId, so useOverlayEvent subscribers can match it.
|
|
555
634
|
}
|
|
556
635
|
|
|
557
636
|
// MARK: - Event Emission Helpers
|
|
@@ -869,7 +948,7 @@ import ShortKitSDK
|
|
|
869
948
|
}
|
|
870
949
|
|
|
871
950
|
/// Parse a JSON string of FeedInput[] from the JS bridge.
|
|
872
|
-
|
|
951
|
+
static func parseFeedInputs(_ json: String) -> [FeedInput]? {
|
|
873
952
|
guard let data = json.data(using: .utf8),
|
|
874
953
|
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
|
875
954
|
return nil
|
|
@@ -881,8 +960,9 @@ import ShortKitSDK
|
|
|
881
960
|
switch type {
|
|
882
961
|
case "video":
|
|
883
962
|
guard let playbackId = obj["playbackId"] as? String else { continue }
|
|
963
|
+
let origin = (obj["origin"] as? String).flatMap { ContentOrigin(rawValue: $0) } ?? .other
|
|
884
964
|
let fallbackUrl = obj["fallbackUrl"] as? String
|
|
885
|
-
result.append(.video(playbackId: playbackId, fallbackUrl: fallbackUrl))
|
|
965
|
+
result.append(.video(playbackId: playbackId, origin: origin, fallbackUrl: fallbackUrl))
|
|
886
966
|
case "imageCarousel":
|
|
887
967
|
guard let itemData = obj["item"],
|
|
888
968
|
let itemJSON = try? JSONSerialization.data(withJSONObject: itemData),
|
|
@@ -20,6 +20,40 @@ import ShortKitSDK
|
|
|
20
20
|
didSet { /* used at init time only */ }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/// URL of the thumbnail the host app is already rendering for the item that
|
|
24
|
+
/// will be the first active cell. Looked up synchronously via SDWebImage's
|
|
25
|
+
/// memory cache (supports expo-image / FastImage clients) and seeded onto
|
|
26
|
+
/// the feed VC before viewDidLoad fires. Falls back to patchMissingThumbnail
|
|
27
|
+
/// (network fetch) on cache miss.
|
|
28
|
+
@objc public var seedThumbnailUrl: String? {
|
|
29
|
+
didSet { /* used at embed time only */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// JSON-serialized FeedInput[] for initial custom-feed items at mount time.
|
|
33
|
+
/// Parsed and wrapped in FeedPreload(immediateItems:) at embed time so the
|
|
34
|
+
/// SDK takes the sync render path — no async preload wait, no ref-attach
|
|
35
|
+
/// race, first cell dequeued on the first run-loop tick.
|
|
36
|
+
/// When preloadId is also set, preloadId takes precedence.
|
|
37
|
+
@objc public var feedItemsJSON: String? {
|
|
38
|
+
didSet { /* used at embed time only */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// When the consumer explicitly manages this prop, it becomes the
|
|
42
|
+
/// authoritative suspension signal — overriding willMove(toWindow:).
|
|
43
|
+
private var isActiveManagedByProp = false
|
|
44
|
+
|
|
45
|
+
@objc public var active: Bool = true {
|
|
46
|
+
didSet {
|
|
47
|
+
guard active != oldValue, let feedVC = feedViewController else { return }
|
|
48
|
+
if !active { isActiveManagedByProp = true }
|
|
49
|
+
if active {
|
|
50
|
+
feedVC.activate()
|
|
51
|
+
} else {
|
|
52
|
+
feedVC.deactivate()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
23
57
|
@objc var feedId: String?
|
|
24
58
|
|
|
25
59
|
// MARK: - Child VC
|
|
@@ -39,7 +73,7 @@ import ShortKitSDK
|
|
|
39
73
|
public override func willMove(toWindow newWindow: UIWindow?) {
|
|
40
74
|
super.willMove(toWindow: newWindow)
|
|
41
75
|
|
|
42
|
-
if newWindow == nil {
|
|
76
|
+
if newWindow == nil && !isActiveManagedByProp {
|
|
43
77
|
suspendFeedViewController()
|
|
44
78
|
}
|
|
45
79
|
}
|
|
@@ -62,14 +96,18 @@ import ShortKitSDK
|
|
|
62
96
|
|
|
63
97
|
// Re-attach an existing suspended VC (e.g. after native stack pop)
|
|
64
98
|
if let feedVC = feedViewController {
|
|
99
|
+
NSLog("[ShortKit FeedView] re-attaching suspended VC feedId=%@", feedId ?? "nil")
|
|
65
100
|
parentVC.addChild(feedVC)
|
|
66
101
|
feedVC.view.frame = bounds
|
|
67
102
|
addSubview(feedVC.view)
|
|
68
103
|
feedVC.didMove(toParent: parentVC)
|
|
69
|
-
|
|
104
|
+
if !isActiveManagedByProp {
|
|
105
|
+
feedVC.activate()
|
|
106
|
+
}
|
|
70
107
|
if let feedId = self.feedId {
|
|
71
108
|
ShortKitBridge.shared?.registerFeed(id: feedId, viewController: feedVC)
|
|
72
109
|
}
|
|
110
|
+
NSLog("[ShortKit FeedView] re-attach complete — activate() called")
|
|
73
111
|
return
|
|
74
112
|
}
|
|
75
113
|
|
|
@@ -90,8 +128,14 @@ import ShortKitSDK
|
|
|
90
128
|
NSLog("[ShortKit FeedView] ❌ No preload handle found for feedId: %@", preloadId)
|
|
91
129
|
NSLog("[ShortKit FeedView] Available preload handles: %@", ShortKitBridge.shared?.preloadHandles.keys.joined(separator: ", ") ?? "none")
|
|
92
130
|
}
|
|
131
|
+
} else if let json = self.feedItemsJSON,
|
|
132
|
+
let items = ShortKitBridge.parseFeedInputs(json) {
|
|
133
|
+
// feedItems prop: wrap in an immediate preload (no async prefetch
|
|
134
|
+
// work — items are available synchronously at viewDidLoad).
|
|
135
|
+
feedConfig.preload = FeedPreload(immediateItems: items)
|
|
136
|
+
NSLog("[ShortKit FeedView] feedItems prop: created immediate preload with %d items", items.count)
|
|
93
137
|
} else {
|
|
94
|
-
NSLog("[ShortKit FeedView] No preloadId prop set")
|
|
138
|
+
NSLog("[ShortKit FeedView] No preloadId or feedItems prop set")
|
|
95
139
|
}
|
|
96
140
|
|
|
97
141
|
NSLog("[ShortKit FeedView] feedConfig.preload is %@", feedConfig.preload != nil ? "SET" : "NIL")
|
|
@@ -104,6 +148,17 @@ import ShortKitSDK
|
|
|
104
148
|
let feedVC = ShortKitFeedViewController(shortKit: sdk, config: feedConfig, startAtItemId: startAtItemId)
|
|
105
149
|
feedVC.setBridgeManaged()
|
|
106
150
|
|
|
151
|
+
// Seed a thumbnail from the host app's image cache so the first cell
|
|
152
|
+
// renders with a visible thumbnail from frame zero. Synchronous
|
|
153
|
+
// SDWebImage lookup (memory then disk, via reflection) — no network.
|
|
154
|
+
// If nil (cache miss or SDWebImage not linked), the existing
|
|
155
|
+
// patchMissingThumbnailIfNeeded helper handles the fallback at
|
|
156
|
+
// feed-open time.
|
|
157
|
+
if let url = self.seedThumbnailUrl,
|
|
158
|
+
let image = SeedThumbnailResolver.resolveFromMemory(url: url) {
|
|
159
|
+
feedVC.seedThumbnail = image
|
|
160
|
+
}
|
|
161
|
+
|
|
107
162
|
if sdk.debugPanelEnabled {
|
|
108
163
|
feedVC.debugPanelFactory = { active, prev, next in
|
|
109
164
|
let panel = DebugPanelView(frame: CGRect(
|
|
@@ -135,6 +190,12 @@ import ShortKitSDK
|
|
|
135
190
|
feedVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
136
191
|
addSubview(feedVC.view)
|
|
137
192
|
feedVC.didMove(toParent: parentVC)
|
|
193
|
+
|
|
194
|
+
// Fabric sets props BEFORE didMoveToWindow, so active=false may have
|
|
195
|
+
// fired its didSet while feedViewController was still nil. Apply now.
|
|
196
|
+
if isActiveManagedByProp && !active, let feedVC = feedViewController {
|
|
197
|
+
feedVC.deactivate()
|
|
198
|
+
}
|
|
138
199
|
}
|
|
139
200
|
|
|
140
201
|
/// Detach the feedVC from the parent VC hierarchy without destroying it.
|
|
@@ -142,15 +203,20 @@ import ShortKitSDK
|
|
|
142
203
|
/// (e.g. pushing a new screen on top). The feedVC and its state are preserved
|
|
143
204
|
/// so they can be re-attached when the view returns to the window.
|
|
144
205
|
private func suspendFeedViewController() {
|
|
206
|
+
NSLog("[ShortKit FeedView] suspendFeedViewController ENTRY feedId=%@", feedId ?? "nil")
|
|
145
207
|
if let feedId = self.feedId {
|
|
146
208
|
ShortKitBridge.shared?.unregisterFeed(id: feedId)
|
|
147
209
|
}
|
|
148
|
-
guard let feedVC = feedViewController else {
|
|
210
|
+
guard let feedVC = feedViewController else {
|
|
211
|
+
NSLog("[ShortKit FeedView] suspendFeedViewController NOOP — no feedVC")
|
|
212
|
+
return
|
|
213
|
+
}
|
|
149
214
|
|
|
150
215
|
feedVC.deactivate()
|
|
151
216
|
feedVC.willMove(toParent: nil)
|
|
152
217
|
feedVC.view.removeFromSuperview()
|
|
153
218
|
feedVC.removeFromParent()
|
|
219
|
+
NSLog("[ShortKit FeedView] suspendFeedViewController EXIT — VC retained for re-attach")
|
|
154
220
|
// Keep feedViewController reference — re-attached in embedFeedViewControllerIfNeeded
|
|
155
221
|
}
|
|
156
222
|
|
|
@@ -26,5 +26,8 @@ RCT_EXPORT_VIEW_PROPERTY(config, NSString)
|
|
|
26
26
|
RCT_EXPORT_VIEW_PROPERTY(startAtItemId, NSString)
|
|
27
27
|
RCT_EXPORT_VIEW_PROPERTY(preloadId, NSString)
|
|
28
28
|
RCT_EXPORT_VIEW_PROPERTY(feedId, NSString)
|
|
29
|
+
RCT_EXPORT_VIEW_PROPERTY(seedThumbnailUrl, NSString)
|
|
30
|
+
RCT_EXPORT_VIEW_PROPERTY(feedItemsJSON, NSString)
|
|
31
|
+
RCT_EXPORT_VIEW_PROPERTY(active, BOOL)
|
|
29
32
|
|
|
30
33
|
@end
|