@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.
Files changed (46) hide show
  1. package/README.md +151 -0
  2. package/android/libs/shortkit-release.aar +0 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +19 -1
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +10 -7
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +43 -0
  6. package/ios/ReactCarouselOverlayHost.swift +51 -3
  7. package/ios/ReactOverlayHost.swift +67 -7
  8. package/ios/ReactVideoCarouselOverlayHost.swift +181 -19
  9. package/ios/SKFabricSurfaceWrapper.mm +7 -1
  10. package/ios/ShortKitBridge.swift +140 -3
  11. package/ios/ShortKitFeedView.swift +20 -0
  12. package/ios/ShortKitFeedViewManager.mm +1 -0
  13. package/ios/ShortKitModule.mm +56 -0
  14. package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +4745 -456
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +127 -5
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  19. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +127 -5
  20. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  22. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  23. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +4745 -456
  24. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +127 -5
  25. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +127 -5
  27. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +4745 -456
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +127 -5
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +127 -5
  31. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  32. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  33. package/package.json +1 -1
  34. package/src/ShortKitCarouselOverlaySurface.tsx +38 -10
  35. package/src/ShortKitCommands.ts +7 -0
  36. package/src/ShortKitContext.ts +6 -0
  37. package/src/ShortKitFeed.tsx +23 -7
  38. package/src/ShortKitOverlaySurface.tsx +59 -23
  39. package/src/ShortKitProvider.tsx +45 -1
  40. package/src/ShortKitVideoCarouselOverlaySurface.tsx +51 -5
  41. package/src/index.ts +4 -0
  42. package/src/serialization.ts +37 -1
  43. package/src/specs/NativeShortKitModule.ts +80 -2
  44. package/src/specs/ShortKitFeedViewNativeComponent.ts +8 -0
  45. package/src/types.ts +71 -2
  46. 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
- if let surface {
97
- surface.setProperties(props)
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
- pendingProps = props
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
- if self.isActive {
156
- self.bridge?.emit("onOverlayPlayerStateChanged", body: [
157
- "surfaceId": self.surfaceId,
158
- "playerState": self.cachedPlayerState
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
- if self.isActive {
170
- self.bridge?.emit("onOverlayMutedChanged", body: [
171
- "surfaceId": self.surfaceId,
172
- "isMuted": self.cachedIsMuted
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, self.timeDirty else { return }
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
- surface.setMaximumSize(size)
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
- // RCTFabricSurface min/max are readonly; use setSize: to set both.
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
 
@@ -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 carouselItem = try? JSONDecoder().decode(VideoCarouselItem.self, from: itemJSON) else {
938
+ let carouselInput = try? JSONDecoder().decode(VideoCarouselInput.self, from: itemJSON) else {
889
939
  continue
890
940
  }
891
- result.append(.videoCarousel(carouselItem))
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(
@@ -26,5 +26,6 @@ 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)
29
30
 
30
31
  @end
@@ -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