@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.
- package/android/libs/shortkit-release.aar +0 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +8 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +10 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +4 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +45 -33
- package/ios/ReactVideoCarouselOverlayHost.swift +6 -0
- package/ios/ShortKitBridge.swift +142 -35
- package/ios/ShortKitFeedView.swift +43 -44
- package/ios/ShortKitModule.mm +11 -0
- package/ios/ShortKitPlayerNativeView.swift +7 -1
- 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 +950 -126
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +26 -3
- 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 +26 -3
- 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 +950 -126
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +26 -3
- 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 +26 -3
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +950 -126
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +26 -3
- 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 +26 -3
- 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/ios/ShortKitWidgetNativeView.swift +33 -12
- package/package.json +1 -1
- package/src/ShortKitFeed.tsx +34 -0
- package/src/ShortKitPlayer.tsx +25 -15
- package/src/ShortKitWidget.tsx +24 -18
- package/src/index.ts +1 -0
- package/src/serialization.ts +38 -0
- package/src/specs/NativeShortKitModule.ts +7 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
+
embedWidgetIfNeeded()
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
override fun onDetachedFromWindow() {
|
|
46
60
|
super.onDetachedFromWindow()
|
|
61
|
+
removeWidget()
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
private fun
|
|
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,
|
|
73
|
+
view.initialize(sdk, parsedConfig, parsedItems)
|
|
59
74
|
addView(view)
|
|
60
75
|
widgetView = view
|
|
61
|
-
|
|
62
|
-
applyItems()
|
|
63
76
|
}
|
|
64
77
|
|
|
65
|
-
private fun
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
133
|
+
val out = mutableListOf<WidgetInput>()
|
|
114
134
|
for (i in 0 until arr.length()) {
|
|
115
135
|
val obj = arr.getJSONObject(i)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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,
|
package/ios/ShortKitBridge.swift
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
|
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: [
|
|
1140
|
+
self.emitOnMain("onDidFetchContentItems", body: [
|
|
1141
|
+
"feedId": feedId,
|
|
1142
|
+
"items": json
|
|
1143
|
+
])
|
|
1037
1144
|
}
|
|
1038
1145
|
}
|
|
1039
1146
|
}
|