@shortkitsdk/react-native 0.2.27 → 0.2.29

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 (38) hide show
  1. package/android/libs/shortkit-release.aar +0 -0
  2. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +8 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +10 -0
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +4 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +45 -33
  6. package/ios/ReactVideoCarouselOverlayHost.swift +6 -0
  7. package/ios/ShortKitBridge.swift +142 -35
  8. package/ios/ShortKitFeedView.swift +43 -44
  9. package/ios/ShortKitModule.mm +11 -0
  10. package/ios/ShortKitPlayerNativeView.swift +7 -1
  11. package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
  12. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  13. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +950 -126
  14. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +26 -3
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +26 -3
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  19. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  20. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +950 -126
  21. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +26 -3
  22. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  23. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +26 -3
  24. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +950 -126
  25. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +26 -3
  26. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +26 -3
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  30. package/ios/ShortKitWidgetNativeView.swift +33 -12
  31. package/package.json +1 -1
  32. package/src/ShortKitFeed.tsx +34 -0
  33. package/src/ShortKitPlayer.tsx +25 -15
  34. package/src/ShortKitWidget.tsx +24 -18
  35. package/src/index.ts +1 -0
  36. package/src/serialization.ts +38 -0
  37. package/src/specs/NativeShortKitModule.ts +7 -0
  38. package/src/types.ts +19 -1
Binary file
@@ -835,6 +835,14 @@ class ShortKitBridge(
835
835
  }
836
836
  }
837
837
 
838
+ fun refresh(feedId: String) {
839
+ val fragment = feedRegistry[feedId]?.get()
840
+ if (fragment != null) {
841
+ Handler(Looper.getMainLooper()).post { fragment.refresh() }
842
+ }
843
+ // No pending ops — refresh on unregistered feed is a no-op
844
+ }
845
+
838
846
  // ------------------------------------------------------------------
839
847
  // Fetch content
840
848
  // ------------------------------------------------------------------
@@ -230,6 +230,16 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
230
230
  }
231
231
  }
232
232
 
233
+ @ReactMethod
234
+ override fun refresh(feedId: String) {
235
+ val b = bridge
236
+ if (b != null) {
237
+ b.refresh(feedId)
238
+ } else {
239
+ // No buffering — refresh before bridge is ready is a no-op
240
+ }
241
+ }
242
+
233
243
  @ReactMethod
