@shortkitsdk/react-native 0.2.35 → 0.2.37
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/build.gradle.kts +8 -0
- package/android/libs/shortkit-release.aar +0 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +94 -46
- package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +46 -7
- package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +233 -27
- package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +252 -27
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +135 -6
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +15 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +21 -11
- package/android/src/test/java/com/shortkit/reactnative/ReactCarouselOverlayHostEmitTest.kt +134 -0
- package/android/src/test/java/com/shortkit/reactnative/ReactOverlayHostDragTest.kt +45 -0
- package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostDragTest.kt +69 -0
- package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostEmitTest.kt +144 -0
- package/android/src/test/java/com/shortkit/reactnative/ShortKitFeedViewActivePropTest.kt +57 -0
- package/ios/ReactOverlayHost.swift +10 -8
- package/ios/ReactVideoCarouselOverlayHost.swift +14 -11
- package/ios/ShortKitBridge.swift +18 -0
- package/ios/ShortKitModule.mm +5 -0
- package/ios/ShortKitPlayerNativeView.swift +36 -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 +932 -84
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +26 -2
- 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 -2
- 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 +932 -84
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +26 -2
- 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 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +932 -84
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +26 -2
- 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 -2
- 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/ShortKitCommands.ts +20 -0
- package/src/ShortKitFeed.tsx +21 -0
- package/src/specs/NativeShortKitModule.ts +10 -0
- package/src/types.ts +35 -0
|
@@ -76,6 +76,70 @@ class ShortKitBridge(
|
|
|
76
76
|
// Static serialization helpers (called by overlay hosts)
|
|
77
77
|
// ------------------------------------------------------------------
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Serialize a [VideoCarouselItem] to a JSON string for bridge transport.
|
|
81
|
+
* SHO-15: used by ReactVideoCarouselOverlayHost when emitting
|
|
82
|
+
* onCarouselActiveVideoCompleted. Mirrors how
|
|
83
|
+
* ReactVideoCarouselOverlayHost.configure already serializes
|
|
84
|
+
* VideoCarouselItem via kotlinx.serialization.
|
|
85
|
+
*/
|
|
86
|
+
fun serializeVideoCarouselItemToJSON(item: com.shortkit.sdk.model.VideoCarouselItem?): String {
|
|
87
|
+
if (item == null) return "null"
|
|
88
|
+
return try {
|
|
89
|
+
kotlinx.serialization.json.Json.encodeToString(
|
|
90
|
+
com.shortkit.sdk.model.VideoCarouselItem.serializer(),
|
|
91
|
+
item,
|
|
92
|
+
)
|
|
93
|
+
} catch (_: Exception) {
|
|
94
|
+
"null"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Serialize a [FeedItem] to a ContentItem-shaped JSON string for the
|
|
100
|
+
* `onFeedTransition` event. For [FeedItem.Content] cells this is the
|
|
101
|
+
* real ContentItem. For every other cell kind (AdSlotItem,
|
|
102
|
+
* ImageCarousel, Survey, VideoCarousel) there is no playable
|
|
103
|
+
* ContentItem, so we synthesize a minimal one whose ONLY meaningful
|
|
104
|
+
* field is `id`.
|
|
105
|
+
*
|
|
106
|
+
* Why: JS hosts use `event.to` as an identity cursor for resume-on-
|
|
107
|
+
* tab-return (`setFeedItems(startAt:)`). Before this synthesis, non-
|
|
108
|
+
* content cells came through as `to=null` and the host's stored
|
|
109
|
+
* resume id stayed pinned to the last regular video — causing the
|
|
110
|
+
* SDK to re-seed the feed at the wrong index on tab return.
|
|
111
|
+
*
|
|
112
|
+
* Scope: this is a bridge-only synthesis. It never flows back into
|
|
113
|
+
* the Android SDK (pool, cache, FeedDataSource, etc. all still see
|
|
114
|
+
* the real FeedItem and treat non-content cells honestly). JS-side
|
|
115
|
+
* consumers should ONLY read `.id` / `.playbackId` from
|
|
116
|
+
* `onFeedTransition` items — deeper fields are placeholders for
|
|
117
|
+
* non-content cells.
|
|
118
|
+
*
|
|
119
|
+
* Mirrors iOS PR #170 ShortKitBridge.swift:770-790.
|
|
120
|
+
*/
|
|
121
|
+
fun serializeFeedItemIdentityJSON(feedItem: com.shortkit.sdk.model.FeedItem): String {
|
|
122
|
+
if (feedItem is com.shortkit.sdk.model.FeedItem.Content) {
|
|
123
|
+
return serializeContentItemToJSON(feedItem.item)
|
|
124
|
+
}
|
|
125
|
+
val synthetic = ContentItem(
|
|
126
|
+
id = feedItem.id,
|
|
127
|
+
playbackId = null,
|
|
128
|
+
title = "",
|
|
129
|
+
description = null,
|
|
130
|
+
duration = 0.0,
|
|
131
|
+
streamingUrl = "",
|
|
132
|
+
thumbnailUrl = "",
|
|
133
|
+
captionTracks = emptyList(),
|
|
134
|
+
customMetadata = null,
|
|
135
|
+
author = null,
|
|
136
|
+
articleUrl = null,
|
|
137
|
+
commentCount = null,
|
|
138
|
+
fallbackUrl = null,
|
|
139
|
+
)
|
|
140
|
+
return serializeContentItemToJSON(synthetic)
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
/**
|
|
80
144
|
* Serialize a [ContentItem] to a JSON string for bridge transport.
|
|
81
145
|
*/
|
|
@@ -349,7 +413,7 @@ class ShortKitBridge(
|
|
|
349
413
|
} catch (_: Exception) { null }
|
|
350
414
|
}
|
|
351
415
|
|
|
352
|
-
|
|
416
|
+
internal fun parseFeedInputs(json: String): List<FeedInput>? {
|
|
353
417
|
return try {
|
|
354
418
|
val arr = JSONArray(json)
|
|
355
419
|
val result = mutableListOf<FeedInput>()
|
|
@@ -535,7 +599,12 @@ class ShortKitBridge(
|
|
|
535
599
|
// Wire delegate
|
|
536
600
|
sdk.delegate = object : ShortKitDelegate {
|
|
537
601
|
override fun onContentTapped(contentId: String, index: Int) {
|
|
602
|
+
// Capture feedId synchronously — after the post-to-main hop
|
|
603
|
+
// the active surface may have shifted (user swiped tabs).
|
|
604
|
+
// Mirrors iOS ShortKitBridge.swift:1174 pattern.
|
|
605
|
+
val feedId = activeSurfaceFeedId()
|
|
538
606
|
val params = Arguments.createMap().apply {
|
|
607
|
+
putString("feedId", feedId)
|
|
539
608
|
putString("contentId", contentId)
|
|
540
609
|
putInt("index", index)
|
|
541
610
|
}
|
|
@@ -550,7 +619,9 @@ class ShortKitBridge(
|
|
|
550
619
|
lastProgressEmitTime = now
|
|
551
620
|
}
|
|
552
621
|
|
|
622
|
+
val feedId = activeSurfaceFeedId()
|
|
553
623
|
val params = Arguments.createMap().apply {
|
|
624
|
+
putString("feedId", feedId)
|
|
554
625
|
when (state) {
|
|
555
626
|
is ShortKitRefreshState.Idle -> {
|
|
556
627
|
putString("status", "idle")
|
|
@@ -574,11 +645,13 @@ class ShortKitBridge(
|
|
|
574
645
|
}
|
|
575
646
|
|
|
576
647
|
override fun onFeedContentFetched(items: List<ContentItem>) {
|
|
648
|
+
val feedId = activeSurfaceFeedId()
|
|
577
649
|
val arr = org.json.JSONArray()
|
|
578
650
|
for (item in items) {
|
|
579
651
|
arr.put(org.json.JSONObject(serializeContentItemToJSON(item)))
|
|
580
652
|
}
|
|
581
653
|
val params = Arguments.createMap().apply {
|
|
654
|
+
putString("feedId", feedId)
|
|
582
655
|
putString("items", arr.toString())
|
|
583
656
|
}
|
|
584
657
|
emitEventOnMain("onDidFetchContentItems", params)
|
|
@@ -640,6 +713,57 @@ class ShortKitBridge(
|
|
|
640
713
|
}
|
|
641
714
|
emitEventOnMain("onFeedReady", params)
|
|
642
715
|
}
|
|
716
|
+
// Per-feed pull-to-refresh — the JS-side ShortKitFeed subscribes
|
|
717
|
+
// to `onRefreshStateChangedPerFeed` (not the unscoped `onRefreshStateChanged`),
|
|
718
|
+
// so this is the event that actually drives the host's refresh
|
|
719
|
+
// handler. Mirrors iOS ShortKitBridge.swift:74-90 emit pattern.
|
|
720
|
+
fragment.onRefreshStateChanged = { state ->
|
|
721
|
+
// Throttle pulling events to max 1 per 16ms to avoid bridge saturation
|
|
722
|
+
if (state is ShortKitRefreshState.Pulling) {
|
|
723
|
+
val now = android.os.SystemClock.uptimeMillis()
|
|
724
|
+
if (now - lastProgressEmitTime >= 16L) {
|
|
725
|
+
lastProgressEmitTime = now
|
|
726
|
+
emitRefreshPerFeed(id, state)
|
|
727
|
+
}
|
|
728
|
+
} else {
|
|
729
|
+
emitRefreshPerFeed(id, state)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
fragment.onVideoCarouselCellTap = { payload ->
|
|
733
|
+
emitVideoCarouselCellTap(payload.id, payload.index, payload.pageIndex)
|
|
734
|
+
}
|
|
735
|
+
// Per-feed transition event. Fires once per transition, bound to this
|
|
736
|
+
// fragment. Replaces the global player.feedTransition.collect pattern
|
|
737
|
+
// (which fanned every feed's transitions out to every mounted
|
|
738
|
+
// <ShortKitFeed>, causing cross-feed state pollution). Structural fix:
|
|
739
|
+
// callback literally cannot fire on the wrong feed. Mirrors iOS
|
|
740
|
+
// PR #170 ShortKitBridge.swift:117-143.
|
|
741
|
+
fragment.onFeedTransition = { event ->
|
|
742
|
+
val params = Arguments.createMap().apply {
|
|
743
|
+
putString("feedId", id)
|
|
744
|
+
putString("phase", when (event.phase) {
|
|
745
|
+
FeedTransitionPhase.BEGAN -> "began"
|
|
746
|
+
FeedTransitionPhase.ENDED -> "ended"
|
|
747
|
+
})
|
|
748
|
+
putString("direction", when (event.direction) {
|
|
749
|
+
FeedDirection.FORWARD -> "forward"
|
|
750
|
+
FeedDirection.BACKWARD -> "backward"
|
|
751
|
+
else -> "forward"
|
|
752
|
+
})
|
|
753
|
+
// Serialize from the underlying FeedItem (not event.from/to
|
|
754
|
+
// which are null for non-content cells). See the docstring on
|
|
755
|
+
// serializeFeedItemIdentityJSON for the why — in short, this
|
|
756
|
+
// ensures carousels/ads/surveys come through with a populated
|
|
757
|
+
// id, so JS-side hosts can track feed position for any cell type.
|
|
758
|
+
event.fromFeedItem?.let {
|
|
759
|
+
putString("fromItem", serializeFeedItemIdentityJSON(it))
|
|
760
|
+
}
|
|
761
|
+
event.toFeedItem?.let {
|
|
762
|
+
putString("toItem", serializeFeedItemIdentityJSON(it))
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
emitEventOnMain("onFeedTransition", params)
|
|
766
|
+
}
|
|
643
767
|
|
|
644
768
|
// Replay buffered operations after the fragment's view is created.
|
|
645
769
|
// commitNowAllowingStateLoss triggers onCreate but onViewCreated
|
|
@@ -669,6 +793,61 @@ class ShortKitBridge(
|
|
|
669
793
|
return feedRegistry[id]?.get()
|
|
670
794
|
}
|
|
671
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Emit `onRefreshStateChangedPerFeed` with feedId in the payload.
|
|
798
|
+
* Used by the per-feed [ShortKitFeedFragment.onRefreshStateChanged]
|
|
799
|
+
* hook wired in [registerFeedFragment]. Mirrors iOS
|
|
800
|
+
* `ShortKitBridge.swift:74-90` body construction.
|
|
801
|
+
*/
|
|
802
|
+
private fun emitRefreshPerFeed(feedId: String, state: ShortKitRefreshState) {
|
|
803
|
+
val params = Arguments.createMap().apply {
|
|
804
|
+
putString("feedId", feedId)
|
|
805
|
+
when (state) {
|
|
806
|
+
is ShortKitRefreshState.Idle -> {
|
|
807
|
+
putString("status", "idle")
|
|
808
|
+
putDouble("progress", 0.0)
|
|
809
|
+
}
|
|
810
|
+
is ShortKitRefreshState.Pulling -> {
|
|
811
|
+
putString("status", "pulling")
|
|
812
|
+
putDouble("progress", state.progress.toDouble())
|
|
813
|
+
}
|
|
814
|
+
is ShortKitRefreshState.Triggered -> {
|
|
815
|
+
putString("status", "triggered")
|
|
816
|
+
putDouble("progress", 0.0)
|
|
817
|
+
}
|
|
818
|
+
is ShortKitRefreshState.Refreshing -> {
|
|
819
|
+
putString("status", "refreshing")
|
|
820
|
+
putDouble("progress", 0.0)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
emitEventOnMain("onRefreshStateChangedPerFeed", params)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Reverse lookup: which registered feedId corresponds to the SDK's
|
|
829
|
+
* currently-active surface? Returns the empty string when no feed
|
|
830
|
+
* surface is active or when the active surface isn't a registered
|
|
831
|
+
* fragment (e.g. host-driven scenario without RN bridge mounting).
|
|
832
|
+
*
|
|
833
|
+
* Used by the three delegate-level emit sites
|
|
834
|
+
* (`onContentTapped`, `onRefreshStateChanged`, `onDidFetchContentItems`)
|
|
835
|
+
* so multi-feed setups route the callback to the originating
|
|
836
|
+
* `<ShortKitFeed>` instead of broadcasting to all mounted feeds.
|
|
837
|
+
*
|
|
838
|
+
* Mirrors iOS `ShortKitBridge.activeSurfaceFeedId()`
|
|
839
|
+
* (`react_native_sdk/ios/ShortKitBridge.swift:1174`). Capture this
|
|
840
|
+
* synchronously at delegate-call time; after any await/post the
|
|
841
|
+
* active surface may have shifted (user swiped tabs).
|
|
842
|
+
*/
|
|
843
|
+
fun activeSurfaceFeedId(): String {
|
|
844
|
+
val active = shortKit?.activeSurface ?: return ""
|
|
845
|
+
for ((id, ref) in feedRegistry) {
|
|
846
|
+
if (ref.get() === active) return id
|
|
847
|
+
}
|
|
848
|
+
return ""
|
|
849
|
+
}
|
|
850
|
+
|
|
672
851
|
fun registerFeed(id: String) {
|
|
673
852
|
// No-op — registerFeedFragment handles drain
|
|
674
853
|
}
|
|
@@ -735,6 +914,25 @@ class ShortKitBridge(
|
|
|
735
914
|
emitEvent.invoke(name, params)
|
|
736
915
|
}
|
|
737
916
|
|
|
917
|
+
/**
|
|
918
|
+
* Emit `onVideoCarouselCellTap` with the active surface's feedId and
|
|
919
|
+
* the cell-supplied (id, index, pageIndex). Mirrors iOS
|
|
920
|
+
* ShortKitBridge.swift:94-101.
|
|
921
|
+
*
|
|
922
|
+
* Called from the cell's onCellTap closure (installed in
|
|
923
|
+
* ShortKitFeedFragment) — see Task 5.
|
|
924
|
+
*/
|
|
925
|
+
internal fun emitVideoCarouselCellTap(itemId: String, index: Int, pageIndex: Int) {
|
|
926
|
+
val feedId = activeSurfaceFeedId()
|
|
927
|
+
val params = Arguments.createMap().apply {
|
|
928
|
+
putString("feedId", feedId)
|
|
929
|
+
putString("id", itemId)
|
|
930
|
+
putInt("index", index)
|
|
931
|
+
putInt("pageIndex", pageIndex)
|
|
932
|
+
}
|
|
933
|
+
emitEventOnMain("onVideoCarouselCellTap", params)
|
|
934
|
+
}
|
|
935
|
+
|
|
738
936
|
// ------------------------------------------------------------------
|
|
739
937
|
// Player commands
|
|
740
938
|
// ------------------------------------------------------------------
|
|
@@ -753,6 +951,24 @@ class ShortKitBridge(
|
|
|
753
951
|
fun skipToNext() { runOnMain { shortKit?.player?.skipToNext() } }
|
|
754
952
|
fun skipToPrevious() { runOnMain { shortKit?.player?.skipToPrevious() } }
|
|
755
953
|
fun setMuted(muted: Boolean) { runOnMain { shortKit?.player?.setMuted(muted) } }
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Toggle outer feed scroll on every registered feed. Note this
|
|
957
|
+
* intentionally diverges from iOS: iOS routes the same command only
|
|
958
|
+
* to the *active* feed (via player.onCommand), but Android fans out
|
|
959
|
+
* to all registered feeds — including masked-feed underlays. The
|
|
960
|
+
* user-visible effect is the same (the active feed's scroll is
|
|
961
|
+
* locked) and fanning out costs nothing, avoiding an active-surface
|
|
962
|
+
* lookup at this layer. Must run on main: viewPager.isUserInputEnabled
|
|
963
|
+
* mutates RecyclerView state, and feedRegistry is mutated on main
|
|
964
|
+
* during register/unregister — concurrent iteration would race.
|
|
965
|
+
*/
|
|
966
|
+
fun setFeedScrollEnabled(enabled: Boolean) = runOnMain {
|
|
967
|
+
feedRegistry.values.forEach { ref ->
|
|
968
|
+
ref.get()?.setFeedScrollEnabled(enabled)
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
756
972
|
fun setPlaybackRate(rate: Double) { runOnMain { shortKit?.player?.setPlaybackRate(rate.toFloat()) } }
|
|
757
973
|
fun setCaptionsEnabled(enabled: Boolean) { runOnMain { shortKit?.player?.setCaptionsEnabled(enabled) } }
|
|
758
974
|
fun selectCaptionTrack(language: String) { runOnMain { shortKit?.player?.selectCaptionTrack(language) } }
|
|
@@ -762,6 +978,17 @@ class ShortKitBridge(
|
|
|
762
978
|
}
|
|
763
979
|
fun setMaxBitrate(bitrate: Double) { runOnMain { shortKit?.player?.setMaxBitrate(bitrate.toInt()) } }
|
|
764
980
|
|
|
981
|
+
// ---- SHO-15: ShortKitCarousel imperative methods + sync accessors ----
|
|
982
|
+
// ShortKitCarousel's onMain shim handles cross-thread routing internally,
|
|
983
|
+
// so we DON'T wrap with bridge.runOnMain — the namespace's threading
|
|
984
|
+
// contract already routes to main + blocks on the latch when called from
|
|
985
|
+
// a background thread.
|
|
986
|
+
fun carouselNext(): Boolean = shortKit?.carousel?.next() ?: false
|
|
987
|
+
fun carouselPrevious(): Boolean = shortKit?.carousel?.previous() ?: false
|
|
988
|
+
fun carouselSetActiveIndex(index: Int): Boolean = shortKit?.carousel?.setActiveIndex(index) ?: false
|
|
989
|
+
fun carouselActiveIndex(): Int = shortKit?.carousel?.activeIndexValue ?: -1
|
|
990
|
+
fun carouselVideoCount(): Int = shortKit?.carousel?.videoCountValue ?: 0
|
|
991
|
+
|
|
765
992
|
fun setUserId(userId: String) {
|
|
766
993
|
shortKit?.setUserId(userId)
|
|
767
994
|
}
|
|
@@ -1090,10 +1317,16 @@ class ShortKitBridge(
|
|
|
1090
1317
|
}
|
|
1091
1318
|
}
|
|
1092
1319
|
|
|
1093
|
-
// Did loop
|
|
1320
|
+
// Did loop — tagged with active surface's feedId. player.didLoop is a
|
|
1321
|
+
// singleton flow on the shared ShortKitPlayer (only one item plays at
|
|
1322
|
+
// a time). The event semantically belongs to whichever feed owns the
|
|
1323
|
+
// active surface; tag with that feedId so JS consumers bound to
|
|
1324
|
+
// specific feeds can filter. Empty string means no active surface
|
|
1325
|
+
// (treat as unknown/global). Mirrors iOS bridge :615-631.
|
|
1094
1326
|
newScope.launch {
|
|
1095
1327
|
player.didLoop.collect { event ->
|
|
1096
1328
|
val params = Arguments.createMap().apply {
|
|
1329
|
+
putString("feedId", activeSurfaceFeedId())
|
|
1097
1330
|
putString("contentId", event.contentId)
|
|
1098
1331
|
putInt("loopCount", event.loopCount)
|
|
1099
1332
|
}
|
|
@@ -1101,34 +1334,26 @@ class ShortKitBridge(
|
|
|
1101
1334
|
}
|
|
1102
1335
|
}
|
|
1103
1336
|
|
|
1104
|
-
//
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
if (event.to != null) {
|
|
1121
|
-
putString("toItem", serializeContentItemToJSON(event.to!!))
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
emitEvent.invoke("onFeedTransition", params)
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// Format change
|
|
1337
|
+
// NOTE: the global `player.feedTransition.collect` subscription used
|
|
1338
|
+
// to live here. It's been removed — feed transitions are now emitted
|
|
1339
|
+
// to RN via the per-fragment `fragment.onFeedTransition` closure
|
|
1340
|
+
// wired in `registerFeedFragment` above. This eliminates the cross-
|
|
1341
|
+
// feed routing bug where every mounted <ShortKitFeed> consumer
|
|
1342
|
+
// received every feed's transitions. Mirrors iOS PR #170
|
|
1343
|
+
// ShortKitBridge.swift:633-642.
|
|
1344
|
+
//
|
|
1345
|
+
// `player.sendFeedTransition` is still called by ShortKitFeedFragment
|
|
1346
|
+
// for backward compatibility with native Kotlin SDK consumers that
|
|
1347
|
+
// subscribe to `ShortKitPlayer.feedTransition` directly.
|
|
1348
|
+
|
|
1349
|
+
// Format change — tagged with active surface's feedId. Same reasoning
|
|
1350
|
+
// as onDidLoop: fires from shared player singleton, but semantically
|
|
1351
|
+
// belongs to whichever feed owns the active surface. Mirrors iOS
|
|
1352
|
+
// bridge :671-688.
|
|
1129
1353
|
newScope.launch {
|
|
1130
1354
|
player.formatChange.collect { event ->
|
|
1131
1355
|
val params = Arguments.createMap().apply {
|
|
1356
|
+
putString("feedId", activeSurfaceFeedId())
|
|
1132
1357
|
putString("contentId", event.contentId)
|
|
1133
1358
|
putDouble("fromBitrate", event.fromBitrate.toDouble())
|
|
1134
1359
|
putDouble("toBitrate", event.toBitrate.toDouble())
|
|
@@ -30,6 +30,15 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
30
30
|
var feedId: String? = null
|
|
31
31
|
var startAtItemId: String? = null
|
|
32
32
|
var preloadId: String? = null
|
|
33
|
+
/**
|
|
34
|
+
* JSON-encoded `FeedInput[]` from the `feedItems` JS prop. Parsed at
|
|
35
|
+
* fragment-construction time only — post-mount changes are a no-op
|
|
36
|
+
* (matches iOS PR #150). Host apps that need post-mount item updates
|
|
37
|
+
* use the imperative `ShortKit.setFeedItems(feedId, items)` API.
|
|
38
|
+
*
|
|
39
|
+
* `preloadId` takes precedence when both are set.
|
|
40
|
+
*/
|
|
41
|
+
var feedItemsJSON: String? = null
|
|
33
42
|
// Fabric's generated delegate maps an absent boolean prop to the Java
|
|
34
43
|
// primitive false, so Boolean? is always non-null from the bridge. Track
|
|
35
44
|
// whether the prop was explicitly set so we can fall back to the provider
|
|
@@ -37,6 +46,62 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
37
46
|
var debugPanel: Boolean = false
|
|
38
47
|
var debugPanelPropSet: Boolean = false
|
|
39
48
|
|
|
49
|
+
/**
|
|
50
|
+
* URL of the thumbnail the host app is already rendering for the item that
|
|
51
|
+
* will be the first active cell. Looked up synchronously via Glide's cache
|
|
52
|
+
* (10ms cap; supports expo-image / FastImage clients) and seeded onto the
|
|
53
|
+
* fragment before its first lifecycle event. Falls back to the existing
|
|
54
|
+
* patchMissingThumbnailIfNeeded helper on cache miss or timeout.
|
|
55
|
+
*
|
|
56
|
+
* Mirrors iOS ShortKitFeedView.seedThumbnailUrl
|
|
57
|
+
* (react_native_sdk/ios/ShortKitFeedView.swift:28).
|
|
58
|
+
*/
|
|
59
|
+
var seedThumbnailUrl: String? = null
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Host-driven activation state, set by the `active` JS prop. Defaults to
|
|
63
|
+
* `true`. Mirrors iOS [`ShortKitFeedView.active`](react_native_sdk/ios/ShortKitFeedView.swift:46).
|
|
64
|
+
*/
|
|
65
|
+
var active: Boolean = true
|
|
66
|
+
internal set
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tracks whether `active` was explicitly set by the host (vs. defaulted).
|
|
70
|
+
* Same purpose as `debugPanelPropSet` — distinguishes "absent" from "false"
|
|
71
|
+
* since Fabric maps absent boolean props to the Java primitive `false`.
|
|
72
|
+
*
|
|
73
|
+
* Used only by the first-mount race fix in [embedFeedFragmentIfNeeded] to
|
|
74
|
+
* decide whether a `false` value is a real host intent or a Fabric default.
|
|
75
|
+
*/
|
|
76
|
+
var activePropSet: Boolean = false
|
|
77
|
+
internal set
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set the `active` prop from the bridge. The canonical entry point for
|
|
81
|
+
* all `active` state transitions — do not mutate the field directly.
|
|
82
|
+
*
|
|
83
|
+
* Behavior:
|
|
84
|
+
* - Always sets `activePropSet = true`.
|
|
85
|
+
* - On transition while a fragment is embedded:
|
|
86
|
+
* - `true → false`: calls `fragment.deactivate()`.
|
|
87
|
+
* - `false → true`: calls `fragment.activate()`.
|
|
88
|
+
* - Returns early if no fragment is yet embedded — the first-mount race
|
|
89
|
+
* in [embedFeedFragmentIfNeeded] handles the deferred deactivate case.
|
|
90
|
+
*
|
|
91
|
+
* Invoked by [ShortKitFeedViewManager.setActive].
|
|
92
|
+
*/
|
|
93
|
+
fun setActiveFromBridge(value: Boolean) {
|
|
94
|
+
val prev = active
|
|
95
|
+
active = value
|
|
96
|
+
activePropSet = true
|
|
97
|
+
val fragment = feedFragment ?: return
|
|
98
|
+
if (prev && !value) {
|
|
99
|
+
fragment.deactivate()
|
|
100
|
+
} else if (!prev && value) {
|
|
101
|
+
fragment.activate()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
40
105
|
// -----------------------------------------------------------------------
|
|
41
106
|
// Fragment management
|
|
42
107
|
// -----------------------------------------------------------------------
|
|
@@ -140,7 +205,15 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
140
205
|
synchronized(ShortKitBridge.staticPendingFeedViews) {
|
|
141
206
|
ShortKitBridge.staticPendingFeedViews.remove(this)
|
|
142
207
|
}
|
|
143
|
-
|
|
208
|
+
// Mirror iOS willMove(toWindow:) at react_native_sdk/ios/ShortKitFeedView.swift:91-99 —
|
|
209
|
+
// suspend only if leaving the window AND the prop says we should be active.
|
|
210
|
+
// When active=false the host has already driven the fragment into deactivate
|
|
211
|
+
// (or will once setActiveFromBridge runs), and we mustn't double-suspend.
|
|
212
|
+
// Default active=true preserves legacy window-inference behavior for hosts
|
|
213
|
+
// that never set the active prop.
|
|
214
|
+
if (active) {
|
|
215
|
+
suspendFeedFragment()
|
|
216
|
+
}
|
|
144
217
|
super.onDetachedFromWindow()
|
|
145
218
|
}
|
|
146
219
|
|
|
@@ -167,7 +240,11 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
167
240
|
.beginTransaction()
|
|
168
241
|
.show(existingFragment)
|
|
169
242
|
.commitNowAllowingStateLoss()
|
|
170
|
-
|
|
243
|
+
// Mirror iOS: only auto-activate if the host's active prop says so.
|
|
244
|
+
// Default active=true preserves legacy "always activate on un-hide" behavior.
|
|
245
|
+
if (active) {
|
|
246
|
+
existingFragment.activate()
|
|
247
|
+
}
|
|
171
248
|
} catch (e: Exception) {
|
|
172
249
|
Log.e(TAG, "Failed to show feed fragment", e)
|
|
173
250
|
}
|
|
@@ -188,24 +265,58 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
188
265
|
|
|
189
266
|
var feedConfig = ShortKitBridge.parseFeedConfig(config ?: "{}", context)
|
|
190
267
|
|
|
191
|
-
|
|
192
|
-
|
|
268
|
+
val explicitPreloadId = preloadId
|
|
269
|
+
val explicitFeedItemsJSON = feedItemsJSON
|
|
270
|
+
if (explicitPreloadId != null) {
|
|
271
|
+
val preload = ShortKitBridge.shared?.consumePreload(explicitPreloadId)
|
|
193
272
|
if (preload != null) {
|
|
194
273
|
feedConfig = feedConfig.copy(preload = preload)
|
|
195
274
|
}
|
|
275
|
+
} else if (explicitFeedItemsJSON != null) {
|
|
276
|
+
// Mount-time custom-feed fast-path (SHO-29 / iOS PR #150).
|
|
277
|
+
// Parse JSON → FeedInput[] → sdk.preloadFeed(items=) which
|
|
278
|
+
// internally calls FeedPreload.forCustomItems with the SDK's
|
|
279
|
+
// own scope. Assign to feedConfig.preload BEFORE constructing
|
|
280
|
+
// the fragment, so FeedPreload.consume() returns synchronously
|
|
281
|
+
// on the first cell bind. Requires Task 1's sync-payload fix
|
|
282
|
+
// in FeedPreload.kt.
|
|
283
|
+
//
|
|
284
|
+
// Using sdk.preloadFeed(items=) (the public SDK entry point at
|
|
285
|
+
// ShortKit.kt:232) instead of FeedPreload.forCustomItems
|
|
286
|
+
// directly — avoids needing to promote internal SDK helpers
|
|
287
|
+
// for cross-module access from the bridge.
|
|
288
|
+
val parsed = ShortKitBridge.parseFeedInputs(explicitFeedItemsJSON)
|
|
289
|
+
if (parsed != null && parsed.isNotEmpty()) {
|
|
290
|
+
val preload = sdk.preloadFeed(items = parsed)
|
|
291
|
+
feedConfig = feedConfig.copy(preload = preload)
|
|
292
|
+
} else {
|
|
293
|
+
Log.w(TAG, "feedItemsJSON parsed to null/empty — ignoring (config feedSource will drive the fetch path)")
|
|
294
|
+
}
|
|
196
295
|
}
|
|
197
296
|
|
|
198
297
|
val fragment = ShortKitFeedFragment.newInstance(sdk, feedConfig, startAtItemId)
|
|
199
298
|
|
|
200
299
|
val debugPanelEnabled = if (debugPanelPropSet) debugPanel else sdk.debugPanelEnabled
|
|
201
300
|
if (debugPanelEnabled) {
|
|
202
|
-
fragment.debugPanelFactory = {
|
|
301
|
+
fragment.debugPanelFactory = { activeMetrics, prev, next, lifecycleOwner ->
|
|
203
302
|
com.shortkit.sdk.debug.DebugPanelView(context).also { panel ->
|
|
204
|
-
panel.subscribe(
|
|
303
|
+
panel.subscribe(activeMetrics, prev, next, lifecycleOwner)
|
|
205
304
|
}
|
|
206
305
|
}
|
|
207
306
|
}
|
|
208
307
|
|
|
308
|
+
// SHO-11: seed a thumbnail from the host app's image cache so the first
|
|
309
|
+
// cell renders with a visible thumbnail from frame zero. Synchronous
|
|
310
|
+
// Glide lookup (memory-only via 10ms cap) — no network. If null (cache
|
|
311
|
+
// miss, timeout, or Glide not linked), the existing
|
|
312
|
+
// patchMissingThumbnailIfNeeded helper handles the fallback at feed-open
|
|
313
|
+
// time. Mirrors iOS ShortKitFeedView.swift:170-173.
|
|
314
|
+
seedThumbnailUrl?.let { url ->
|
|
315
|
+
com.shortkit.sdk.feed.SeedThumbnailResolver.resolveFromCache(context, url)?.let { bitmap ->
|
|
316
|
+
fragment.seedThumbnail = bitmap
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
209
320
|
try {
|
|
210
321
|
activity.supportFragmentManager
|
|
211
322
|
.beginTransaction()
|
|
@@ -213,6 +324,24 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
213
324
|
.commitNowAllowingStateLoss()
|
|
214
325
|
this.feedFragment = fragment
|
|
215
326
|
|
|
327
|
+
// Android-specific first-mount race: Fabric may have set active=false
|
|
328
|
+
// BEFORE we got to embed the fragment. The fragment self-claims the
|
|
329
|
+
// player pool in onResume (ShortKitFeedFragment.kt:2112), so by the
|
|
330
|
+
// time commitNowAllowingStateLoss returns the fragment is already
|
|
331
|
+
// active. Retroactively deactivate to honor the host's intent.
|
|
332
|
+
//
|
|
333
|
+
// Note: this produces a brief claim-then-yield window (microseconds).
|
|
334
|
+
// Acceptable for normal use; if a host needs strictly never-active
|
|
335
|
+
// mounts, the fragment's onResume self-claim would need a managed-mode
|
|
336
|
+
// flag to suppress (separate ticket).
|
|
337
|
+
//
|
|
338
|
+
// activePropSet guard: only fires when the host explicitly set the prop.
|
|
339
|
+
// Fabric maps absent booleans to Java false, so without this guard
|
|
340
|
+
// every fragment would deactivate at mount.
|
|
341
|
+
if (activePropSet && !active) {
|
|
342
|
+
fragment.deactivate()
|
|
343
|
+
}
|
|
344
|
+
|
|
216
345
|
// View.generateViewId() can return IDs that collide with Fabric-
|
|
217
346
|
// managed views (e.g. ReactSurfaceView also gets id=1). When that
|
|
218
347
|
// happens, FragmentManager.replace() places the fragment view in
|
|
@@ -35,12 +35,27 @@ class ShortKitFeedViewManager : SimpleViewManager<ShortKitFeedView>() {
|
|
|
35
35
|
view.preloadId = preloadId
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
@ReactProp(name = "feedItemsJSON")
|
|
39
|
+
fun setFeedItemsJSON(view: ShortKitFeedView, feedItemsJSON: String?) {
|
|
40
|
+
view.feedItemsJSON = feedItemsJSON
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
@ReactProp(name = "debugPanel")
|
|
39
44
|
fun setDebugPanel(view: ShortKitFeedView, debugPanel: Boolean) {
|
|
40
45
|
view.debugPanel = debugPanel
|
|
41
46
|
view.debugPanelPropSet = true
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
@ReactProp(name = "active")
|
|
50
|
+
fun setActive(view: ShortKitFeedView, active: Boolean) {
|
|
51
|
+
view.setActiveFromBridge(active)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@ReactProp(name = "seedThumbnailUrl")
|
|
55
|
+
fun setSeedThumbnailUrl(view: ShortKitFeedView, url: String?) {
|
|
56
|
+
view.seedThumbnailUrl = url
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
override fun onDropViewInstance(view: ShortKitFeedView) {
|
|
45
60
|
view.destroy()
|
|
46
61
|
super.onDropViewInstance(view)
|
|
@@ -136,6 +136,11 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
136
136
|
@ReactMethod
|
|
137
137
|
override fun pause() { bridge?.pause() }
|
|
138
138
|
|
|
139
|
+
@ReactMethod
|
|
140
|
+
override fun setFeedScrollEnabled(enabled: Boolean) {
|
|
141
|
+
bridge?.setFeedScrollEnabled(enabled)
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
@ReactMethod
|
|
140
145
|
override fun seek(seconds: Double) { bridge?.seek(seconds) }
|
|
141
146
|
|
|
@@ -149,28 +154,30 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
149
154
|
override fun skipToPrevious() { bridge?.skipToPrevious() }
|
|
150
155
|
|
|
151
156
|
// -----------------------------------------------------------------
|
|
152
|
-
// Carousel commands —
|
|
153
|
-
//
|
|
157
|
+
// Carousel commands — bridge to ShortKit.activeInstance().carousel.
|
|
158
|
+
// Mirrors iOS RN bridge from PR #136. SHO-15.
|
|
159
|
+
// Boundary semantics: returns false / -1 / 0 when no ShortKit instance
|
|
160
|
+
// is active or no fragment is bound, matching iOS `?? false` / `?? nil`
|
|
161
|
+
// fallbacks via the namespace's onMain shim + null-tolerance.
|
|
154
162
|
// -----------------------------------------------------------------
|
|
155
163
|
|
|
156
164
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
157
|
-
override fun carouselNext(): Boolean = false
|
|
165
|
+
override fun carouselNext(): Boolean = bridge?.carouselNext() ?: false
|
|
158
166
|
|
|
159
167
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
160
|
-
override fun carouselPrevious(): Boolean = false
|
|
168
|
+
override fun carouselPrevious(): Boolean = bridge?.carouselPrevious() ?: false
|
|
161
169
|
|
|
162
170
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
163
|
-
override fun carouselSetActiveIndex(index: Double): Boolean =
|
|
164
|
-
|
|
165
|
-
// Carousel accessors — stubs for PR 1.
|
|
166
|
-
// onCarouselActiveVideoCompleted emitter intentionally unwired — never fires on Android in PR 1.
|
|
167
|
-
// PR 2 will subscribe to ShortKit.activeInstance.get().carousel.activeVideoCompleted.
|
|
171
|
+
override fun carouselSetActiveIndex(index: Double): Boolean =
|
|
172
|
+
bridge?.carouselSetActiveIndex(index.toInt()) ?: false
|
|
168
173
|
|
|
169
174
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
170
|
-
override fun getCarouselActiveIndex(): Double =
|
|
175
|
+
override fun getCarouselActiveIndex(): Double =
|
|
176
|
+
(bridge?.carouselActiveIndex() ?: -1).toDouble()
|
|
171
177
|
|
|
172
178
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
173
|
-
override fun getCarouselVideoCount(): Double =
|
|
179
|
+
override fun getCarouselVideoCount(): Double =
|
|
180
|
+
(bridge?.carouselVideoCount() ?: 0).toDouble()
|
|
174
181
|
|
|
175
182
|
@ReactMethod
|
|
176
183
|
override fun setMuted(muted: Boolean) { bridge?.setMuted(muted) }
|
|
@@ -326,6 +333,7 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
326
333
|
"onContentTapped" -> emitOnContentTapped(params)
|
|
327
334
|
"onDismiss" -> emitOnDismiss(params)
|
|
328
335
|
"onRefreshStateChanged" -> emitOnRefreshStateChanged(params)
|
|
336
|
+
"onRefreshStateChangedPerFeed" -> emitOnRefreshStateChangedPerFeed(params)
|
|
329
337
|
"onDidFetchContentItems" -> emitOnDidFetchContentItems(params)
|
|
330
338
|
"onFeedReady" -> emitOnFeedReady(params)
|
|
331
339
|
"onOverlayActiveChanged" -> emitOnOverlayActiveChanged(params)
|
|
@@ -341,8 +349,10 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
341
349
|
"onCarouselActiveImageChanged" -> emitOnCarouselActiveImageChanged(params)
|
|
342
350
|
"onCarouselItemChanged" -> emitOnCarouselItemChanged(params)
|
|
343
351
|
"onVideoCarouselActiveVideoChanged" -> emitOnVideoCarouselActiveVideoChanged(params)
|
|
352
|
+
"onVideoCarouselCellTap" -> emitOnVideoCarouselCellTap(params)
|
|
344
353
|
"onVideoCarouselItemChanged" -> emitOnVideoCarouselItemChanged(params)
|
|
345
354
|
"onCarouselActiveVideoCompleted" -> emitOnCarouselActiveVideoCompleted(params)
|
|
355
|
+
"onVideoCarouselCellTap" -> emitOnVideoCarouselCellTap(params)
|
|
346
356
|
else -> {
|
|
347
357
|
android.util.Log.w("SK:Module", "sendEvent: unknown event name '$name', using legacy emitter")
|
|
348
358
|
reactApplicationContext
|