@shortkitsdk/react-native 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +47 -4
  2. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +431 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +83 -0
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +2 -0
  5. package/ios/ReactCarouselOverlayHost.swift +37 -17
  6. package/ios/ReactOverlayHost.swift +20 -8
  7. package/ios/ReactVideoCarouselOverlayHost.swift +283 -0
  8. package/ios/ShortKitBridge.swift +42 -0
  9. package/ios/ShortKitModule.mm +2 -1
  10. package/ios/ShortKitSDK.xcframework/Info.plist +4 -4
  11. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +1833 -201
  12. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +51 -1
  13. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  14. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +51 -1
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  17. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +1833 -201
  18. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +51 -1
  19. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  20. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +51 -1
  21. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  22. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  23. package/ios/ShortKitSDK.xcframework.bak2/Info.plist +43 -0
  24. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  25. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
  26. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +31351 -0
  27. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +865 -0
  28. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  29. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +865 -0
  30. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  31. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  32. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  33. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
  34. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +31351 -0
  35. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +865 -0
  36. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  37. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +865 -0
  38. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  39. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  40. package/package.json +1 -1
  41. package/src/ShortKitCarouselOverlaySurface.tsx +57 -2
  42. package/src/ShortKitFeed.tsx +5 -1
  43. package/src/ShortKitOverlaySurface.tsx +4 -5
  44. package/src/ShortKitVideoCarouselOverlaySurface.tsx +156 -0
  45. package/src/index.ts +4 -1
  46. package/src/serialization.ts +7 -0
  47. package/src/specs/NativeShortKitModule.ts +13 -0
  48. package/src/types.ts +39 -1
@@ -56,6 +56,13 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
56
56
  private var pendingWriteJob: Job? = null
57
57
  private var configureGeneration: Int = 0
58
58
 
59
+ /** Unique identifier for this overlay instance, used for event routing. */
60
+ val surfaceId: String = java.util.UUID.randomUUID().toString()
61
+
62
+ private var cachedItemJSON: String? = null
63
+ private var isActive: Boolean = false
64
+ private var activeImageIndex: Int = 0
65
+
59
66
  // ------------------------------------------------------------------
60
67
  // Fabric layout workaround
61
68
  // ------------------------------------------------------------------
@@ -123,6 +130,9 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
123
130
  // ------------------------------------------------------------------
124
131
 
125
132
  override fun configure(item: ImageCarouselItem) {
133
+ isActive = false
134
+ activeImageIndex = 0
135
+
126
136
  // Increment generation — any in-flight coroutine from a previous
127
137
  // configure() call will see a stale generation and bail out.
128
138
  val gen = ++configureGeneration
@@ -155,8 +165,8 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
155
165
  } else item
156
166
 
157
167
  val json = Json.encodeToString(fastItem)
158
- val bundle = Bundle().apply { putString("item", json) }
159
- applySurfaceProps(bundle)
168
+ cachedItemJSON = json
169
+ pushProps()
160
170
 
161
171
  // Pre-size the surface view NOW — before the overlay is attached to a cell.
162
172
  val parentView = parent as? android.view.View
@@ -203,12 +213,45 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
203
213
  val hasLocalImages = modifiedItem.images.any { it.url.startsWith("file://") }
204
214
  if (hasLocalImages) {
205
215
  val localJson = Json.encodeToString(modifiedItem)
206
- val localBundle = Bundle().apply { putString("item", localJson) }
207
- applySurfaceProps(localBundle)
216
+ cachedItemJSON = localJson
217
+ pushProps()
208
218
  }
209
219
  }
210
220
  }
211
221
 
