@shortkitsdk/react-native 0.2.23 → 0.2.25
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/ShortKitBridge.kt +10 -7
- 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 +140 -3
- package/ios/ShortKitFeedView.swift +20 -0
- package/ios/ShortKitFeedViewManager.mm +1 -0
- package/ios/ShortKitModule.mm +56 -0
- package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
- 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 +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +127 -5
- 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 +127 -5
- 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 +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +127 -5
- 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 +127 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +127 -5
- 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 +127 -5
- 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 +7 -0
- package/src/ShortKitContext.ts +6 -0
- package/src/ShortKitFeed.tsx +23 -7
- package/src/ShortKitOverlaySurface.tsx +59 -23
- package/src/ShortKitProvider.tsx +45 -1
- package/src/ShortKitVideoCarouselOverlaySurface.tsx +51 -5
- package/src/index.ts +4 -0
- package/src/serialization.ts +37 -1
- package/src/specs/NativeShortKitModule.ts +80 -2
- package/src/specs/ShortKitFeedViewNativeComponent.ts +8 -0
- package/src/types.ts +71 -2
- 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
|
@@ -18,6 +18,8 @@ import ShortKitSDK
|
|
|
18
18
|
private var cancellables = Set<AnyCancellable>()
|
|
19
19
|
private weak var delegate: ShortKitBridgeDelegateProtocol?
|
|
20
20
|
private var lastProgressEmitTime: CFTimeInterval = 0
|
|
21
|
+
private var lastDownloadProgressEmitTime: TimeInterval = 0
|
|
22
|
+
private var itemCache: [String: ContentItem] = [:]
|
|
21
23
|
|
|
22
24
|
// Time update coalescing (250ms, matching overlay timer interval)
|
|
23
25
|
private var cachedTime: (current: Double, duration: Double, buffered: Double) = (0, 0, 0)
|
|
@@ -129,6 +131,7 @@ import ShortKitSDK
|
|
|
129
131
|
subscribeToPublishers(sdk.player)
|
|
130
132
|
startTimeCoalescing()
|
|
131
133
|
sdk.delegate = self
|
|
134
|
+
sdk.downloadDelegate = self
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
// MARK: - Teardown
|
|
@@ -140,6 +143,7 @@ import ShortKitSDK
|
|
|
140
143
|
preloadHandles.removeAll()
|
|
141
144
|
feedRegistry.removeAll()
|
|
142
145
|
pendingOps.removeAll()
|
|
146
|
+
itemCache.removeAll()
|
|
143
147
|
shortKit = nil
|
|
144
148
|
if ShortKitBridge.shared === self {
|
|
145
149
|
ShortKitBridge.shared = nil
|
|
@@ -203,6 +207,44 @@ import ShortKitSDK
|
|
|
203
207
|
shortKit?.player.skipToPrevious()
|
|
204
208
|
}
|
|
205
209
|
|
|
210
|
+
// MARK: - Carousel Commands
|
|
211
|
+
|
|
212
|
+
/// Advance the active carousel to the next video.
|
|
213
|
+
/// Returns NSNumber wrapping Bool: @(YES) on success, @(NO) when no carousel active.
|
|
214
|
+
@objc public func carouselNext() -> NSNumber {
|
|
215
|
+
return NSNumber(value: shortKit?.carousel.next() ?? false)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Move the active carousel to the previous video.
|
|
219
|
+
@objc public func carouselPrevious() -> NSNumber {
|
|
220
|
+
return NSNumber(value: shortKit?.carousel.previous() ?? false)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// Jump the active carousel to a specific zero-based index.
|
|
224
|
+
///
|
|
225
|
+
/// Accepts `Int` (bridged to ObjC `NSInteger` / encoding `q`), matching
|
|
226
|
+
/// what RN TurboModule codegen emits for a TS `Int32` parameter. Declaring
|
|
227
|
+
/// this with `NSNumber *` crashed at the Swift @objc thunk's ARC retain
|
|
228
|
+
/// because the codegen wrote a raw int into the NSInvocation slot, which
|
|
229
|
+
/// the thunk interpreted as an object pointer.
|
|
230
|
+
@objc public func carouselSetActiveIndex(_ index: Int) -> NSNumber {
|
|
231
|
+
return NSNumber(value: shortKit?.carousel.setActiveIndex(index) ?? false)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// MARK: - Carousel Accessors
|
|
235
|
+
|
|
236
|
+
/// Zero-based index of the currently playing carousel video, or -1 when no carousel is active.
|
|
237
|
+
@objc public func getCarouselActiveIndex() -> NSNumber {
|
|
238
|
+
guard let idx = shortKit?.carousel.activeIndexValue else { return NSNumber(value: -1) }
|
|
239
|
+
return NSNumber(value: idx)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// Total number of videos in the active carousel, or 0 when no carousel is active.
|
|
243
|
+
@objc public func getCarouselVideoCount() -> NSNumber {
|
|
244
|
+
guard let count = shortKit?.carousel.videoCountValue else { return NSNumber(value: 0) }
|
|
245
|
+
return NSNumber(value: count)
|
|
246
|
+
}
|
|
247
|
+
|
|
206
248
|
@objc public func setMuted(_ muted: Bool) {
|
|
207
249
|
shortKit?.player.setMuted(muted)
|
|
208
250
|
}
|
|
@@ -396,6 +438,7 @@ import ShortKitSDK
|
|
|
396
438
|
.receive(on: DispatchQueue.main)
|
|
397
439
|
.sink { [weak self] item in
|
|
398
440
|
guard let item else { return }
|
|
441
|
+
self?.itemCache[item.id] = item
|
|
399
442
|
let body = Self.contentItemDict(item)
|
|
400
443
|
DispatchQueue.main.async {
|
|
401
444
|
self?.emit("onCurrentItemChanged", body: body)
|
|
@@ -547,6 +590,9 @@ import ShortKitSDK
|
|
|
547
590
|
.store(in: &cancellables)
|
|
548
591
|
|
|
549
592
|
// Remaining content count — now handled per-feed via onRemainingContentCountChange callback
|
|
593
|
+
|
|
594
|
+
// Carousel video completion is emitted from ReactVideoCarouselOverlayHost
|
|
595
|
+
// using that host's surfaceId, so useOverlayEvent subscribers can match it.
|
|
550
596
|
}
|
|
551
597
|
|
|
552
598
|
// MARK: - Event Emission Helpers
|
|
@@ -637,6 +683,9 @@ import ShortKitSDK
|
|
|
637
683
|
if let fallbackUrl = item.fallbackUrl {
|
|
638
684
|
dict["fallbackUrl"] = fallbackUrl
|
|
639
685
|
}
|
|
686
|
+
if let downloadUrl = item.downloadUrl {
|
|
687
|
+
dict["downloadUrl"] = downloadUrl
|
|
688
|
+
}
|
|
640
689
|
|
|
641
690
|
return dict
|
|
642
691
|
}
|
|
@@ -873,8 +922,9 @@ import ShortKitSDK
|
|
|
873
922
|
switch type {
|
|
874
923
|
case "video":
|
|
875
924
|
guard let playbackId = obj["playbackId"] as? String else { continue }
|
|
925
|
+
let origin = (obj["origin"] as? String).flatMap { ContentOrigin(rawValue: $0) } ?? .other
|
|
876
926
|
let fallbackUrl = obj["fallbackUrl"] as? String
|
|
877
|
-
result.append(.video(playbackId: playbackId, fallbackUrl: fallbackUrl))
|
|
927
|
+
result.append(.video(playbackId: playbackId, origin: origin, fallbackUrl: fallbackUrl))
|
|
878
928
|
case "imageCarousel":
|
|
879
929
|
guard let itemData = obj["item"],
|
|
880
930
|
let itemJSON = try? JSONSerialization.data(withJSONObject: itemData),
|
|
@@ -885,10 +935,10 @@ import ShortKitSDK
|
|
|
885
935
|
case "videoCarousel":
|
|
886
936
|
guard let itemData = obj["item"] as? [String: Any],
|
|
887
937
|
let itemJSON = try? JSONSerialization.data(withJSONObject: itemData),
|
|
888
|
-
let
|
|
938
|
+
let carouselInput = try? JSONDecoder().decode(VideoCarouselInput.self, from: itemJSON) else {
|
|
889
939
|
continue
|
|
890
940
|
}
|
|
891
|
-
result.append(.videoCarousel(
|
|
941
|
+
result.append(.videoCarousel(carouselInput))
|
|
892
942
|
default:
|
|
893
943
|
continue
|
|
894
944
|
}
|
|
@@ -937,6 +987,11 @@ extension ShortKitBridge: ShortKitDelegate {
|
|
|
937
987
|
}
|
|
938
988
|
|
|
939
989
|
public func shortKit(_ shortKit: ShortKit, didFetchContentItems items: [ContentItem]) {
|
|
990
|
+
DispatchQueue.main.async {
|
|
991
|
+
for item in items {
|
|
992
|
+
self.itemCache[item.id] = item
|
|
993
|
+
}
|
|
994
|
+
}
|
|
940
995
|
Task {
|
|
941
996
|
let data = try? JSONEncoder().encode(items)
|
|
942
997
|
let json = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
@@ -953,3 +1008,85 @@ extension ShortKitBridge {
|
|
|
953
1008
|
}
|
|
954
1009
|
}
|
|
955
1010
|
|
|
1011
|
+
// MARK: - ShortKitDownloadDelegate
|
|
1012
|
+
|
|
1013
|
+
extension ShortKitBridge: ShortKitDownloadDelegate {
|
|
1014
|
+
public func shortKit(_ shortKit: ShortKit, didStartDownload item: ContentItem) {
|
|
1015
|
+
emitOnMain("onDownloadStarted", body: ["itemId": item.id])
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
public func shortKit(_ shortKit: ShortKit, didUpdateDownloadProgress item: ContentItem, progress: Double) {
|
|
1019
|
+
let now = CACurrentMediaTime()
|
|
1020
|
+
guard now - lastDownloadProgressEmitTime >= 0.1 else { return }
|
|
1021
|
+
lastDownloadProgressEmitTime = now
|
|
1022
|
+
emitOnMain("onDownloadProgress", body: [
|
|
1023
|
+
"itemId": item.id,
|
|
1024
|
+
"progress": progress,
|
|
1025
|
+
])
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
public func shortKit(_ shortKit: ShortKit, didCompleteDownload item: ContentItem, fileURL: URL) {
|
|
1029
|
+
emitOnMain("onDownloadCompleted", body: [
|
|
1030
|
+
"itemId": item.id,
|
|
1031
|
+
"fileUrl": fileURL.absoluteString,
|
|
1032
|
+
])
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
public func shortKit(_ shortKit: ShortKit, didFailDownload item: ContentItem, error: ShortKitDownloadError) {
|
|
1036
|
+
let message: String
|
|
1037
|
+
switch error {
|
|
1038
|
+
case .downloadNotAvailable:
|
|
1039
|
+
message = "Download not available"
|
|
1040
|
+
case .downloadInProgress:
|
|
1041
|
+
message = "Download already in progress"
|
|
1042
|
+
case .networkError(let underlying):
|
|
1043
|
+
message = "Network error: \(underlying.localizedDescription)"
|
|
1044
|
+
case .httpError(let statusCode):
|
|
1045
|
+
message = "HTTP error: \(statusCode)"
|
|
1046
|
+
case .cancelled:
|
|
1047
|
+
message = "Download cancelled"
|
|
1048
|
+
}
|
|
1049
|
+
emitOnMain("onDownloadFailed", body: [
|
|
1050
|
+
"itemId": item.id,
|
|
1051
|
+
"error": message,
|
|
1052
|
+
])
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// MARK: - Downloads
|
|
1057
|
+
|
|
1058
|
+
extension ShortKitBridge {
|
|
1059
|
+
@objc public func downloadVideo(_ itemId: String, mode: String, completion: @escaping (String?, NSError?) -> Void) {
|
|
1060
|
+
DispatchQueue.main.async {
|
|
1061
|
+
guard let shortKit = self.shortKit else {
|
|
1062
|
+
completion(nil, NSError(domain: "ShortKitBridge", code: 1, userInfo: [
|
|
1063
|
+
NSLocalizedDescriptionKey: "ShortKit not initialized"
|
|
1064
|
+
]))
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
guard let item = self.itemCache[itemId] else {
|
|
1069
|
+
completion(nil, NSError(domain: "ShortKitBridge", code: 2, userInfo: [
|
|
1070
|
+
NSLocalizedDescriptionKey: "Content item not found: \(itemId)"
|
|
1071
|
+
]))
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
let downloadMode: DownloadMode = mode == "interruptive" ? .interruptive : .nonInterruptive
|
|
1076
|
+
|
|
1077
|
+
Task {
|
|
1078
|
+
do {
|
|
1079
|
+
let fileURL = try await shortKit.downloadVideo(item, mode: downloadMode)
|
|
1080
|
+
completion(fileURL.absoluteString, nil)
|
|
1081
|
+
} catch {
|
|
1082
|
+
completion(nil, error as NSError)
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
@objc public func cancelDownload() {
|
|
1089
|
+
shortKit?.cancelDownload()
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
@@ -20,6 +20,15 @@ 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
|
+
|
|
23
32
|
@objc var feedId: String?
|
|
24
33
|
|
|
25
34
|
// MARK: - Child VC
|
|
@@ -104,6 +113,17 @@ import ShortKitSDK
|
|
|
104
113
|
let feedVC = ShortKitFeedViewController(shortKit: sdk, config: feedConfig, startAtItemId: startAtItemId)
|
|
105
114
|
feedVC.setBridgeManaged()
|
|
106
115
|
|
|
116
|
+
// Seed a thumbnail from the host app's image cache so the first cell
|
|
117
|
+
// renders with a visible thumbnail from frame zero. Synchronous
|
|
118
|
+
// SDWebImage lookup (memory then disk, via reflection) — no network.
|
|
119
|
+
// If nil (cache miss or SDWebImage not linked), the existing
|
|
120
|
+
// patchMissingThumbnailIfNeeded helper handles the fallback at
|
|
121
|
+
// feed-open time.
|
|
122
|
+
if let url = self.seedThumbnailUrl,
|
|
123
|
+
let image = SeedThumbnailResolver.resolveFromMemory(url: url) {
|
|
124
|
+
feedVC.seedThumbnail = image
|
|
125
|
+
}
|
|
126
|
+
|
|
107
127
|
if sdk.debugPanelEnabled {
|
|
108
128
|
feedVC.debugPanelFactory = { active, prev, next in
|
|
109
129
|
let panel = DebugPanelView(frame: CGRect(
|
package/ios/ShortKitModule.mm
CHANGED
|
@@ -82,8 +82,16 @@ RCT_EXPORT_MODULE(ShortKitModule)
|
|
|
82
82
|
@"onOverlayFeedScrollPhaseChanged",
|
|
83
83
|
@"onOverlayTimeUpdate",
|
|
84
84
|
@"onOverlayFullState",
|
|
85
|
+
@"onOverlayItemChanged",
|
|
85
86
|
@"onCarouselActiveImageChanged",
|
|
87
|
+
@"onCarouselItemChanged",
|
|
86
88
|
@"onVideoCarouselActiveVideoChanged",
|
|
89
|
+
@"onVideoCarouselItemChanged",
|
|
90
|
+
@"onCarouselActiveVideoCompleted",
|
|
91
|
+
@"onDownloadStarted",
|
|
92
|
+
@"onDownloadProgress",
|
|
93
|
+
@"onDownloadCompleted",
|
|
94
|
+
@"onDownloadFailed",
|
|
87
95
|
];
|
|
88
96
|
}
|
|
89
97
|
|
|
@@ -193,6 +201,35 @@ RCT_EXPORT_METHOD(skipToPrevious) {
|
|
|
193
201
|
[_shortKitBridge skipToPrevious];
|
|
194
202
|
}
|
|
195
203
|
|
|
204
|
+
// MARK: - Carousel Commands
|
|
205
|
+
|
|
206
|
+
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, carouselNext)
|
|
207
|
+
{
|
|
208
|
+
return [_shortKitBridge carouselNext];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, carouselPrevious)
|
|
212
|
+
{
|
|
213
|
+
return [_shortKitBridge carouselPrevious];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, carouselSetActiveIndex:(NSInteger)index)
|
|
217
|
+
{
|
|
218
|
+
return [_shortKitBridge carouselSetActiveIndex:index];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// MARK: - Carousel Accessors
|
|
222
|
+
|
|
223
|
+
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, getCarouselActiveIndex)
|
|
224
|
+
{
|
|
225
|
+
return [_shortKitBridge getCarouselActiveIndex];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, getCarouselVideoCount)
|
|
229
|
+
{
|
|
230
|
+
return [_shortKitBridge getCarouselVideoCount];
|
|
231
|
+
}
|
|
232
|
+
|
|
196
233
|
RCT_EXPORT_METHOD(setMuted:(BOOL)muted) {
|
|
197
234
|
[_shortKitBridge setMuted:muted];
|
|
198
235
|
}
|
|
@@ -297,6 +334,25 @@ RCT_EXPORT_METHOD(getStoryboardData:(NSString *)playbackId
|
|
|
297
334
|
}];
|
|
298
335
|
}
|
|
299
336
|
|
|
337
|
+
// MARK: - Download Management
|
|
338
|
+
|
|
339
|
+
RCT_EXPORT_METHOD(downloadVideo:(NSString *)itemId
|
|
340
|
+
mode:(NSString *)mode
|
|
341
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
342
|
+
reject:(RCTPromiseRejectBlock)reject) {
|
|
343
|
+
[_shortKitBridge downloadVideo:itemId mode:mode completion:^(NSString *fileUrl, NSError *error) {
|
|
344
|
+
if (error) {
|
|
345
|
+
reject(@"DOWNLOAD_ERROR", error.localizedDescription, error);
|
|
346
|
+
} else {
|
|
347
|
+
resolve(fileUrl);
|
|
348
|
+
}
|
|
349
|
+
}];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
RCT_EXPORT_METHOD(cancelDownload) {
|
|
353
|
+
[_shortKitBridge cancelDownload];
|
|
354
|
+
}
|
|
355
|
+
|
|
300
356
|
// MARK: - New Architecture (TurboModule)
|
|
301
357
|
|
|
302
358
|
#ifdef RCT_NEW_ARCH_ENABLED
|