@shortkitsdk/react-native 0.2.1 → 0.2.2
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/src/main/java/com/shortkit/reactnative/ShortKitCarouselOverlayBridge.kt +48 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +48 -6
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +67 -5
- package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +28 -1
- package/ios/ShortKitBridge.swift +46 -1
- package/ios/ShortKitCarouselOverlayBridge.swift +54 -0
- package/ios/ShortKitFeedView.swift +46 -7
- package/ios/ShortKitModule.mm +22 -2
- package/ios/ShortKitOverlayBridge.swift +23 -1
- package/package.json +1 -1
- package/src/CarouselOverlayManager.tsx +71 -0
- package/src/ShortKitContext.ts +13 -0
- package/src/ShortKitFeed.tsx +3 -0
- package/src/ShortKitProvider.tsx +118 -2
- package/src/index.ts +3 -1
- package/src/serialization.ts +6 -2
- package/src/specs/NativeShortKitModule.ts +19 -0
- package/src/types.ts +4 -3
- package/src/useShortKitCarousel.ts +29 -0
- package/src/useShortKitPlayer.ts +10 -2
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package com.shortkit.reactnative
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Color
|
|
5
|
+
import android.widget.FrameLayout
|
|
6
|
+
import com.facebook.react.bridge.Arguments
|
|
7
|
+
import com.shortkit.sdk.model.ImageCarouselItem
|
|
8
|
+
import com.shortkit.sdk.overlay.CarouselOverlay
|
|
9
|
+
import kotlinx.serialization.encodeToString
|
|
10
|
+
import kotlinx.serialization.json.Json
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A transparent [FrameLayout] that implements [CarouselOverlay] and bridges
|
|
14
|
+
* carousel overlay lifecycle calls to JS events via [ShortKitModule].
|
|
15
|
+
*
|
|
16
|
+
* The actual carousel overlay UI is rendered by React Native on the JS side
|
|
17
|
+
* through the `CarouselOverlayManager` component. This view simply relays
|
|
18
|
+
* the SDK lifecycle events so the JS carousel overlay knows when to
|
|
19
|
+
* configure, activate, reset, etc.
|
|
20
|
+
*
|
|
21
|
+
* Android equivalent of iOS `ShortKitCarouselOverlayBridge.swift`.
|
|
22
|
+
*/
|
|
23
|
+
class ShortKitCarouselOverlayBridge(context: Context) : FrameLayout(context), CarouselOverlay {
|
|
24
|
+
|
|
25
|
+
init {
|
|
26
|
+
setBackgroundColor(Color.TRANSPARENT)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override fun configure(item: ImageCarouselItem) {
|
|
30
|
+
val json = Json.encodeToString(item)
|
|
31
|
+
val params = Arguments.createMap().apply {
|
|
32
|
+
putString("item", json)
|
|
33
|
+
}
|
|
34
|
+
ShortKitModule.shared?.emitCarouselOverlayEvent("onCarouselOverlayConfigure", params)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override fun resetState() {
|
|
38
|
+
ShortKitModule.shared?.emitCarouselOverlayEvent("onCarouselOverlayReset", Arguments.createMap())
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
override fun fadeOutForTransition() {
|
|
42
|
+
ShortKitModule.shared?.emitCarouselOverlayEvent("onCarouselOverlayFadeOut", Arguments.createMap())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override fun restoreFromTransition() {
|
|
46
|
+
ShortKitModule.shared?.emitCarouselOverlayEvent("onCarouselOverlayRestore", Arguments.createMap())
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -51,12 +51,18 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
51
51
|
private var viewPager: ViewPager2? = null
|
|
52
52
|
private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/** Video overlay for the currently active cell (nativeID="overlay-current"). */
|
|
55
55
|
private var currentOverlayView: View? = null
|
|
56
56
|
|
|
57
|
-
/**
|
|
57
|
+
/** Video overlay for the upcoming cell (nativeID="overlay-next"). */
|
|
58
58
|
private var nextOverlayView: View? = null
|
|
59
59
|
|
|
60
|
+
/** Carousel overlay for the currently active cell (nativeID="carousel-overlay-current"). */
|
|
61
|
+
private var currentCarouselOverlayView: View? = null
|
|
62
|
+
|
|
63
|
+
/** Carousel overlay for the upcoming cell (nativeID="carousel-overlay-next"). */
|
|
64
|
+
private var nextCarouselOverlayView: View? = null
|
|
65
|
+
|
|
60
66
|
/** The page index used for overlay transform calculations. */
|
|
61
67
|
private var currentPage: Int = 0
|
|
62
68
|
|
|
@@ -158,8 +164,12 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
158
164
|
viewPager = null
|
|
159
165
|
currentOverlayView?.translationY = 0f
|
|
160
166
|
nextOverlayView?.translationY = 0f
|
|
167
|
+
currentCarouselOverlayView?.translationY = 0f
|
|
168
|
+
nextCarouselOverlayView?.translationY = 0f
|
|
161
169
|
currentOverlayView = null
|
|
162
170
|
nextOverlayView = null
|
|
171
|
+
currentCarouselOverlayView = null
|
|
172
|
+
nextCarouselOverlayView = null
|
|
163
173
|
}
|
|
164
174
|
|
|
165
175
|
private fun handleScrollOffset(
|
|
@@ -193,6 +203,8 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
193
203
|
val h = height.toFloat()
|
|
194
204
|
currentOverlayView?.translationY = 0f
|
|
195
205
|
nextOverlayView?.translationY = h
|
|
206
|
+
currentCarouselOverlayView?.translationY = 0f
|
|
207
|
+
nextCarouselOverlayView?.translationY = h
|
|
196
208
|
}
|
|
197
209
|
pageUpdateRunnable = runnable
|
|
198
210
|
handler.postDelayed(runnable, 80)
|
|
@@ -219,17 +231,25 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
219
231
|
if (currentOverlayView == null || nextOverlayView == null) {
|
|
220
232
|
findOverlayViews()
|
|
221
233
|
}
|
|
234
|
+
if (currentCarouselOverlayView == null || nextCarouselOverlayView == null) {
|
|
235
|
+
findCarouselOverlayViews()
|
|
236
|
+
}
|
|
222
237
|
|
|
223
|
-
// Current
|
|
238
|
+
// Current overlays follow the active cell
|
|
224
239
|
currentOverlayView?.translationY = -delta
|
|
240
|
+
currentCarouselOverlayView?.translationY = -delta
|
|
225
241
|
|
|
226
|
-
// Next
|
|
242
|
+
// Next overlays: positioned one page ahead in the scroll direction
|
|
227
243
|
if (delta >= 0) {
|
|
228
244
|
// Forward scroll (or idle): next cell is below
|
|
229
|
-
|
|
245
|
+
val nextY = cellHeight - delta
|
|
246
|
+
nextOverlayView?.translationY = nextY
|
|
247
|
+
nextCarouselOverlayView?.translationY = nextY
|
|
230
248
|
} else {
|
|
231
249
|
// Backward scroll: next cell is above
|
|
232
|
-
|
|
250
|
+
val nextY = -cellHeight - delta
|
|
251
|
+
nextOverlayView?.translationY = nextY
|
|
252
|
+
nextCarouselOverlayView?.translationY = nextY
|
|
233
253
|
}
|
|
234
254
|
}
|
|
235
255
|
|
|
@@ -260,6 +280,28 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
|
|
|
260
280
|
}
|
|
261
281
|
}
|
|
262
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Find the sibling RN carousel overlay views by nativeID.
|
|
285
|
+
*/
|
|
286
|
+
private fun findCarouselOverlayViews() {
|
|
287
|
+
var ancestor = parent as? ViewGroup ?: return
|
|
288
|
+
while (true) {
|
|
289
|
+
for (i in 0 until ancestor.childCount) {
|
|
290
|
+
val child = ancestor.getChildAt(i)
|
|
291
|
+
if (child === this || isOwnAncestor(child)) continue
|
|
292
|
+
|
|
293
|
+
if (currentCarouselOverlayView == null) {
|
|
294
|
+
currentCarouselOverlayView = ReactFindViewUtil.findView(child, "carousel-overlay-current")
|
|
295
|
+
}
|
|
296
|
+
if (nextCarouselOverlayView == null) {
|
|
297
|
+
nextCarouselOverlayView = ReactFindViewUtil.findView(child, "carousel-overlay-next")
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (currentCarouselOverlayView != null && nextCarouselOverlayView != null) return
|
|
301
|
+
ancestor = ancestor.parent as? ViewGroup ?: return
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
263
305
|
/** Check if the given view is an ancestor of this view. */
|
|
264
306
|
private fun isOwnAncestor(view: View): Boolean {
|
|
265
307
|
var current: ViewParent? = parent
|
|
@@ -50,6 +50,8 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
50
50
|
private var listenerCount = 0
|
|
51
51
|
@Volatile
|
|
52
52
|
private var hasListeners = false
|
|
53
|
+
private var pendingFeedItems: String? = null
|
|
54
|
+
private var pendingAppendItems: String? = null
|
|
53
55
|
|
|
54
56
|
/** Expose the underlying SDK for the Fabric feed view manager. */
|
|
55
57
|
val sdk: ShortKit? get() = shortKit
|
|
@@ -120,6 +122,16 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
120
122
|
this.shortKit = sdk
|
|
121
123
|
shared = this
|
|
122
124
|
|
|
125
|
+
// Replay any feed items that arrived before initialization
|
|
126
|
+
pendingFeedItems?.let { json ->
|
|
127
|
+
parseCustomFeedItems(json)?.let { sdk.setFeedItems(it) }
|
|
128
|
+
pendingFeedItems = null
|
|
129
|
+
}
|
|
130
|
+
pendingAppendItems?.let { json ->
|
|
131
|
+
parseCustomFeedItems(json)?.let { sdk.appendFeedItems(it) }
|
|
132
|
+
pendingAppendItems = null
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
subscribeToFlows(sdk.player)
|
|
124
136
|
|
|
125
137
|
sdk.delegate = object : com.shortkit.ShortKitDelegate {
|
|
@@ -233,14 +245,24 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
233
245
|
|
|
234
246
|
@ReactMethod
|
|
235
247
|
override fun setFeedItems(items: String) {
|
|
236
|
-
val
|
|
237
|
-
|
|
248
|
+
val sdk = shortKit
|
|
249
|
+
if (sdk != null) {
|
|
250
|
+
val parsed = parseCustomFeedItems(items) ?: return
|
|
251
|
+
sdk.setFeedItems(parsed)
|
|
252
|
+
} else {
|
|
253
|
+
pendingFeedItems = items
|
|
254
|
+
}
|
|
238
255
|
}
|
|
239
256
|
|
|
240
257
|
@ReactMethod
|
|
241
258
|
override fun appendFeedItems(items: String) {
|
|
242
|
-
val
|
|
243
|
-
|
|
259
|
+
val sdk = shortKit
|
|
260
|
+
if (sdk != null) {
|
|
261
|
+
val parsed = parseCustomFeedItems(items) ?: return
|
|
262
|
+
sdk.appendFeedItems(parsed)
|
|
263
|
+
} else {
|
|
264
|
+
pendingAppendItems = items
|
|
265
|
+
}
|
|
244
266
|
}
|
|
245
267
|
|
|
246
268
|
@ReactMethod
|
|
@@ -279,6 +301,14 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
279
301
|
sendEvent(name, params)
|
|
280
302
|
}
|
|
281
303
|
|
|
304
|
+
// -----------------------------------------------------------------------
|
|
305
|
+
// Carousel overlay lifecycle events
|
|
306
|
+
// -----------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
fun emitCarouselOverlayEvent(name: String, params: WritableMap) {
|
|
309
|
+
sendEvent(name, params)
|
|
310
|
+
}
|
|
311
|
+
|
|
282
312
|
// -----------------------------------------------------------------------
|
|
283
313
|
// Flow subscriptions
|
|
284
314
|
// -----------------------------------------------------------------------
|
|
@@ -642,10 +672,12 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
642
672
|
val feedSourceStr = obj.optString("feedSource", "algorithmic")
|
|
643
673
|
val feedSource = if (feedSourceStr == "custom") FeedSource.CUSTOM else FeedSource.ALGORITHMIC
|
|
644
674
|
|
|
675
|
+
val carouselOverlay = parseCarouselOverlay(obj.optString("carouselOverlay", null))
|
|
676
|
+
|
|
645
677
|
FeedConfig(
|
|
646
678
|
feedHeight = feedHeight,
|
|
647
679
|
videoOverlay = videoOverlay,
|
|
648
|
-
carouselOverlay =
|
|
680
|
+
carouselOverlay = carouselOverlay,
|
|
649
681
|
surveyOverlay = SurveyOverlayMode.None,
|
|
650
682
|
adOverlay = AdOverlayMode.None,
|
|
651
683
|
muteOnStart = muteOnStart,
|
|
@@ -712,6 +744,36 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
712
744
|
}
|
|
713
745
|
}
|
|
714
746
|
|
|
747
|
+
/**
|
|
748
|
+
* Parse a double-stringified carousel overlay JSON.
|
|
749
|
+
* - `"\"none\""` -> None
|
|
750
|
+
* - `"{\"type\":\"custom\"}"` -> Custom with bridge overlay factory
|
|
751
|
+
*/
|
|
752
|
+
private fun parseCarouselOverlay(json: String?): CarouselOverlayMode {
|
|
753
|
+
if (json.isNullOrEmpty()) return CarouselOverlayMode.None
|
|
754
|
+
return try {
|
|
755
|
+
val parsed = json.trim()
|
|
756
|
+
|
|
757
|
+
if (parsed == "\"none\"" || parsed == "none") {
|
|
758
|
+
return CarouselOverlayMode.None
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
val inner = if (parsed.startsWith("\"") && parsed.endsWith("\"")) {
|
|
762
|
+
JSONObject(parsed.substring(1, parsed.length - 1).replace("\\\"", "\""))
|
|
763
|
+
} else {
|
|
764
|
+
JSONObject(parsed)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (inner.optString("type") == "custom") {
|
|
768
|
+
CarouselOverlayMode.Custom { ShortKitCarouselOverlayBridge(reactApplicationContext) }
|
|
769
|
+
} else {
|
|
770
|
+
CarouselOverlayMode.None
|
|
771
|
+
}
|
|
772
|
+
} catch (_: Exception) {
|
|
773
|
+
CarouselOverlayMode.None
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
715
777
|
private fun parseCustomFeedItems(json: String): List<CustomFeedItem>? {
|
|
716
778
|
return try {
|
|
717
779
|
val arr = JSONArray(json)
|
|
@@ -2,6 +2,8 @@ package com.shortkit.reactnative
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.graphics.Color
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
5
7
|
import android.view.GestureDetector
|
|
6
8
|
import android.view.MotionEvent
|
|
7
9
|
import android.widget.FrameLayout
|
|
@@ -32,6 +34,15 @@ class ShortKitOverlayBridge(context: Context) : FrameLayout(context), FeedOverla
|
|
|
32
34
|
*/
|
|
33
35
|
private var currentItem: ContentItem? = null
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Deferred configure emission — cancelled if [activatePlayback] fires
|
|
39
|
+
* on the same message-queue iteration (handleSwipe re-configure of the
|
|
40
|
+
* active cell, not a prefetch for the next cell).
|
|
41
|
+
*/
|
|
42
|
+
private var pendingConfigureRunnable: Runnable? = null
|
|
43
|
+
|
|
44
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
45
|
+
|
|
35
46
|
// -----------------------------------------------------------------------
|
|
36
47
|
// Gestures
|
|
37
48
|
// -----------------------------------------------------------------------
|
|
@@ -76,7 +87,18 @@ class ShortKitOverlayBridge(context: Context) : FrameLayout(context), FeedOverla
|
|
|
76
87
|
|
|
77
88
|
override fun configure(item: ContentItem) {
|
|
78
89
|
currentItem = item
|
|
79
|
-
|
|
90
|
+
|
|
91
|
+
// Defer the configure event by one message-queue tick. If activatePlayback()
|
|
92
|
+
// fires before then (handleSwipe sequence: configure → reset → activate),
|
|
93
|
+
// the event is cancelled — preventing nextItem from being overwritten
|
|
94
|
+
// with the current cell's data.
|
|
95
|
+
pendingConfigureRunnable?.let { handler.removeCallbacks(it) }
|
|
96
|
+
val runnable = Runnable {
|
|
97
|
+
currentItem?.let { ShortKitModule.shared?.emitOverlayEvent("onOverlayConfigure", it) }
|
|
98
|
+
pendingConfigureRunnable = null
|
|
99
|
+
}
|
|
100
|
+
pendingConfigureRunnable = runnable
|
|
101
|
+
handler.post(runnable)
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
override fun resetPlaybackProgress() {
|
|
@@ -85,6 +107,11 @@ class ShortKitOverlayBridge(context: Context) : FrameLayout(context), FeedOverla
|
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
override fun activatePlayback() {
|
|
110
|
+
// Cancel the pending configure — it was for the current cell (part of
|
|
111
|
+
// handleSwipe), not a prefetch for the next cell.
|
|
112
|
+
pendingConfigureRunnable?.let { handler.removeCallbacks(it) }
|
|
113
|
+
pendingConfigureRunnable = null
|
|
114
|
+
|
|
88
115
|
val item = currentItem ?: return
|
|
89
116
|
ShortKitModule.shared?.emitOverlayEvent("onOverlayActivate", item)
|
|
90
117
|
}
|
package/ios/ShortKitBridge.swift
CHANGED
|
@@ -347,6 +347,20 @@ import ShortKit
|
|
|
347
347
|
emitOnMain(name, body: body)
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
// MARK: - Carousel Overlay Lifecycle Events
|
|
351
|
+
|
|
352
|
+
/// Emit carousel overlay lifecycle events with an ImageCarouselItem.
|
|
353
|
+
public func emitCarouselOverlayEvent(_ name: String, item: ImageCarouselItem) {
|
|
354
|
+
guard let data = try? JSONEncoder().encode(item),
|
|
355
|
+
let json = String(data: data, encoding: .utf8) else { return }
|
|
356
|
+
emitOnMain(name, body: ["item": json])
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// Emit a raw carousel overlay event with an arbitrary body.
|
|
360
|
+
public func emitCarouselOverlayEvent(_ name: String, body: [String: Any]) {
|
|
361
|
+
emitOnMain(name, body: body)
|
|
362
|
+
}
|
|
363
|
+
|
|
350
364
|
// MARK: - Content Item Serialization
|
|
351
365
|
|
|
352
366
|
/// Serialize a ContentItem to a JSON string for bridge transport.
|
|
@@ -457,10 +471,12 @@ import ShortKit
|
|
|
457
471
|
let feedSourceStr = obj["feedSource"] as? String ?? "algorithmic"
|
|
458
472
|
let feedSource: FeedSource = feedSourceStr == "custom" ? .custom : .algorithmic
|
|
459
473
|
|
|
474
|
+
let carouselOverlay = parseCarouselOverlay(obj["carouselOverlay"] as? String)
|
|
475
|
+
|
|
460
476
|
return FeedConfig(
|
|
461
477
|
feedHeight: feedHeight,
|
|
462
478
|
videoOverlay: videoOverlay,
|
|
463
|
-
carouselOverlay:
|
|
479
|
+
carouselOverlay: carouselOverlay,
|
|
464
480
|
surveyOverlay: .none,
|
|
465
481
|
adOverlay: .none,
|
|
466
482
|
muteOnStart: muteOnStart,
|
|
@@ -499,6 +515,35 @@ import ShortKit
|
|
|
499
515
|
return .none
|
|
500
516
|
}
|
|
501
517
|
|
|
518
|
+
/// Parse a double-stringified carousel overlay JSON into a `CarouselOverlayMode`.
|
|
519
|
+
///
|
|
520
|
+
/// Examples:
|
|
521
|
+
/// - `"\"none\""` → `.none`
|
|
522
|
+
/// - `"{\"type\":\"custom\"}"` → `.custom { ShortKitCarouselOverlayBridge() }`
|
|
523
|
+
private static func parseCarouselOverlay(_ json: String?) -> CarouselOverlayMode {
|
|
524
|
+
guard let json,
|
|
525
|
+
let data = json.data(using: .utf8),
|
|
526
|
+
let parsed = try? JSONSerialization.jsonObject(with: data) else {
|
|
527
|
+
return .none
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if let str = parsed as? String, str == "none" {
|
|
531
|
+
return .none
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if let obj = parsed as? [String: Any],
|
|
535
|
+
let type = obj["type"] as? String,
|
|
536
|
+
type == "custom" {
|
|
537
|
+
return .custom { @Sendable in
|
|
538
|
+
let overlay = ShortKitCarouselOverlayBridge()
|
|
539
|
+
overlay.bridge = ShortKitBridge.shared
|
|
540
|
+
return overlay
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return .none
|
|
545
|
+
}
|
|
546
|
+
|
|
502
547
|
/// Parse a double-stringified feedHeight JSON.
|
|
503
548
|
/// e.g. `"{\"type\":\"fullscreen\"}"` or `"{\"type\":\"percentage\",\"value\":0.8}"`
|
|
504
549
|
private static func parseFeedHeight(_ json: String?) -> FeedHeight {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import ShortKit
|
|
3
|
+
|
|
4
|
+
/// A transparent UIView that conforms to `CarouselOverlay` and bridges
|
|
5
|
+
/// carousel overlay lifecycle calls to JS events via `ShortKitBridge`.
|
|
6
|
+
///
|
|
7
|
+
/// The actual carousel overlay UI is rendered by React Native on the JS side
|
|
8
|
+
/// through the `CarouselOverlayManager` component. This view simply relays
|
|
9
|
+
/// the SDK lifecycle events so the JS carousel overlay knows when to
|
|
10
|
+
/// configure, activate, reset, etc.
|
|
11
|
+
@objc public class ShortKitCarouselOverlayBridge: UIView, @unchecked Sendable, CarouselOverlay {
|
|
12
|
+
|
|
13
|
+
// MARK: - Bridge Reference
|
|
14
|
+
|
|
15
|
+
/// Weak reference to the bridge, set by the factory closure in `parseFeedConfig`.
|
|
16
|
+
weak var bridge: ShortKitBridge?
|
|
17
|
+
|
|
18
|
+
// MARK: - State
|
|
19
|
+
|
|
20
|
+
/// Stores the last configured ImageCarouselItem so we can pass it with
|
|
21
|
+
/// lifecycle events that don't receive the item as a parameter.
|
|
22
|
+
private var currentItem: ImageCarouselItem?
|
|
23
|
+
|
|
24
|
+
// MARK: - Init
|
|
25
|
+
|
|
26
|
+
override init(frame: CGRect) {
|
|
27
|
+
super.init(frame: frame)
|
|
28
|
+
backgroundColor = .clear
|
|
29
|
+
isUserInteractionEnabled = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
required init?(coder: NSCoder) {
|
|
33
|
+
fatalError("init(coder:) is not supported")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// MARK: - CarouselOverlay
|
|
37
|
+
|
|
38
|
+
public func configure(with item: ImageCarouselItem) {
|
|
39
|
+
currentItem = item
|
|
40
|
+
bridge?.emitCarouselOverlayEvent("onCarouselOverlayConfigure", item: item)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public func resetState() {
|
|
44
|
+
bridge?.emitCarouselOverlayEvent("onCarouselOverlayReset", body: [:])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public func fadeOutForTransition() {
|
|
48
|
+
bridge?.emitCarouselOverlayEvent("onCarouselOverlayFadeOut", body: [:])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public func restoreFromTransition() {
|
|
52
|
+
bridge?.emitCarouselOverlayEvent("onCarouselOverlayRestore", body: [:])
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -27,10 +27,14 @@ import ShortKit
|
|
|
27
27
|
// MARK: - Scroll Tracking
|
|
28
28
|
|
|
29
29
|
private var scrollObservation: NSKeyValueObservation?
|
|
30
|
-
///
|
|
30
|
+
/// Video overlay for the currently active cell (nativeID="overlay-current").
|
|
31
31
|
private weak var currentOverlayView: UIView?
|
|
32
|
-
///
|
|
32
|
+
/// Video overlay for the upcoming cell (nativeID="overlay-next").
|
|
33
33
|
private weak var nextOverlayView: UIView?
|
|
34
|
+
/// Carousel overlay for the currently active cell (nativeID="carousel-overlay-current").
|
|
35
|
+
private weak var currentCarouselOverlayView: UIView?
|
|
36
|
+
/// Carousel overlay for the upcoming cell (nativeID="carousel-overlay-next").
|
|
37
|
+
private weak var nextCarouselOverlayView: UIView?
|
|
34
38
|
/// The page index used for overlay transform calculations.
|
|
35
39
|
private var currentPage: Int = 0
|
|
36
40
|
/// Deferred page update to avoid flashing stale metadata.
|
|
@@ -94,8 +98,12 @@ import ShortKit
|
|
|
94
98
|
scrollObservation = nil
|
|
95
99
|
currentOverlayView?.transform = .identity
|
|
96
100
|
nextOverlayView?.transform = .identity
|
|
101
|
+
currentCarouselOverlayView?.transform = .identity
|
|
102
|
+
nextCarouselOverlayView?.transform = .identity
|
|
97
103
|
currentOverlayView = nil
|
|
98
104
|
nextOverlayView = nil
|
|
105
|
+
currentCarouselOverlayView = nil
|
|
106
|
+
nextCarouselOverlayView = nil
|
|
99
107
|
|
|
100
108
|
guard let feedVC = feedViewController else { return }
|
|
101
109
|
|
|
@@ -150,6 +158,8 @@ import ShortKit
|
|
|
150
158
|
let h = self.bounds.height
|
|
151
159
|
self.currentOverlayView?.transform = .identity
|
|
152
160
|
self.nextOverlayView?.transform = CGAffineTransform(translationX: 0, y: h)
|
|
161
|
+
self.currentCarouselOverlayView?.transform = .identity
|
|
162
|
+
self.nextCarouselOverlayView?.transform = CGAffineTransform(translationX: 0, y: h)
|
|
153
163
|
}
|
|
154
164
|
pageUpdateWorkItem = workItem
|
|
155
165
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08, execute: workItem)
|
|
@@ -168,17 +178,27 @@ import ShortKit
|
|
|
168
178
|
if currentOverlayView == nil || nextOverlayView == nil {
|
|
169
179
|
findOverlayViews()
|
|
170
180
|
}
|
|
181
|
+
if currentCarouselOverlayView == nil || nextCarouselOverlayView == nil {
|
|
182
|
+
findCarouselOverlayViews()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let translateY = CGAffineTransform(translationX: 0, y: -delta)
|
|
171
186
|
|
|
172
|
-
// Current
|
|
173
|
-
currentOverlayView?.transform =
|
|
187
|
+
// Current overlays follow the active cell
|
|
188
|
+
currentOverlayView?.transform = translateY
|
|
189
|
+
currentCarouselOverlayView?.transform = translateY
|
|
174
190
|
|
|
175
|
-
// Next
|
|
191
|
+
// Next overlays: positioned one page ahead in the scroll direction
|
|
176
192
|
if delta >= 0 {
|
|
177
193
|
// Forward scroll (or idle): next cell is below
|
|
178
|
-
|
|
194
|
+
let nextTransform = CGAffineTransform(translationX: 0, y: cellHeight - delta)
|
|
195
|
+
nextOverlayView?.transform = nextTransform
|
|
196
|
+
nextCarouselOverlayView?.transform = nextTransform
|
|
179
197
|
} else {
|
|
180
198
|
// Backward scroll: next cell is above
|
|
181
|
-
|
|
199
|
+
let nextTransform = CGAffineTransform(translationX: 0, y: -cellHeight - delta)
|
|
200
|
+
nextOverlayView?.transform = nextTransform
|
|
201
|
+
nextCarouselOverlayView?.transform = nextTransform
|
|
182
202
|
}
|
|
183
203
|
}
|
|
184
204
|
|
|
@@ -208,6 +228,25 @@ import ShortKit
|
|
|
208
228
|
}
|
|
209
229
|
}
|
|
210
230
|
|
|
231
|
+
/// Find the sibling RN carousel overlay views by nativeID.
|
|
232
|
+
private func findCarouselOverlayViews() {
|
|
233
|
+
var ancestor: UIView? = superview
|
|
234
|
+
while let container = ancestor {
|
|
235
|
+
for child in container.subviews {
|
|
236
|
+
guard !self.isDescendant(of: child) else { continue }
|
|
237
|
+
|
|
238
|
+
if currentCarouselOverlayView == nil {
|
|
239
|
+
currentCarouselOverlayView = findView(withNativeID: "carousel-overlay-current", in: child)
|
|
240
|
+
}
|
|
241
|
+
if nextCarouselOverlayView == nil {
|
|
242
|
+
nextCarouselOverlayView = findView(withNativeID: "carousel-overlay-next", in: child)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if currentCarouselOverlayView != nil && nextCarouselOverlayView != nil { return }
|
|
246
|
+
ancestor = container.superview
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
211
250
|
/// Recursively find a view by its React Native `nativeID` prop.
|
|
212
251
|
/// In Fabric, this is stored on `RCTViewComponentView.nativeId`,
|
|
213
252
|
/// not `accessibilityIdentifier`.
|
package/ios/ShortKitModule.mm
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
@implementation ShortKitModule {
|
|
12
12
|
ShortKitBridge *_shortKitBridge;
|
|
13
13
|
BOOL _hasListeners;
|
|
14
|
+
NSString *_pendingFeedItems;
|
|
15
|
+
NSString *_pendingAppendItems;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
RCT_EXPORT_MODULE(ShortKitModule)
|
|
@@ -118,6 +120,16 @@ RCT_EXPORT_METHOD(initialize:(NSString *)apiKey
|
|
|
118
120
|
clientAppVersion:clientAppVersion
|
|
119
121
|
customDimensions:customDimensions
|
|
120
122
|
delegate:self];
|
|
123
|
+
|
|
124
|
+
// Replay any feed items that arrived before initialization
|
|
125
|
+
if (_pendingFeedItems) {
|
|
126
|
+
[_shortKitBridge setFeedItems:_pendingFeedItems];
|
|
127
|
+
_pendingFeedItems = nil;
|
|
128
|
+
}
|
|
129
|
+
if (_pendingAppendItems) {
|
|
130
|
+
[_shortKitBridge appendFeedItems:_pendingAppendItems];
|
|
131
|
+
_pendingAppendItems = nil;
|
|
132
|
+
}
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
RCT_EXPORT_METHOD(destroy) {
|
|
@@ -194,11 +206,19 @@ RCT_EXPORT_METHOD(setMaxBitrate:(double)bitrate) {
|
|
|
194
206
|
// MARK: - Custom Feed
|
|
195
207
|
|
|
196
208
|
RCT_EXPORT_METHOD(setFeedItems:(NSString *)items) {
|
|
197
|
-
|
|
209
|
+
if (_shortKitBridge) {
|
|
210
|
+
[_shortKitBridge setFeedItems:items];
|
|
211
|
+
} else {
|
|
212
|
+
_pendingFeedItems = items;
|
|
213
|
+
}
|
|
198
214
|
}
|
|
199
215
|
|
|
200
216
|
RCT_EXPORT_METHOD(appendFeedItems:(NSString *)items) {
|
|
201
|
-
|
|
217
|
+
if (_shortKitBridge) {
|
|
218
|
+
[_shortKitBridge appendFeedItems:items];
|
|
219
|
+
} else {
|
|
220
|
+
_pendingAppendItems = items;
|
|
221
|
+
}
|
|
202
222
|
}
|
|
203
223
|
|
|
204
224
|
RCT_EXPORT_METHOD(fetchContent:(NSInteger)limit
|
|
@@ -20,6 +20,11 @@ import ShortKit
|
|
|
20
20
|
/// events that don't receive the item as a parameter.
|
|
21
21
|
private var currentItem: ContentItem?
|
|
22
22
|
|
|
23
|
+
/// Deferred configure emission — cancelled if `activatePlayback()` fires
|
|
24
|
+
/// on the same run-loop iteration (meaning this was a handleSwipe
|
|
25
|
+
/// re-configure of the active cell, not a prefetch for the next cell).
|
|
26
|
+
private var pendingConfigureWorkItem: DispatchWorkItem?
|
|
27
|
+
|
|
23
28
|
// MARK: - Init
|
|
24
29
|
|
|
25
30
|
override init(frame: CGRect) {
|
|
@@ -66,7 +71,19 @@ import ShortKit
|
|
|
66
71
|
|
|
67
72
|
public func configure(with item: ContentItem) {
|
|
68
73
|
currentItem = item
|
|
69
|
-
|
|
74
|
+
|
|
75
|
+
// Defer the configure event by one run-loop tick. If activatePlayback()
|
|
76
|
+
// fires before then (handleSwipe sequence: configure → reset → activate),
|
|
77
|
+
// the event is cancelled — preventing nextItem from being overwritten
|
|
78
|
+
// with the current cell's data.
|
|
79
|
+
pendingConfigureWorkItem?.cancel()
|
|
80
|
+
let workItem = DispatchWorkItem { [weak self] in
|
|
81
|
+
guard let self, let item = self.currentItem else { return }
|
|
82
|
+
self.bridge?.emitOverlayEvent("onOverlayConfigure", item: item)
|
|
83
|
+
self.pendingConfigureWorkItem = nil
|
|
84
|
+
}
|
|
85
|
+
pendingConfigureWorkItem = workItem
|
|
86
|
+
DispatchQueue.main.async(execute: workItem)
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
public func resetPlaybackProgress() {
|
|
@@ -75,6 +92,11 @@ import ShortKit
|
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
public func activatePlayback() {
|
|
95
|
+
// Cancel the pending configure — it was for the current cell (part of
|
|
96
|
+
// handleSwipe), not a prefetch for the next cell.
|
|
97
|
+
pendingConfigureWorkItem?.cancel()
|
|
98
|
+
pendingConfigureWorkItem = nil
|
|
99
|
+
|
|
78
100
|
guard let item = currentItem else { return }
|
|
79
101
|
bridge?.emitOverlayEvent("onOverlayActivate", item: item)
|
|
80
102
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useContext, useMemo } from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import type { CarouselOverlayConfig } from './types';
|
|
4
|
+
import { ShortKitContext } from './ShortKitContext';
|
|
5
|
+
import type { ShortKitContextValue } from './ShortKitContext';
|
|
6
|
+
|
|
7
|
+
interface CarouselOverlayManagerProps {
|
|
8
|
+
carouselOverlay: CarouselOverlayConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Internal component that renders TWO instances of the developer's custom
|
|
13
|
+
* carousel overlay component — one for the current cell and one for the next.
|
|
14
|
+
*
|
|
15
|
+
* Works identically to `OverlayManager` but for image carousel cells.
|
|
16
|
+
* The native side finds these views by their `nativeID` and applies
|
|
17
|
+
* scroll-tracking transforms so each overlay moves with its respective
|
|
18
|
+
* carousel cell during swipe transitions.
|
|
19
|
+
*/
|
|
20
|
+
export function CarouselOverlayManager({ carouselOverlay }: CarouselOverlayManagerProps) {
|
|
21
|
+
if (
|
|
22
|
+
carouselOverlay === 'none' ||
|
|
23
|
+
typeof carouselOverlay === 'string' ||
|
|
24
|
+
carouselOverlay.type !== 'custom' ||
|
|
25
|
+
!('component' in carouselOverlay)
|
|
26
|
+
) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CarouselComponent = carouselOverlay.component;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<View style={StyleSheet.absoluteFill} nativeID="carousel-overlay-current" pointerEvents="box-none">
|
|
35
|
+
<CarouselComponent />
|
|
36
|
+
</View>
|
|
37
|
+
<View style={StyleSheet.absoluteFill} nativeID="carousel-overlay-next" pointerEvents="box-none">
|
|
38
|
+
<NextCarouselOverlayProvider>
|
|
39
|
+
<CarouselComponent />
|
|
40
|
+
</NextCarouselOverlayProvider>
|
|
41
|
+
</View>
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wraps children with a modified ShortKitContext where `currentCarouselItem`
|
|
48
|
+
* is set to the provider's `nextCarouselItem`.
|
|
49
|
+
*/
|
|
50
|
+
function NextCarouselOverlayProvider({ children }: { children: React.ReactNode }) {
|
|
51
|
+
const context = useContext(ShortKitContext);
|
|
52
|
+
|
|
53
|
+
const nextValue: ShortKitContextValue | null = useMemo(() => {
|
|
54
|
+
if (!context) return null;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...context,
|
|
58
|
+
currentCarouselItem: context.nextCarouselItem,
|
|
59
|
+
isCarouselActive: context.nextCarouselItem != null,
|
|
60
|
+
isCarouselTransitioning: false,
|
|
61
|
+
};
|
|
62
|
+
}, [context]);
|
|
63
|
+
|
|
64
|
+
if (!nextValue) return <>{children}</>;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ShortKitContext.Provider value={nextValue}>
|
|
68
|
+
{children}
|
|
69
|
+
</ShortKitContext.Provider>
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/ShortKitContext.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createContext } from 'react';
|
|
2
2
|
import type {
|
|
3
3
|
ContentItem,
|
|
4
|
+
ImageCarouselItem,
|
|
4
5
|
CustomFeedItem,
|
|
5
6
|
PlayerTime,
|
|
6
7
|
PlayerState,
|
|
7
8
|
CaptionTrack,
|
|
8
9
|
ContentSignal,
|
|
9
10
|
OverlayConfig,
|
|
11
|
+
CarouselOverlayConfig,
|
|
10
12
|
} from './types';
|
|
11
13
|
|
|
12
14
|
export interface ShortKitContextValue {
|
|
@@ -48,9 +50,20 @@ export interface ShortKitContextValue {
|
|
|
48
50
|
appendFeedItems: (items: CustomFeedItem[]) => void;
|
|
49
51
|
fetchContent: (limit?: number) => Promise<ContentItem[]>;
|
|
50
52
|
|
|
53
|
+
// Carousel overlay state
|
|
54
|
+
currentCarouselItem: ImageCarouselItem | null;
|
|
55
|
+
nextCarouselItem: ImageCarouselItem | null;
|
|
56
|
+
isCarouselActive: boolean;
|
|
57
|
+
isCarouselTransitioning: boolean;
|
|
58
|
+
|
|
59
|
+
// Active cell type — used by overlay managers to show/hide
|
|
60
|
+
activeCellType: 'video' | 'carousel' | null;
|
|
61
|
+
|
|
51
62
|
// Internal — used by ShortKitFeed to render custom overlays
|
|
52
63
|
/** @internal */
|
|
53
64
|
_overlayConfig: OverlayConfig;
|
|
65
|
+
/** @internal */
|
|
66
|
+
_carouselOverlayConfig: CarouselOverlayConfig;
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
export const ShortKitContext = createContext<ShortKitContextValue | null>(null);
|
package/src/ShortKitFeed.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { ShortKitFeedProps } from './types';
|
|
|
4
4
|
import ShortKitFeedView from './specs/ShortKitFeedViewNativeComponent';
|
|
5
5
|
import NativeShortKitModule from './specs/NativeShortKitModule';
|
|
6
6
|
import { OverlayManager } from './OverlayManager';
|
|
7
|
+
import { CarouselOverlayManager } from './CarouselOverlayManager';
|
|
7
8
|
import { ShortKitContext } from './ShortKitContext';
|
|
8
9
|
import { deserializeContentItem } from './serialization';
|
|
9
10
|
|
|
@@ -38,6 +39,7 @@ export function ShortKitFeed(props: ShortKitFeedProps) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const overlayConfig = context._overlayConfig;
|
|
42
|
+
const carouselOverlayConfig = context._carouselOverlayConfig;
|
|
41
43
|
|
|
42
44
|
// ---------------------------------------------------------------------------
|
|
43
45
|
// Subscribe to feed-level events and forward to callback props
|
|
@@ -143,6 +145,7 @@ export function ShortKitFeed(props: ShortKitFeedProps) {
|
|
|
143
145
|
config="{}"
|
|
144
146
|
/>
|
|
145
147
|
<OverlayManager overlay={overlayConfig} />
|
|
148
|
+
<CarouselOverlayManager carouselOverlay={carouselOverlayConfig} />
|
|
146
149
|
</View>
|
|
147
150
|
);
|
|
148
151
|
}
|
package/src/ShortKitProvider.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import type { ShortKitContextValue } from './ShortKitContext';
|
|
|
6
6
|
import type {
|
|
7
7
|
ShortKitProviderProps,
|
|
8
8
|
ContentItem,
|
|
9
|
+
ImageCarouselItem,
|
|
9
10
|
CustomFeedItem,
|
|
10
11
|
PlayerTime,
|
|
11
12
|
PlayerState,
|
|
@@ -41,6 +42,11 @@ interface State {
|
|
|
41
42
|
isTransitioning: boolean;
|
|
42
43
|
lastOverlayTap: number;
|
|
43
44
|
lastOverlayDoubleTap: { x: number; y: number; id: number } | null;
|
|
45
|
+
currentCarouselItem: ImageCarouselItem | null;
|
|
46
|
+
nextCarouselItem: ImageCarouselItem | null;
|
|
47
|
+
isCarouselActive: boolean;
|
|
48
|
+
isCarouselTransitioning: boolean;
|
|
49
|
+
activeCellType: 'video' | 'carousel' | null;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
const initialState: State = {
|
|
@@ -59,6 +65,11 @@ const initialState: State = {
|
|
|
59
65
|
isTransitioning: false,
|
|
60
66
|
lastOverlayTap: 0,
|
|
61
67
|
lastOverlayDoubleTap: null,
|
|
68
|
+
currentCarouselItem: null,
|
|
69
|
+
nextCarouselItem: null,
|
|
70
|
+
isCarouselActive: false,
|
|
71
|
+
isCarouselTransitioning: false,
|
|
72
|
+
activeCellType: null,
|
|
62
73
|
};
|
|
63
74
|
|
|
64
75
|
type Action =
|
|
@@ -78,7 +89,13 @@ type Action =
|
|
|
78
89
|
| { type: 'OVERLAY_FADE_OUT' }
|
|
79
90
|
| { type: 'OVERLAY_RESTORE' }
|
|
80
91
|
| { type: 'OVERLAY_TAP' }
|
|
81
|
-
| { type: 'OVERLAY_DOUBLE_TAP'; payload: { x: number; y: number } }
|
|
92
|
+
| { type: 'OVERLAY_DOUBLE_TAP'; payload: { x: number; y: number } }
|
|
93
|
+
| { type: 'ACTIVE_CELL_TYPE'; payload: 'video' | 'carousel' }
|
|
94
|
+
| { type: 'CAROUSEL_OVERLAY_CONFIGURE'; payload: ImageCarouselItem }
|
|
95
|
+
| { type: 'CAROUSEL_OVERLAY_ACTIVATE'; payload: ImageCarouselItem }
|
|
96
|
+
| { type: 'CAROUSEL_OVERLAY_RESET' }
|
|
97
|
+
| { type: 'CAROUSEL_OVERLAY_FADE_OUT' }
|
|
98
|
+
| { type: 'CAROUSEL_OVERLAY_RESTORE' };
|
|
82
99
|
|
|
83
100
|
function reducer(state: State, action: Action): State {
|
|
84
101
|
switch (action.type) {
|
|
@@ -91,10 +108,13 @@ function reducer(state: State, action: Action): State {
|
|
|
91
108
|
action.payload === 'paused' ||
|
|
92
109
|
action.payload === 'buffering' ||
|
|
93
110
|
action.payload === 'seeking';
|
|
111
|
+
const becameActive = !state.isActive && isPlaybackActive;
|
|
94
112
|
return {
|
|
95
113
|
...state,
|
|
96
114
|
playerState: action.payload,
|
|
97
115
|
isActive: state.isActive || isPlaybackActive,
|
|
116
|
+
// First playback means a video cell is active (initial load)
|
|
117
|
+
activeCellType: becameActive ? 'video' : state.activeCellType,
|
|
98
118
|
};
|
|
99
119
|
}
|
|
100
120
|
case 'CURRENT_ITEM':
|
|
@@ -118,7 +138,20 @@ function reducer(state: State, action: Action): State {
|
|
|
118
138
|
case 'OVERLAY_CONFIGURE':
|
|
119
139
|
return { ...state, nextItem: action.payload };
|
|
120
140
|
case 'OVERLAY_ACTIVATE':
|
|
121
|
-
|
|
141
|
+
// Reset time/cue/transition state to prevent stale playback data from
|
|
142
|
+
// the previous cell flashing on the new cell's overlay.
|
|
143
|
+
// Note: nextItem is NOT cleared here — the native-side deferred-configure
|
|
144
|
+
// (ShortKitOverlayBridge) already prevents handleSwipe from polluting it.
|
|
145
|
+
// Clearing it would cause overlay-next to unmount its content ~80ms before
|
|
146
|
+
// the page settle swaps overlays, creating a visible dim-layer gap.
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
currentItem: action.payload,
|
|
150
|
+
isActive: true,
|
|
151
|
+
time: { current: 0, duration: action.payload.duration, buffered: 0 },
|
|
152
|
+
activeCue: null,
|
|
153
|
+
isTransitioning: false,
|
|
154
|
+
};
|
|
122
155
|
case 'OVERLAY_RESET':
|
|
123
156
|
// Don't set isActive = false — the overlay stays mounted during
|
|
124
157
|
// transitions. In the native SDK each cell has its own overlay
|
|
@@ -140,6 +173,18 @@ function reducer(state: State, action: Action): State {
|
|
|
140
173
|
id: (state.lastOverlayDoubleTap?.id ?? 0) + 1,
|
|
141
174
|
},
|
|
142
175
|
};
|
|
176
|
+
case 'ACTIVE_CELL_TYPE':
|
|
177
|
+
return { ...state, activeCellType: action.payload };
|
|
178
|
+
case 'CAROUSEL_OVERLAY_CONFIGURE':
|
|
179
|
+
return { ...state, nextCarouselItem: action.payload };
|
|
180
|
+
case 'CAROUSEL_OVERLAY_ACTIVATE':
|
|
181
|
+
return { ...state, currentCarouselItem: action.payload, isCarouselActive: true };
|
|
182
|
+
case 'CAROUSEL_OVERLAY_RESET':
|
|
183
|
+
return { ...state, isCarouselTransitioning: false };
|
|
184
|
+
case 'CAROUSEL_OVERLAY_FADE_OUT':
|
|
185
|
+
return { ...state, isCarouselTransitioning: true };
|
|
186
|
+
case 'CAROUSEL_OVERLAY_RESTORE':
|
|
187
|
+
return { ...state, isCarouselTransitioning: false };
|
|
143
188
|
default:
|
|
144
189
|
return state;
|
|
145
190
|
}
|
|
@@ -397,6 +442,61 @@ export function ShortKitProvider({
|
|
|
397
442
|
}),
|
|
398
443
|
);
|
|
399
444
|
|
|
445
|
+
// Feed transition — track active cell type
|
|
446
|
+
subscriptions.push(
|
|
447
|
+
NativeShortKitModule.onFeedTransition((event) => {
|
|
448
|
+
if (event.phase === 'ended') {
|
|
449
|
+
// toItem is null when the destination cell is non-video (carousel, survey, ad)
|
|
450
|
+
const isVideo = event.toItem != null;
|
|
451
|
+
dispatch({
|
|
452
|
+
type: 'ACTIVE_CELL_TYPE',
|
|
453
|
+
payload: isVideo ? 'video' : 'carousel',
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// Carousel overlay lifecycle events
|
|
460
|
+
subscriptions.push(
|
|
461
|
+
NativeShortKitModule.onCarouselOverlayConfigure((event) => {
|
|
462
|
+
try {
|
|
463
|
+
const item = JSON.parse(event.item) as ImageCarouselItem;
|
|
464
|
+
dispatch({ type: 'CAROUSEL_OVERLAY_CONFIGURE', payload: item });
|
|
465
|
+
} catch {
|
|
466
|
+
// Ignore malformed JSON
|
|
467
|
+
}
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
subscriptions.push(
|
|
472
|
+
NativeShortKitModule.onCarouselOverlayActivate((event) => {
|
|
473
|
+
try {
|
|
474
|
+
const item = JSON.parse(event.item) as ImageCarouselItem;
|
|
475
|
+
dispatch({ type: 'CAROUSEL_OVERLAY_ACTIVATE', payload: item });
|
|
476
|
+
} catch {
|
|
477
|
+
// Ignore malformed JSON
|
|
478
|
+
}
|
|
479
|
+
}),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
subscriptions.push(
|
|
483
|
+
NativeShortKitModule.onCarouselOverlayReset((_event) => {
|
|
484
|
+
dispatch({ type: 'CAROUSEL_OVERLAY_RESET' });
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
subscriptions.push(
|
|
489
|
+
NativeShortKitModule.onCarouselOverlayFadeOut((_event) => {
|
|
490
|
+
dispatch({ type: 'CAROUSEL_OVERLAY_FADE_OUT' });
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
subscriptions.push(
|
|
495
|
+
NativeShortKitModule.onCarouselOverlayRestore((_event) => {
|
|
496
|
+
dispatch({ type: 'CAROUSEL_OVERLAY_RESTORE' });
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
|
|
400
500
|
// Note: Feed-level callback events (onDidLoop, onFeedTransition,
|
|
401
501
|
// onShareTapped, etc.) are subscribed by the ShortKitFeed component
|
|
402
502
|
// (Task 11), not here. The provider only manages state-driving events.
|
|
@@ -526,8 +626,18 @@ export function ShortKitProvider({
|
|
|
526
626
|
appendFeedItems: appendFeedItemsCmd,
|
|
527
627
|
fetchContent: fetchContentCmd,
|
|
528
628
|
|
|
629
|
+
// Active cell type
|
|
630
|
+
activeCellType: state.activeCellType,
|
|
631
|
+
|
|
632
|
+
// Carousel overlay state
|
|
633
|
+
currentCarouselItem: state.currentCarouselItem,
|
|
634
|
+
nextCarouselItem: state.nextCarouselItem,
|
|
635
|
+
isCarouselActive: state.isCarouselActive,
|
|
636
|
+
isCarouselTransitioning: state.isCarouselTransitioning,
|
|
637
|
+
|
|
529
638
|
// Internal — consumed by ShortKitFeed to pass to OverlayManager
|
|
530
639
|
_overlayConfig: config.overlay ?? 'none',
|
|
640
|
+
_carouselOverlayConfig: config.carouselOverlay ?? 'none',
|
|
531
641
|
}),
|
|
532
642
|
[
|
|
533
643
|
state.playerState,
|
|
@@ -545,6 +655,11 @@ export function ShortKitProvider({
|
|
|
545
655
|
state.isTransitioning,
|
|
546
656
|
state.lastOverlayTap,
|
|
547
657
|
state.lastOverlayDoubleTap,
|
|
658
|
+
state.activeCellType,
|
|
659
|
+
state.currentCarouselItem,
|
|
660
|
+
state.nextCarouselItem,
|
|
661
|
+
state.isCarouselActive,
|
|
662
|
+
state.isCarouselTransitioning,
|
|
548
663
|
play,
|
|
549
664
|
pause,
|
|
550
665
|
seek,
|
|
@@ -563,6 +678,7 @@ export function ShortKitProvider({
|
|
|
563
678
|
appendFeedItemsCmd,
|
|
564
679
|
fetchContentCmd,
|
|
565
680
|
config.overlay,
|
|
681
|
+
config.carouselOverlay,
|
|
566
682
|
],
|
|
567
683
|
);
|
|
568
684
|
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,13 @@ export { ShortKitPlayer } from './ShortKitPlayer';
|
|
|
4
4
|
export { ShortKitWidget } from './ShortKitWidget';
|
|
5
5
|
export { useShortKitPlayer } from './useShortKitPlayer';
|
|
6
6
|
export { useShortKit } from './useShortKit';
|
|
7
|
+
export { useShortKitCarousel } from './useShortKitCarousel';
|
|
7
8
|
export type {
|
|
8
9
|
FeedConfig,
|
|
9
10
|
FeedHeight,
|
|
10
11
|
FeedSource,
|
|
11
12
|
OverlayConfig,
|
|
12
|
-
|
|
13
|
+
CarouselOverlayConfig,
|
|
13
14
|
SurveyMode,
|
|
14
15
|
|
|
15
16
|
PlayerConfig,
|
|
@@ -36,3 +37,4 @@ export type {
|
|
|
36
37
|
ShortKitWidgetProps,
|
|
37
38
|
ShortKitPlayerState,
|
|
38
39
|
} from './types';
|
|
40
|
+
export type { ShortKitCarouselState } from './useShortKitCarousel';
|
package/src/serialization.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
export interface SerializedFeedConfig {
|
|
14
14
|
feedHeight: string;
|
|
15
15
|
overlay: string;
|
|
16
|
-
|
|
16
|
+
carouselOverlay: string;
|
|
17
17
|
surveyMode: string;
|
|
18
18
|
muteOnStart: boolean;
|
|
19
19
|
feedSource: string;
|
|
@@ -37,7 +37,11 @@ export function serializeFeedConfig(config: FeedConfig): SerializedFeedConfig {
|
|
|
37
37
|
return {
|
|
38
38
|
feedHeight: JSON.stringify(config.feedHeight ?? { type: 'fullscreen' }),
|
|
39
39
|
overlay,
|
|
40
|
-
|
|
40
|
+
carouselOverlay: (() => {
|
|
41
|
+
const raw = config.carouselOverlay ?? 'none';
|
|
42
|
+
if (typeof raw === 'string') return JSON.stringify(raw);
|
|
43
|
+
return JSON.stringify({ type: 'custom' });
|
|
44
|
+
})(),
|
|
41
45
|
surveyMode: JSON.stringify(config.surveyMode ?? 'none'),
|
|
42
46
|
muteOnStart: config.muteOnStart ?? true,
|
|
43
47
|
feedSource: config.feedSource ?? 'algorithmic',
|
|
@@ -122,6 +122,20 @@ type ContentTappedEvent = Readonly<{
|
|
|
122
122
|
index: Int32;
|
|
123
123
|
}>;
|
|
124
124
|
|
|
125
|
+
type CarouselOverlayConfigureEvent = Readonly<{
|
|
126
|
+
item: string; // JSON-serialized ImageCarouselItem
|
|
127
|
+
}>;
|
|
128
|
+
|
|
129
|
+
type CarouselOverlayActivateEvent = Readonly<{
|
|
130
|
+
item: string; // JSON-serialized ImageCarouselItem
|
|
131
|
+
}>;
|
|
132
|
+
|
|
133
|
+
type CarouselOverlayResetEvent = Readonly<{}>;
|
|
134
|
+
|
|
135
|
+
type CarouselOverlayFadeOutEvent = Readonly<{}>;
|
|
136
|
+
|
|
137
|
+
type CarouselOverlayRestoreEvent = Readonly<{}>;
|
|
138
|
+
|
|
125
139
|
type OverlayTapEvent = Readonly<{}>;
|
|
126
140
|
|
|
127
141
|
type OverlayDoubleTapEvent = Readonly<{
|
|
@@ -189,6 +203,11 @@ export interface Spec extends TurboModule {
|
|
|
189
203
|
readonly onOverlayTap: EventEmitter<OverlayTapEvent>;
|
|
190
204
|
readonly onOverlayDoubleTap: EventEmitter<OverlayDoubleTapEvent>;
|
|
191
205
|
readonly onContentTapped: EventEmitter<ContentTappedEvent>;
|
|
206
|
+
readonly onCarouselOverlayConfigure: EventEmitter<CarouselOverlayConfigureEvent>;
|
|
207
|
+
readonly onCarouselOverlayActivate: EventEmitter<CarouselOverlayActivateEvent>;
|
|
208
|
+
readonly onCarouselOverlayReset: EventEmitter<CarouselOverlayResetEvent>;
|
|
209
|
+
readonly onCarouselOverlayFadeOut: EventEmitter<CarouselOverlayFadeOutEvent>;
|
|
210
|
+
readonly onCarouselOverlayRestore: EventEmitter<CarouselOverlayRestoreEvent>;
|
|
192
211
|
}
|
|
193
212
|
|
|
194
213
|
export default TurboModuleRegistry.getEnforcing<Spec>('ShortKitModule');
|
package/src/types.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type FeedSource = 'algorithmic' | 'custom';
|
|
|
7
7
|
export interface FeedConfig {
|
|
8
8
|
feedHeight?: FeedHeight;
|
|
9
9
|
overlay?: OverlayConfig;
|
|
10
|
-
|
|
10
|
+
carouselOverlay?: CarouselOverlayConfig;
|
|
11
11
|
surveyMode?: SurveyMode;
|
|
12
12
|
muteOnStart?: boolean;
|
|
13
13
|
feedSource?: FeedSource;
|
|
@@ -21,9 +21,9 @@ export type OverlayConfig =
|
|
|
21
21
|
| 'none'
|
|
22
22
|
| { type: 'custom'; component: React.ComponentType };
|
|
23
23
|
|
|
24
|
-
export type
|
|
24
|
+
export type CarouselOverlayConfig =
|
|
25
25
|
| 'none'
|
|
26
|
-
| { type: '
|
|
26
|
+
| { type: 'custom'; component: React.ComponentType };
|
|
27
27
|
|
|
28
28
|
export type SurveyMode =
|
|
29
29
|
| 'none'
|
|
@@ -212,6 +212,7 @@ export interface ShortKitPlayerState {
|
|
|
212
212
|
playerState: PlayerState;
|
|
213
213
|
currentItem: ContentItem | null;
|
|
214
214
|
nextItem: ContentItem | null;
|
|
215
|
+
activeCellType: 'video' | 'carousel' | null;
|
|
215
216
|
time: PlayerTime;
|
|
216
217
|
isMuted: boolean;
|
|
217
218
|
playbackRate: number;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { ShortKitContext } from './ShortKitContext';
|
|
3
|
+
import type { ImageCarouselItem } from './types';
|
|
4
|
+
|
|
5
|
+
export interface ShortKitCarouselState {
|
|
6
|
+
currentCarouselItem: ImageCarouselItem | null;
|
|
7
|
+
isCarouselActive: boolean;
|
|
8
|
+
isCarouselTransitioning: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hook to access carousel overlay state from the nearest ShortKitProvider.
|
|
13
|
+
*
|
|
14
|
+
* Use this inside a custom carousel overlay component to get the current
|
|
15
|
+
* `ImageCarouselItem` data (images, title, description, etc.).
|
|
16
|
+
*
|
|
17
|
+
* Must be used within a `<ShortKitProvider>`.
|
|
18
|
+
*/
|
|
19
|
+
export function useShortKitCarousel(): ShortKitCarouselState {
|
|
20
|
+
const context = useContext(ShortKitContext);
|
|
21
|
+
if (!context) {
|
|
22
|
+
throw new Error('useShortKitCarousel must be used within a ShortKitProvider');
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
currentCarouselItem: context.currentCarouselItem,
|
|
26
|
+
isCarouselActive: context.isCarouselActive,
|
|
27
|
+
isCarouselTransitioning: context.isCarouselTransitioning,
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/useShortKitPlayer.ts
CHANGED
|
@@ -16,12 +16,20 @@ export function useShortKitPlayer(): ShortKitPlayerState {
|
|
|
16
16
|
throw new Error('useShortKitPlayer must be used within a ShortKitProvider');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// Return only player-related state and commands (exclude SDK operations
|
|
20
|
-
// and internal fields)
|
|
19
|
+
// Return only player-related state and commands (exclude SDK operations,
|
|
20
|
+
// carousel state, and internal fields)
|
|
21
21
|
const {
|
|
22
22
|
setUserId: _setUserId,
|
|
23
23
|
clearUserId: _clearUserId,
|
|
24
|
+
setFeedItems: _setFeedItems,
|
|
25
|
+
appendFeedItems: _appendFeedItems,
|
|
26
|
+
fetchContent: _fetchContent,
|
|
27
|
+
currentCarouselItem: _currentCarouselItem,
|
|
28
|
+
nextCarouselItem: _nextCarouselItem,
|
|
29
|
+
isCarouselActive: _isCarouselActive,
|
|
30
|
+
isCarouselTransitioning: _isCarouselTransitioning,
|
|
24
31
|
_overlayConfig: _overlay,
|
|
32
|
+
_carouselOverlayConfig: _carouselOverlay,
|
|
25
33
|
...playerState
|
|
26
34
|
} = context;
|
|
27
35
|
|