222
+ override fun activatePlayback() {
223
+ isActive = true
224
+ val params = com.facebook.react.bridge.Arguments.createMap().apply {
225
+ putString("surfaceId", surfaceId)
226
+ putBoolean("isActive", true)
227
+ putString("playerState", "idle")
228
+ putBoolean("isMuted", true)
229
+ putDouble("playbackRate", 1.0)
230
+ putBoolean("captionsEnabled", false)
231
+ putNull("activeCue")
232
+ putNull("feedScrollPhase")
233
+ }
234
+ ShortKitBridge.shared?.emitEvent("onOverlayFullState", params)
235
+ }
236
+
237
+ override fun updateActiveImage(index: Int) {
238
+ activeImageIndex = index
239
+ val params = com.facebook.react.bridge.Arguments.createMap().apply {
240
+ putString("surfaceId", surfaceId)
241
+ putInt("activeImageIndex", index)
242
+ }
243
+ ShortKitBridge.shared?.emitEvent("onCarouselActiveImageChanged", params)
244
+ }
245
+
246
+ /** Build a props bundle with item data and surfaceId. */
247
+ private fun pushProps() {
248
+ val bundle = Bundle().apply {
249
+ putString("surfaceId", surfaceId)
250
+ cachedItemJSON?.let { putString("item", it) }
251
+ }
252
+ applySurfaceProps(bundle)
253
+ }
254
+
212
255
  /** Apply props to the surface, handling all lifecycle states. */
