@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.
@@ -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
- /** Overlay for the currently active cell (nativeID="overlay-current"). */
54
+ /** Video overlay for the currently active cell (nativeID="overlay-current"). */
55
55
  private var currentOverlayView: View? = null
56
56
 
57
- /** Overlay for the upcoming cell (nativeID="overlay-next"). */
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 overlay follows the active cell
238
+ // Current overlays follow the active cell
224
239
  currentOverlayView?.translationY = -delta
240
+ currentCarouselOverlayView?.translationY = -delta
225
241
 
226
- // Next overlay: positioned one page ahead in the scroll direction
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
- nextOverlayView?.translationY = cellHeight - delta
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
- nextOverlayView?.translationY = -cellHeight - delta
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 parsed = parseCustomFeedItems(items) ?: return
237
- shortKit?.setFeedItems(parsed)
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 parsed = parseCustomFeedItems(items) ?: return
243
- shortKit?.appendFeedItems(parsed)
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 = CarouselOverlayMode.None,
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
- ShortKitModule.shared?.emitOverlayEvent("onOverlayConfigure", item)
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
  }
@@ -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: .none,
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
- /// Overlay for the currently active cell (nativeID="overlay-current").
30
+ /// Video overlay for the currently active cell (nativeID="overlay-current").
31
31
  private weak var currentOverlayView: UIView?
32
- /// Overlay for the upcoming cell (nativeID="overlay-next").
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 overlay follows the active cell
173
- currentOverlayView?.transform = CGAffineTransform(translationX: 0, y: -delta)
187
+ // Current overlays follow the active cell
188
+ currentOverlayView?.transform = translateY
189
+ currentCarouselOverlayView?.transform = translateY
174
190
 
175
- // Next overlay: positioned one page ahead in the scroll direction
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
- nextOverlayView?.transform = CGAffineTransform(translationX: 0, y: cellHeight - delta)
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
- nextOverlayView?.transform = CGAffineTransform(translationX: 0, y: -cellHeight - delta)
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`.
@@ -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
- [_shortKitBridge setFeedItems:items];
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
- [_shortKitBridge appendFeedItems:items];
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
- bridge?.emitOverlayEvent("onOverlayConfigure", item: item)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shortkitsdk/react-native",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "ShortKit React Native SDK — short-form video feed",
5
5
  "react-native": "src/index",
6
6
  "source": "src/index",
@@ -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
+ }
@@ -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);
@@ -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
  }
@@ -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
- return { ...state, currentItem: action.payload, isActive: true };
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
- CarouselMode,
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';
@@ -13,7 +13,7 @@ import type {
13
13
  export interface SerializedFeedConfig {
14
14
  feedHeight: string;
15
15
  overlay: string;
16
- carouselMode: string;
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
- carouselMode: JSON.stringify(config.carouselMode ?? 'none'),
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
- carouselMode?: CarouselMode;
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 CarouselMode =
24
+ export type CarouselOverlayConfig =
25
25
  | 'none'
26
- | { type: 'template'; name: 'default' };
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
+ }
@@ -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