234
244
  override fun preloadFeed(configJSON: String, itemsJSON: String?, promise: Promise) {
235
245
  val b = bridge
@@ -4,6 +4,7 @@ import android.content.Context
4
4
  import android.view.MotionEvent
5
5
  import android.widget.FrameLayout
6
6
  import com.shortkit.sdk.config.PlayerClickAction
7
+ import com.shortkit.sdk.config.FeedConfig
7
8
  import com.shortkit.sdk.config.PlayerConfig
8
9
  import com.shortkit.sdk.config.VideoOverlayMode
9
10
  import com.shortkit.sdk.model.ContentItem
@@ -106,6 +107,9 @@ class ShortKitPlayerNativeView(context: Context) : FrameLayout(context) {
106
107
  loop = obj.optBoolean("loop", true),
107
108
  muteOnStart = obj.optBoolean("muteOnStart", true),
108
109
  videoOverlay = parseOverlay(obj),
110
+ feedConfig = obj.optString("feedConfig", "").let { fcStr ->
111
+ if (fcStr.isNotEmpty()) ShortKitBridge.parseFeedConfig(fcStr, context) else FeedConfig()
112
+ },
109
113
  )
110
114
  } catch (_: Exception) {
111
115
  PlayerConfig()
@@ -5,28 +5,40 @@ import android.widget.FrameLayout
5
5
  import com.shortkit.sdk.config.PlayerClickAction
6
6
  import com.shortkit.sdk.config.VideoOverlayMode
7
7
  import com.shortkit.sdk.config.WidgetConfig
8
- import com.shortkit.sdk.model.ContentItem
9
8
  import com.shortkit.sdk.widget.ShortKitWidgetView
9
+ import com.shortkit.sdk.config.FeedConfig
10
10
  import com.shortkit.sdk.model.FeedFilter
11
+ import com.shortkit.sdk.model.WidgetInput
11
12
  import org.json.JSONArray
12
13
  import org.json.JSONObject
13
14
 
14
15
  /**
15
16
  * Fabric native view wrapping [ShortKitWidgetView] for use as a
16
17
  * horizontal video carousel in React Native.
18
+ *
19
+ * Props:
20
+ * - `config`: JSON string with WidgetConfig values
21
+ * - `items`: JSON string with WidgetInput array (compact playback-ID form)
17
22
  */
18
23
  class ShortKitWidgetNativeView(context: Context) : FrameLayout(context) {
19
24
 
20
25
  private var widgetView: ShortKitWidgetView? = null
21
26
  private var configJson: String? = null
22
27
  private var itemsJson: String? = null
28
+ private var parsedConfig: WidgetConfig = WidgetConfig()
29
+ private var parsedItems: List<WidgetInput> = emptyList()
23
30
 
24
31
  var config: String?
25
32
  get() = configJson
26
33
  set(value) {
27
34
  if (value == configJson) return
28
35
  configJson = value
29
- rebuildIfNeeded()
36
+ parsedConfig = parseWidgetConfig(value)
37
+ // If widget is already built, rebuild to pick up new config.
38
+ if (widgetView != null) {
39
+ removeWidget()
40
+ embedWidgetIfNeeded()
41
+ }
30
42
  }
31
43
 
32
44
  var items: String?
@@ -34,39 +46,38 @@ class ShortKitWidgetNativeView(context: Context) : FrameLayout(context) {
34
46
  set(value) {
35
47
  if (value == itemsJson) return
36
48
  itemsJson = value
37
- applyItems()
49
+ parsedItems = parseWidgetInputs(value) ?: emptyList()
50
+ // Post-mount update on an existing widget.
51
+ widgetView?.configure(parsedItems)
38
52
  }
39
53
 
40
54
  override fun onAttachedToWindow() {
41
55
  super.onAttachedToWindow()
42
- rebuildIfNeeded()
56
+ embedWidgetIfNeeded()
43
57
  }
44
58
 
45
59
  override fun onDetachedFromWindow() {
46
60
  super.onDetachedFromWindow()
61
+ removeWidget()
47
62
  }
48
63
 
49
- private fun rebuildIfNeeded() {
64
+ private fun embedWidgetIfNeeded() {
50
65
  if (widgetView != null) return
51
-
52
66
  val sdk = ShortKitBridge.shared?.sdk ?: return
53
- val widgetConfig = parseWidgetConfig(configJson)
54
67
 
68
+ // Pass items at initialize time so the widget never races the server
69
+ // fetch — analogous to the feed's `feedItems` prop wiring.
55
70
  val view = ShortKitWidgetView(context).apply {
56
71
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
57
72
  }
58
- view.initialize(sdk, widgetConfig)
73
+ view.initialize(sdk, parsedConfig, parsedItems)
59
74
  addView(view)
60
75
  widgetView = view
61
-
62
- applyItems()
63
76
  }
64
77
 
65
- private fun applyItems() {
66
- val json = itemsJson ?: return
67
- val view = widgetView ?: return
68
- val contentItems = parseContentItems(json) ?: return
69
- view.configure(contentItems)
78
+ private fun removeWidget() {
79
+ widgetView?.let { removeView(it) }
80
+ widgetView = null
70
81
  }
71
82
 
72
83
  private fun parseWidgetConfig(json: String?): WidgetConfig {
@@ -91,6 +102,9 @@ class ShortKitWidgetNativeView(context: Context) : FrameLayout(context) {
91
102
  filter = obj.optString("filter", "").let { filterStr ->
92
103
  if (filterStr.isNotEmpty()) ShortKitBridge.parseFeedFilterToModel(filterStr) else null
93
104
  },
105
+ feedConfig = obj.optString("feedConfig", "").let { fcStr ->
106
+ if (fcStr.isNotEmpty()) ShortKitBridge.parseFeedConfig(fcStr, context) else FeedConfig()
107
+ },
94
108
  )
95
109
  } catch (_: Exception) {
96
110
  WidgetConfig()
@@ -107,29 +121,27 @@ class ShortKitWidgetNativeView(context: Context) : FrameLayout(context) {
107
121
  return VideoOverlayMode.None
108
122
  }
109
123
 
110
- private fun parseContentItems(json: String): List<ContentItem>? {
124
+ /**
125
+ * Parse a JSON array of `WidgetInput` values (compact playback-ID form).
126
+ * Mirrors the JS `WidgetInput` type:
127
+ * `{ type: 'video'; playbackId: string; fallbackUrl?: string }`.
128
+ */
129
+ private fun parseWidgetInputs(json: String?): List<WidgetInput>? {
130
+ if (json.isNullOrEmpty()) return null
111
131
  return try {
112
132
  val arr = JSONArray(json)
113
- val items = mutableListOf<ContentItem>()
133
+ val out = mutableListOf<WidgetInput>()
114
134
  for (i in 0 until arr.length()) {
115
135
  val obj = arr.getJSONObject(i)
116
- items.add(
117
- ContentItem(
118
- id = obj.getString("id"),
119
- title = obj.getString("title"),
120
- description = obj.optString("description", null),
121
- duration = obj.getDouble("duration"),
122
- streamingUrl = obj.getString("streamingUrl"),
123
- thumbnailUrl = obj.getString("thumbnailUrl"),
124
- captionTracks = emptyList(),
125
- customMetadata = null,
126
- author = obj.optString("author", null),
127
- articleUrl = obj.optString("articleUrl", null),
128
- commentCount = if (obj.has("commentCount")) obj.getInt("commentCount") else null,
129
- )
130
- )
136
+ if (obj.optString("type") != "video") continue
137
+ val playbackId = obj.optString("playbackId", "")
138
+ if (playbackId.isEmpty()) continue
139
+ val fallbackUrl = if (obj.has("fallbackUrl") && !obj.isNull("fallbackUrl")) {
140
+ obj.getString("fallbackUrl")
141
+ } else null
142
+ out.add(WidgetInput.Video(playbackId = playbackId, fallbackUrl = fallbackUrl))
131
143
  }
132
- items
144
+ out
133
145
  } catch (_: Exception) {
134
146
  null
135
147
  }
@@ -324,7 +324,13 @@ import ShortKitSDK
324
324
  } else {
325
325
  carouselItemJSON = "{}"
326
326
  }
327
+ // Tag with active surface's feedId so per-<ShortKitFeed>
328
+ // subscribers can filter to their own feed instance — same
329
+ // pattern as the other per-feed events. surfaceId continues
330
+ // to identify the React overlay host (a separate concern).
331
+ let feedId = self.bridge?.activeSurfaceFeedIdPublic() ?? ""
327
332
  self.bridge?.emit("onCarouselActiveVideoCompleted", body: [
333
+ "feedId": feedId,
328
334
  "surfaceId": self.surfaceId,
329
335
  "contentItem": contentItemJSON,
330
336
  "indexInCarousel": event.indexInCarousel,
@@ -72,7 +72,6 @@ import ShortKitSDK
72
72
 
73
73
  // Wire per-feed refresh state callback (scoped by feedId)
74
74
  vc.onRefreshStateChanged = { [weak self] state in
75
- NSLog("[ShortKit Bridge] onRefreshStateChangedPerFeed feedId=%@ status=%@", id, "\(state)")
76
75
  var body: [String: Any] = ["feedId": id]
77
76
  switch state {
78
77
  case .idle:
@@ -101,6 +100,34 @@ import ShortKitSDK
101
100
  ])
102
101
  }
103
102
 
103
+ // Wire per-feed transition event. The FVC fires this closure from
104
+ // handleSwipe(to:) — one per transition, bound to this feed. This
105
+ // replaces the global `player.feedTransition` subscription pattern
106
+ // (which fanned every feed's transitions out to every mounted
107
+ // <ShortKitFeed>, causing cross-feed state pollution). Structural
108
+ // fix: callback literally cannot fire on the wrong feed.
109
+ vc.onFeedTransition = { [weak self] event in
110
+ guard let self else { return }
111
+ var body: [String: Any] = [
112
+ "feedId": id,
113
+ "phase": Self.transitionPhaseString(event.phase),
114
+ "direction": Self.transitionDirectionString(event.direction)
115
+ ]
116
+ // Serialize from the underlying FeedItem (not event.from/to
117
+ // which are nil for non-content cells). See the docstring on
118
+ // `serializeFeedItemIdentityJSON` for the why — in short, this
119
+ // ensures carousels/ads/surveys come through with a populated
120
+ // `id`, so JS-side hosts can track feed position for any cell
121
+ // type.
122
+ if let fromFeedItem = event.fromFeedItem {
123
+ body["fromItem"] = self.serializeFeedItemIdentityJSON(fromFeedItem)
124
+ }
125
+ if let toFeedItem = event.toFeedItem {
126
+ body["toItem"] = self.serializeFeedItemIdentityJSON(toFeedItem)
127
+ }
128
+ self.emitOnMain("onFeedTransition", body: body)
129
+ }
130
+
104
131
  // Replay buffered operations on the next run-loop tick so the VC's
105
132
  // view hierarchy is fully set up after didMoveToWindow returns.
106
133
  if let ops = pendingOps.removeValue(forKey: id) {
@@ -122,6 +149,24 @@ import ShortKitSDK
122
149
  return feedRegistry[id]?.vc
123
150
  }
124
151
 
152
+ /// Look up the feedId for the currently-active surface. Used by events
153
+ /// that originate in shared singletons (player Combine publishers, the
154
+ /// `ShortKitDelegate`) and must be attributed to one feed for the JS
155
+ /// wrapper's feedId filter to work. Returns `""` if no active surface
156
+ /// or if the active surface isn't in the registry — consumers should
157
+ /// treat empty feedId as "unknown/global" (JS wrapper accepts it as a
158
+ /// fallback for forward compatibility with older native builds).
159
+ private func activeSurfaceFeedId() -> String {
160
+ return feedRegistry.first(where: { $0.value.vc?.isActiveSurface == true })?.key ?? ""
161
+ }
162
+
163
+ /// Internal accessor for `activeSurfaceFeedId()` so cross-file emit
164
+ /// sites (e.g. `ReactVideoCarouselOverlayHost`) can tag emits with
165
+ /// the active feed's feedId without duplicating the lookup logic.
166
+ internal func activeSurfaceFeedIdPublic() -> String {
167
+ return activeSurfaceFeedId()
168
+ }
169
+
125
170
  // MARK: - Init
126
171
 
127
172
  @objc public init(
@@ -404,6 +449,14 @@ import ShortKitSDK
404
449
  }
405
450
  }
406
451
 
452
+ @objc public func refresh(_ feedId: String) {
453
+ DispatchQueue.main.async { [weak self] in
454
+ guard let self else { return }
455
+ self.feedViewController(for: feedId)?.refresh()
456
+ // No pending ops buffer — refresh on a not-yet-registered feed is a no-op
457
+ }
458
+ }
459
+
407
460
  @objc public func fetchContent(_ limit: Int, filterJSON: String?, completion: @escaping (String) -> Void) {
408
461
  let filter = filterJSON.flatMap { Self.parseFeedFilter($0) }
409
462
  Task {
@@ -422,29 +475,21 @@ import ShortKitSDK
422
475
  let config = Self.parseFeedConfig(configJSON)
423
476
  let preload: FeedPreload?
424
477
 
425
- NSLog("[ShortKit Bridge] preloadFeed called — feedSource: %@, hasItemsJSON: %@",
426
- config.feedSource == .custom ? "custom" : "algorithmic",
427
- itemsJSON != nil ? "yes (\(itemsJSON!.prefix(100))...)" : "no")
428
-
429
478
  if config.feedSource == .custom {
430
479
  guard let json = itemsJSON, let items = Self.parseFeedInputs(json) else {
431
- NSLog("[ShortKit Bridge] ❌ preloadFeed: feedSource=custom but no valid items — returning empty")
432
480
  completion("")
433
481
  return
434
482
  }
435
- NSLog("[ShortKit Bridge] preloadFeed: creating custom preload with %d items", items.count)
436
483
  preload = shortKit?.preloadFeed(items: items)
437
484
  } else {
438
485
  preload = shortKit?.preloadFeed(filter: config.filter)
439
486
  }
440
487
 
441
488
  guard let preload else {
442
- NSLog("[ShortKit Bridge] ❌ preloadFeed: shortKit?.preloadFeed returned nil")
443
489
  completion("")
444
490
  return
445
491
  }
446
492
  let uuid = UUID().uuidString
447
- NSLog("[ShortKit Bridge] ✅ preloadFeed: created handle %@", uuid)
448
493
  preloadHandles[uuid] = preload
449
494
  activeFeedId = uuid
450
495
  completion(uuid)
@@ -545,38 +590,34 @@ import ShortKitSDK
545
590
  }
546
591
  .store(in: &cancellables)
547
592
 
548
- // Did loop
593
+ // Did loop — tagged with active surface's feedId. `player.didLoop`
594
+ // is a singleton Combine publisher on the shared player (only one
595
+ // item plays at a time). The event semantically belongs to
596
+ // whichever feed owns the active surface; tag with that feedId so
597
+ // JS consumers bound to specific feeds can filter.
549
598
  player.didLoop
550
599
  .receive(on: DispatchQueue.main)
551
600
  .sink { [weak self] event in
552
- self?.emit("onDidLoop", body: [
601
+ guard let self else { return }
602
+ let feedId = self.activeSurfaceFeedId()
603
+ self.emit("onDidLoop", body: [
604
+ "feedId": feedId,
553
605
  "contentId": event.contentId,
554
606
  "loopCount": event.loopCount
555
607
  ])
556
608
  }
557
609
  .store(in: &cancellables)
558
610
 
559
- // Feed transition deferred to next run-loop tick so emission doesn't
560
- // contend with Core Animation's commit phase during swipe settle.
561
- player.feedTransition
562
- .receive(on: DispatchQueue.main)
563
- .sink { [weak self] event in
564
- guard let self else { return }
565
- var body: [String: Any] = [
566
- "phase": Self.transitionPhaseString(event.phase),
567
- "direction": Self.transitionDirectionString(event.direction)
568
- ]
569
- if let from = event.from {
570
- body["fromItem"] = self.serializeContentItemToJSON(from)
571
- }
572
- if let to = event.to {
573
- body["toItem"] = self.serializeContentItemToJSON(to)
574
- }
575
- DispatchQueue.main.async { [weak self] in
576
- self?.emit("onFeedTransition", body: body)
577
- }
578
- }
579
- .store(in: &cancellables)
611
+ // NOTE: The global `player.feedTransition` subscription used to live
612
+ // here. It's been removed feed transitions are now emitted to RN
613
+ // via the per-FVC `vc.onFeedTransition` closure wired in
614
+ // `registerFeed(id:viewController:)` above. This eliminates the
615
+ // cross-feed routing bug where every mounted <ShortKitFeed>
616
+ // consumer received every feed's transitions.
617
+ //
618
+ // `player.sendFeedTransition` is still called by FVC.handleSwipe
619
+ // for backward compatibility with native iOS consumers that
620
+ // subscribe to `ShortKit.player.feedTransition` directly.
580
621
 
581
622
  // Feed scroll phase — coalesced: only emit .dragging on first touch
582
623
  // (transition from settled), drop intermediate .dragging events. Always
@@ -605,11 +646,16 @@ import ShortKitSDK
605
646
  }
606
647
  .store(in: &cancellables)
607
648
 
608
- // Format change
649
+ // Format change — tagged with active surface's feedId. Same
650
+ // reasoning as onDidLoop: fires from shared player singleton, but
651
+ // semantically belongs to whichever feed owns the active surface.
609
652
  player.formatChange
610
653
  .receive(on: DispatchQueue.main)
611
654
  .sink { [weak self] event in
612
- self?.emit("onFormatChange", body: [
655
+ guard let self else { return }
656
+ let feedId = self.activeSurfaceFeedId()
657
+ self.emit("onFormatChange", body: [
658
+ "feedId": feedId,
613
659
  "contentId": event.contentId,
614
660
  "fromBitrate": Double(event.fromBitrate),
615
661
  "toBitrate": Double(event.toBitrate),
@@ -675,6 +721,52 @@ import ShortKitSDK
675
721
  return json
676
722
  }
677
723
 
724
+ /// Serialize a `FeedItem` to a ContentItem-shaped JSON string for the
725
+ /// `onFeedTransition` event. For `.content` cells this is the real
726
+ /// `ContentItem`. For every other cell kind (adSlot, imageCarousel,
727
+ /// survey, videoCarousel) there is no playable `ContentItem`, so we
728
+ /// synthesize a minimal one whose ONLY meaningful field is `id`.
729
+ ///
730
+ /// Why: JS hosts use `event.to` as an identity cursor for resume-on-
731
+ /// tab-return (`setFeedItems(startAt:)`). Before this synthesis, non-
732
+ /// content cells came through as `to=null` and the host's stored
733
+ /// resume id stayed pinned to the last regular video — causing the
734
+ /// SDK to re-seed the feed at the wrong index on tab return, which
735
+ /// in turn caused black carousel cells + wrong audio (see repro
736
+ /// analysis in debug.log, apr 2026).
737
+ ///
738
+ /// Scope: this is a bridge-only synthesis. It never flows back into
739
+ /// the Swift SDK (pool, cache, FeedDataSource, etc. all still see the
740
+ /// real `FeedItem` / `ContentItem?` and treat non-content cells
741
+ /// honestly). JS-side consumers should ONLY read `.id` / `.playbackId`
742
+ /// from `onFeedTransition` items — deeper fields are placeholders for
743
+ /// non-content cells.
744
+ ///
745
+ /// TODO: when we want to expose richer per-cell metadata on the JS
746
+ /// side, migrate to a proper FeedPosition event payload rather than
747
+ /// extending this synthesis. See the long-term fix plan.
748
+ private func serializeFeedItemIdentityJSON(_ feedItem: FeedItem) -> String {
749
+ if let contentItem = feedItem.contentItem {
750
+ return serializeContentItemToJSON(contentItem)
751
+ }
752
+ let synthetic = ContentItem(
753
+ id: feedItem.id,
754
+ playbackId: nil,
755
+ title: "",
756
+ description: nil,
757
+ duration: 0,
758
+ streamingUrl: "",
759
+ thumbnailUrl: "",
760
+ captionTracks: [],
761
+ customMetadata: nil,
762
+ author: nil,
763
+ articleUrl: nil,
764
+ commentCount: nil,
765
+ fallbackUrl: nil
766
+ )
767
+ return serializeContentItemToJSON(synthetic)
768
+ }
769
+
678
770
  /// Build an NSDictionary from a ContentItem with fields matching the JS spec.
679
771
  /// `captionTracks` and `customMetadata` are JSON-serialized strings.
680
772
  private static func contentItemDict(_ item: ContentItem) -> [String: Any] {
@@ -999,7 +1091,14 @@ import ShortKitSDK
999
1091
 
1000
1092
  extension ShortKitBridge: ShortKitDelegate {
1001
1093
  public func shortKit(_ shortKit: ShortKit, didTapContent contentId: String, at index: Int) {
1094
+ // Tag with active surface's feedId. The delegate is a singleton
1095
+ // (the bridge), so we use the activeSurface lookup to attribute
1096
+ // this tap to the feed it originated from. Without the tag, every
1097
+ // mounted <ShortKitFeed> consumer would fire onContentTapped on
1098
+ // every tap — including taps in sibling feeds.
1099
+ let feedId = activeSurfaceFeedId()
1002
1100
  emitOnMain("onContentTapped", body: [
1101
+ "feedId": feedId,
1003
1102
  "contentId": contentId,
1004
1103
  "index": index
1005
1104
  ])
@@ -1030,10 +1129,18 @@ extension ShortKitBridge: ShortKitDelegate {
1030
1129
  self.itemCache[item.id] = item
1031
1130
  }
1032
1131
  }
1132
+ // Capture the feedId synchronously before the async Task — after
1133
+ // the await, activeSurface may have shifted (user swiped tabs).
1134
+ // The fetch semantically belongs to whichever feed initiated it,
1135
+ // which at this delegate-call moment is the active surface.
1136
+ let feedId = activeSurfaceFeedId()
1033
1137
  Task {
1034
1138
  let data = try? JSONEncoder().encode(items)
1035
1139
  let json = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
1036
- self.emitOnMain("onDidFetchContentItems", body: ["items": json])
1140
+ self.emitOnMain("onDidFetchContentItems", body: [
1141
+ "feedId": feedId,
1142
+ "items": json
1143
+ ])
1037
1144
  }
1038
1145
  }
1039
1146
  }