213
256
  private fun applySurfaceProps(bundle: Bundle) {
214
257
  val s = surface
@@ -0,0 +1,431 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import android.content.Context
4
+ import android.os.Bundle
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import android.widget.FrameLayout
8
+ import com.facebook.react.ReactApplication
9
+ import com.facebook.react.bridge.Arguments
10
+ import com.facebook.react.interfaces.fabric.ReactSurface
11
+ import com.facebook.react.runtime.ReactSurfaceImpl
12
+ import com.shortkit.sdk.ShortKitPlayer
13
+ import com.shortkit.sdk.model.ContentItem
14
+ import com.shortkit.sdk.model.PlayerState
15
+ import com.shortkit.sdk.model.VideoCarouselItem
16
+ import com.shortkit.sdk.overlay.VideoCarouselOverlay
17
+ import kotlinx.coroutines.CoroutineScope
18
+ import kotlinx.coroutines.Dispatchers
19
+ import kotlinx.coroutines.SupervisorJob
20
+ import kotlinx.coroutines.cancel
21
+ import kotlinx.coroutines.launch
22
+ import kotlinx.serialization.encodeToString
23
+ import kotlinx.serialization.json.Json
24
+ import java.util.UUID
25
+
26
+ /**
27
+ * A [FrameLayout] that conforms to [VideoCarouselOverlay] and hosts a React Native
28
+ * Fabric [ReactSurface] for rendering the developer's React video carousel overlay
29
+ * component inside a feed cell.
30
+ *
31
+ * Pushes [VideoCarouselItem] and active video data as surface properties, and
32
+ * emits playback state (isActive, time, playerState, isMuted) via bridge events
33
+ * using the same event names as [ReactOverlayHost] (routed by [surfaceId]).
34
+ *
35
+ * Android equivalent of `react_native_sdk/ios/ReactVideoCarouselOverlayHost.swift`.
36
+ */
37
+ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), VideoCarouselOverlay {
38
+
39
+ private companion object {
40
+ const val TAG = "SK:VidCarouselHost"
41
+ }
42
+
43
+ // Touch handling: this view sits below the ViewPager2 in z-order.
44
+ // The cell's root FrameLayout dispatches taps to this overlay when the
45
+ // ViewPager2 doesn't consume them (i.e. non-scroll touches).
46
+
47
+ // ------------------------------------------------------------------
48
+ // Configuration
49
+ // ------------------------------------------------------------------
50
+
51
+ /** Suffix for the surface module name (e.g. "news" -> "ShortKitVideoCarouselOverlay_news"). */
52
+ var videoCarouselOverlayName: String = "Default"
53
+
54
+ // ------------------------------------------------------------------
55
+ // State
56
+ // ------------------------------------------------------------------
57
+
58
+ private var surface: ReactSurface? = null
59
+ private var pendingProps: Bundle? = null
60
+ private var isInLayoutPass: Boolean = false
61
+
62
+ /** Unique identifier for this overlay instance, used for event routing. */
63
+ val surfaceId: String = UUID.randomUUID().toString()
64
+
65
+ /** Cached carouselItem JSON — updateInitProps replaces all props, doesn't merge. */
66
+ private var carouselItemJSON: String? = null
67
+
68
+ // Player state
69
+ private var player: ShortKitPlayer? = null
70
+ private var isActive: Boolean = false
71
+ private var cachedPlayerState: String = "idle"
72
+ private var cachedIsMuted: Boolean = true
73
+ private var cachedCurrentTime: Double = 0.0
74
+ private var cachedDuration: Double = 0.0
75
+ private var cachedBuffered: Double = 0.0
76
+ private var timeDirty: Boolean = false
77
+ private val handler = Handler(Looper.getMainLooper())
78
+ private var timeCoalesceRunnable: Runnable? = null
79
+ private var flowScope: CoroutineScope? = null
80
+
81
+ // ------------------------------------------------------------------
82
+ // Fabric layout workaround
83
+ // ------------------------------------------------------------------
84
+
85
+ private val layoutHandler = Handler(Looper.getMainLooper())
86
+
87
+ private val layoutRunnable = Runnable {
88
+ measure(
89
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
90
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
91
+ )
92
+ layout(left, top, right, bottom)
93
+ }
94
+
95
+ override fun requestLayout() {
96
+ super.requestLayout()
97
+ if (!isInLayoutPass) {
98
+ @Suppress("UNNECESSARY_SAFE_CALL")
99
+ layoutHandler?.post(layoutRunnable)
100
+ }
101
+ }
102
+
103
+ // ------------------------------------------------------------------
104
+ // Init
105
+ // ------------------------------------------------------------------
106
+
107
+ init {
108
+ setBackgroundColor(android.graphics.Color.TRANSPARENT)
109
+ }
110
+
111
+ // ------------------------------------------------------------------
112
+ // Surface warmup
113
+ // ------------------------------------------------------------------
114
+
115
+ fun prepareSurface() {
116
+ createSurfaceIfNeeded()
117
+
118
+ val displayW = context.resources.displayMetrics.widthPixels
119
+ val displayH = context.resources.displayMetrics.heightPixels
120
+ val wSpec = MeasureSpec.makeMeasureSpec(displayW, MeasureSpec.EXACTLY)
121
+ val hSpec = MeasureSpec.makeMeasureSpec(displayH, MeasureSpec.EXACTLY)
122
+ measure(wSpec, hSpec)
123
+ layout(0, 0, displayW, displayH)
124
+ measureAndLayoutSurfaceView(displayW, displayH)
125
+ }
126
+
127
+ // ------------------------------------------------------------------
128
+ // VideoCarouselOverlay
129
+ // ------------------------------------------------------------------
130
+
131
+ override fun configure(item: VideoCarouselItem) {
132
+ isActive = false
133
+ timeDirty = false
134
+ stopTimeCoalescing()
135
+ cachedCurrentTime = 0.0
136
+ cachedDuration = 0.0
137
+ cachedBuffered = 0.0
138
+ cachedPlayerState = "idle"
139
+
140
+ val json = Json.encodeToString(item)
141
+ carouselItemJSON = json
142
+ val bundle = Bundle().apply {
143
+ putString("surfaceId", surfaceId)
144
+ putString("carouselItem", json)
145
+ putBoolean("isActive", false)
146
+ putString("playerState", "idle")
147
+ putBoolean("isMuted", cachedIsMuted)
148
+ if (item.videos.isNotEmpty()) {
149
+ putString("activeVideo", ShortKitBridge.serializeContentItemToJSON(item.videos.first()))
150
+ putInt("activeVideoIndex", 0)
151
+ }
152
+ }
153
+ applySurfaceProps(bundle)
154
+
155
+ // Pre-size the surface view
156
+ val parentView = parent as? android.view.View
157
+ val w = if (width > 0) width
158
+ else if (parentView != null && parentView.width > 0) parentView.width
159
+ else context.resources.displayMetrics.widthPixels
160
+ val h = if (height > 0) height
161
+ else if (parentView != null && parentView.height > 0) parentView.height
162
+ else context.resources.displayMetrics.heightPixels
163
+ measureAndLayoutSurfaceView(w, h)
164
+ }
165
+
166
+ override fun updateActiveVideo(index: Int, item: ContentItem) {
167
+ // Only emit when active — matches ReactOverlayHost pattern.
168
+ // During initial setup, configure() sets the first video via surface props.
169
+ if (!isActive) return
170
+
171
+ val params = Arguments.createMap().apply {
172
+ putString("surfaceId", surfaceId)
173
+ putString("activeVideo", ShortKitBridge.serializeContentItemToJSON(item))
174
+ putInt("activeVideoIndex", index)
175
+ }
176
+ ShortKitBridge.shared?.emitEvent("onVideoCarouselActiveVideoChanged", params)
177
+ }
178
+
179
+ override fun resetState() {
180
+ // Don't clear surface props — configure() will overwrite.
181
+ }
182
+
183
+ override fun attach(player: ShortKitPlayer) {
184
+ this.player = player
185
+ resubscribeToPlayer(player)
186
+ }
187
+
188
+ override fun activatePlayback() {
189
+ isActive = true
190
+ startTimeCoalescing()
191
+
192
+ handler.post {
193
+ if (isActive) {
194
+ emitFullState()
195
+ }
196
+ }
197
+ }
198
+
199
+ override fun deactivatePlayback() {
200
+ isActive = false
201
+ timeDirty = false
202
+ stopTimeCoalescing()
203
+ }
204
+
205
+ // ------------------------------------------------------------------
206
+ // Player Subscriptions
207
+ // ------------------------------------------------------------------
208
+
209
+ private fun resubscribeToPlayer(player: ShortKitPlayer) {
210
+ flowScope?.cancel()
211
+ val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
212
+ flowScope = scope
213
+
214
+ scope.launch {
215
+ player.playerState.collect { state ->
216
+ cachedPlayerState = ShortKitBridge.playerStateString(state)
217
+ if (isActive) {
218
+ val params = Arguments.createMap().apply {
219
+ putString("surfaceId", surfaceId)
220
+ putString("playerState", cachedPlayerState)
221
+ }
222
+ ShortKitBridge.shared?.emitEvent("onOverlayPlayerStateChanged", params)
223
+ }
224
+ }
225
+ }
226
+
227
+ scope.launch {
228
+ player.isMuted.collect { muted ->
229
+ cachedIsMuted = muted
230
+ if (isActive) {
231
+ val params = Arguments.createMap().apply {
232
+ putString("surfaceId", surfaceId)
233
+ putBoolean("isMuted", cachedIsMuted)
234
+ }
235
+ ShortKitBridge.shared?.emitEvent("onOverlayMutedChanged", params)
236
+ }
237
+ }
238
+ }
239
+
240
+ scope.launch {
241
+ player.time.collect { time ->
242
+ cachedCurrentTime = time.currentMs / 1000.0
243
+ cachedDuration = time.durationMs / 1000.0
244
+ cachedBuffered = time.bufferedMs / 1000.0
245
+ if (isActive) {
246
+ timeDirty = true
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ // ------------------------------------------------------------------
253
+ // Time Coalescing (250ms)
254
+ // ------------------------------------------------------------------
255
+
256
+ private fun startTimeCoalescing() {
257
+ stopTimeCoalescing()
258
+ val runnable = object : Runnable {
259
+ override fun run() {
260
+ if (isActive && timeDirty && cachedPlayerState != "seeking") {
261
+ emitTimeUpdate()
262
+ timeDirty = false
263
+ }
264
+ if (isActive) {
265
+ handler.postDelayed(this, 250)
266
+ }
267
+ }
268
+ }
269
+ timeCoalesceRunnable = runnable
270
+ handler.postDelayed(runnable, 250)
271
+ }
272
+
273
+ private fun stopTimeCoalescing() {
274
+ timeCoalesceRunnable?.let { handler.removeCallbacks(it) }
275
+ timeCoalesceRunnable = null
276
+ }
277
+
278
+ private fun emitTimeUpdate() {
279
+ val params = Arguments.createMap().apply {
280
+ putString("surfaceId", surfaceId)
281
+ putDouble("current", cachedCurrentTime)
282
+ putDouble("duration", cachedDuration)
283
+ putDouble("buffered", cachedBuffered)
284
+ }
285
+ ShortKitBridge.shared?.emitEvent("onOverlayTimeUpdate", params)
286
+ }
287
+
288
+ // ------------------------------------------------------------------
289
+ // Full State Emission
290
+ // ------------------------------------------------------------------
291
+
292
+ private fun emitFullState() {
293
+ val bridge = ShortKitBridge.shared ?: return
294
+
295
+ val params = Arguments.createMap().apply {
296
+ putString("surfaceId", surfaceId)
297
+ putBoolean("isActive", true)
298
+ putString("playerState", cachedPlayerState)
299
+ putBoolean("isMuted", cachedIsMuted)
300
+ putDouble("playbackRate", 1.0)
301
+ putBoolean("captionsEnabled", false)
302
+ putNull("activeCue")
303
+ putNull("feedScrollPhase")
304
+ }
305
+ bridge.emitEvent("onOverlayFullState", params)
306
+ }
307
+
308
+ // ------------------------------------------------------------------
309
+ // Surface Creation
310
+ // ------------------------------------------------------------------
311
+
312
+ /** Apply props to the surface, handling all lifecycle states. */
313
+ private fun applySurfaceProps(bundle: Bundle) {
314
+ val s = surface
315
+ if (s != null && s.isRunning) {
316
+ (s as? ReactSurfaceImpl)?.updateInitProps(bundle)
317
+ } else if (s != null && !s.isRunning) {
318
+ (s as? ReactSurfaceImpl)?.updateInitProps(bundle)
319
+ s.start()
320
+ } else {
321
+ pendingProps = bundle
322
+ createSurfaceIfNeeded()
323
+ }
324
+ }
325
+
326
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
327
+ super.onSizeChanged(w, h, oldw, oldh)
328
+ if (w > 0 && h > 0) {
329
+ measureAndLayoutSurfaceView(w, h)
330
+ }
331
+ }
332
+
333
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
334
+ isInLayoutPass = true
335
+ try {
336
+ super.onLayout(changed, left, top, right, bottom)
337
+ val w = right - left
338
+ val h = bottom - top
339
+ if (w > 0 && h > 0) {
340
+ measureAndLayoutSurfaceView(w, h)
341
+ } else {
342
+ val parentView = parent as? android.view.View
343
+ val pw = parentView?.width ?: 0
344
+ val ph = parentView?.height ?: 0
345
+ if (pw > 0 && ph > 0) {
346
+ measureAndLayoutSurfaceView(pw, ph)
347
+ }
348
+ }
349
+ } finally {
350
+ isInLayoutPass = false
351
+ }
352
+ }
353
+
354
+ private fun measureAndLayoutSurfaceView(w: Int, h: Int) {
355
+ val sv = surface?.view ?: return
356
+ if (sv.width == w && sv.height == h) return
357
+ val wSpec = android.view.View.MeasureSpec.makeMeasureSpec(w, android.view.View.MeasureSpec.EXACTLY)
358
+ val hSpec = android.view.View.MeasureSpec.makeMeasureSpec(h, android.view.View.MeasureSpec.EXACTLY)
359
+ sv.measure(wSpec, hSpec)
360
+ sv.layout(0, 0, w, h)
361
+ }
362
+
363
+ override fun onAttachedToWindow() {
364
+ super.onAttachedToWindow()
365
+
366
+ // Re-subscribe to player flows if we were detached and reattached
367
+ if (flowScope == null) {
368
+ player?.let { resubscribeToPlayer(it) }
369
+ }
370
+
371
+ val parentView = parent as? android.view.View
372
+ if (parentView != null && parentView.width > 0 && parentView.height > 0 && (width == 0 || height == 0)) {
373
+ val wSpec = MeasureSpec.makeMeasureSpec(parentView.width, MeasureSpec.EXACTLY)
374
+ val hSpec = MeasureSpec.makeMeasureSpec(parentView.height, MeasureSpec.EXACTLY)
375
+ measure(wSpec, hSpec)
376
+ layout(0, 0, parentView.width, parentView.height)
377
+ }
378
+ }
379
+
380
+ private fun createSurfaceIfNeeded() {
381
+ if (surface != null) return
382
+
383
+ val reactHost = (context.applicationContext as? ReactApplication)?.reactHost
384
+ if (reactHost == null) {
385
+ android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
386
+ return
387
+ }
388
+ val moduleName = "ShortKitVideoCarouselOverlay_$videoCarouselOverlayName"
389
+
390
+ val initialProps = pendingProps
391
+ pendingProps = null
392
+
393
+ val newSurface = reactHost.createSurface(context, moduleName, initialProps)
394
+ surface = newSurface
395
+
396
+ newSurface.view?.let { surfaceView ->
397
+ surfaceView.layoutParams = LayoutParams(
398
+ LayoutParams.MATCH_PARENT,
399
+ LayoutParams.MATCH_PARENT
400
+ )
401
+ addView(surfaceView)
402
+ }
403
+
404
+ // Only start if we have item data
405
+ if (initialProps != null) {
406
+ newSurface.start()
407
+ }
408
+
409
+ val parentView = parent as? android.view.View
410
+ val w = if (width > 0) width else parentView?.width ?: 0
411
+ val h = if (height > 0) height else parentView?.height ?: 0
412
+ if (w > 0 && h > 0) {
413
+ measureAndLayoutSurfaceView(w, h)
414
+ }
415
+ }
416
+
417
+ // ------------------------------------------------------------------
418
+ // Cleanup
419
+ // ------------------------------------------------------------------
420
+
421
+ override fun onDetachedFromWindow() {
422
+ super.onDetachedFromWindow()
423
+ flowScope?.cancel()
424
+ flowScope = null
425
+ isActive = false
426
+ stopTimeCoalescing()
427
+ if (surface?.isRunning == true) {
428
+ surface?.stop()
429
+ }
430
+ }
431
+ }
@@ -17,6 +17,7 @@ import com.shortkit.sdk.model.FeedInput
17
17
  import com.shortkit.sdk.model.FeedScrollPhase
18
18
  import com.shortkit.sdk.model.FeedTransitionPhase
19
19
  import com.shortkit.sdk.model.ImageCarouselItem
20
+ import com.shortkit.sdk.model.VideoCarouselItem
20
21
  import com.shortkit.sdk.model.JsonValue
21
22
  import com.shortkit.sdk.model.PlayerState
22
23
  import com.shortkit.sdk.config.AdOverlayMode
@@ -26,6 +27,7 @@ import com.shortkit.sdk.config.FeedHeight
26
27
  import com.shortkit.sdk.config.FeedSource
27
28
  import com.shortkit.sdk.config.ScrollAxis
28
29
  import com.shortkit.sdk.config.SurveyOverlayMode
30
+ import com.shortkit.sdk.config.VideoCarouselOverlayMode
29
31
  import com.shortkit.sdk.config.VideoOverlayMode
30
32
  import com.shortkit.sdk.feed.FeedPreload
31
33
  import com.shortkit.sdk.feed.ShortKitFeedFragment
@@ -152,6 +154,8 @@ class ShortKitBridge(
152
154
 
153
155
  val carouselOverlayRaw = obj.optString("carouselOverlay", null)
154
156
  val carouselOverlay = parseCarouselOverlay(carouselOverlayRaw, context)
157
+ val videoCarouselOverlayRaw = obj.optString("videoCarouselOverlay", null)
158
+ val videoCarouselOverlay = parseVideoCarouselOverlay(videoCarouselOverlayRaw, context)
155
159
  val autoplay = obj.optBoolean("autoplay", true)
156
160
  val filter = obj.optJSONObject("filter")?.let { parseFeedFilterToModel(it.toString()) }
157
161
 
@@ -162,6 +166,7 @@ class ShortKitBridge(
162
166
  feedHeight = feedHeight,
163
167
  videoOverlay = videoOverlay,
164
168
  carouselOverlay = carouselOverlay,
169
+ videoCarouselOverlay = videoCarouselOverlay,
165
170
  surveyOverlay = SurveyOverlayMode.None,
166
171
  adOverlay = AdOverlayMode.None,
167
172
  muteOnStart = muteOnStart,
@@ -273,6 +278,43 @@ class ShortKitBridge(
273
278
  }
274
279
  }
275
280
 
281
+ /**
282
+ * Parse a double-stringified video carousel overlay JSON.
283
+ * - `"\"none\""` -> None
284
+ * - `"{\"type\":\"custom\"}"` -> Custom with ReactVideoCarouselOverlayHost factory
285
+ */
286
+ private fun parseVideoCarouselOverlay(json: String?, context: android.content.Context?): VideoCarouselOverlayMode {
287
+ if (json.isNullOrEmpty()) return VideoCarouselOverlayMode.None
288
+ return try {
289
+ val parsed = json.trim()
290
+
291
+ if (parsed == "\"none\"" || parsed == "none") {
292
+ return VideoCarouselOverlayMode.None
293
+ }
294
+
295
+ val inner = if (parsed.startsWith("\"") && parsed.endsWith("\"")) {
296
+ JSONObject(parsed.substring(1, parsed.length - 1).replace("\\\"", "\""))
297
+ } else {
298
+ JSONObject(parsed)
299
+ }
300
+
301
+ if (inner.optString("type") == "custom" && context != null) {
302
+ val name = inner.optString("name", "Default")
303
+ val ctx = context.applicationContext
304
+ VideoCarouselOverlayMode.Custom {
305
+ ReactVideoCarouselOverlayHost(ctx).apply {
306
+ videoCarouselOverlayName = name
307
+ prepareSurface()
308
+ }
309
+ }
310
+ } else {
311
+ VideoCarouselOverlayMode.None
312
+ }
313
+ } catch (_: Exception) {
314
+ VideoCarouselOverlayMode.None
315
+ }
316
+ }
317
+
276
318
  private fun parseFeedFilter(json: String?): String? {
277
319
  // On Android, the SDK accepts the raw JSON string for filtering.
278
320
  // Return as-is if non-null/non-empty.
@@ -316,6 +358,26 @@ class ShortKitBridge(
316
358
  val carouselItem = parseImageCarouselItem(itemObj) ?: continue
317
359
  result.add(FeedInput.ImageCarousel(carouselItem))
318
360
  }
361
+ "videoCarousel" -> {
362
+ val itemObj = obj.optJSONObject("item") ?: continue
363
+ val videosArr = itemObj.optJSONArray("videos") ?: continue
364
+ val videos = mutableListOf<ContentItem>()
365
+ for (i in 0 until videosArr.length()) {
366
+ val videoObj = videosArr.getJSONObject(i)
367
+ parseContentItem(videoObj)?.let { videos.add(it) }
368
+ }
369
+ if (videos.isEmpty()) continue
370
+ val carouselItem = VideoCarouselItem(
371
+ id = itemObj.getString("id"),
372
+ videos = videos,
373
+ title = itemObj.optString("title", null),
374
+ description = itemObj.optString("description", null),
375
+ author = itemObj.optString("author", null),
376
+ section = itemObj.optString("section", null),
377
+ articleUrl = itemObj.optString("articleUrl", null),
378
+ )
379
+ result.add(FeedInput.VideoCarousel(carouselItem))
380
+ }
319
381
  }
320
382
  }
321
383
  result.ifEmpty { null }
@@ -349,6 +411,27 @@ class ShortKitBridge(
349
411
  )
350
412
  }
351
413
 
414
+ private fun parseContentItem(obj: JSONObject): ContentItem? {
415
+ val id = obj.optString("id", null) ?: return null
416
+ val title = obj.optString("title", null) ?: return null
417
+ val duration = obj.optDouble("duration", -1.0).takeIf { it >= 0 } ?: return null
418
+ val streamingUrl = obj.optString("streamingUrl", null) ?: return null
419
+ val thumbnailUrl = obj.optString("thumbnailUrl", null) ?: return null
420
+ return ContentItem(
421
+ id = id,
422
+ playbackId = obj.optString("playbackId", null),
423
+ title = title,
424
+ description = obj.optString("description", null),
425
+ duration = duration,
426
+ streamingUrl = streamingUrl,
427
+ thumbnailUrl = thumbnailUrl,
428
+ captionTracks = emptyList(),
429
+ author = obj.optString("author", null),
430
+ articleUrl = obj.optString("articleUrl", null),
431
+ fallbackUrl = obj.optString("fallbackUrl", null),
432
+ )
433
+ }
434
+
352
435
  private fun buildCaptionTracksJSONArray(tracks: List<CaptionTrack>): JSONArray {
353
436
  val arr = JSONArray()
354
437
  for (track in tracks) {
@@ -273,6 +273,8 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
273
273
  "onOverlayFeedScrollPhaseChanged" -> emitOnOverlayFeedScrollPhaseChanged(params)
274
274
  "onOverlayTimeUpdate" -> emitOnOverlayTimeUpdate(params)
275
275
  "onOverlayFullState" -> emitOnOverlayFullState(params)
276
+ "onCarouselActiveImageChanged" -> emitOnCarouselActiveImageChanged(params)
277
+ "onVideoCarouselActiveVideoChanged" -> emitOnVideoCarouselActiveVideoChanged(params)
276
278
  else -> {
277
279
  android.util.Log.w("SK:Module", "sendEvent: unknown event name '$name', using legacy emitter")
278
280
  reactApplicationContext