@shortkitsdk/react-native 0.2.6 → 0.2.12

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 (75) hide show
  1. package/ShortKitReactNative.podspec +1 -0
  2. package/android/build.gradle.kts +17 -1
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +379 -0
  4. package/android/src/main/java/com/shortkit/reactnative/ReactLoadingHost.kt +40 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +570 -0
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +1029 -0
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +212 -219
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +17 -3
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +157 -742
  10. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +11 -2
  11. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +2 -2
  12. package/ios/ReactCarouselOverlayHost.swift +177 -0
  13. package/ios/ReactLoadingHost.swift +38 -0
  14. package/ios/ReactOverlayHost.swift +444 -0
  15. package/ios/SKFabricSurfaceWrapper.h +18 -0
  16. package/ios/SKFabricSurfaceWrapper.mm +57 -0
  17. package/ios/ShortKitBridge.swift +220 -63
  18. package/ios/ShortKitFeedView.swift +82 -228
  19. package/ios/ShortKitFeedViewManager.mm +3 -2
  20. package/ios/ShortKitModule.mm +69 -37
  21. package/ios/ShortKitPlayerNativeView.swift +39 -8
  22. package/ios/ShortKitReactNative-Bridging-Header.h +2 -0
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
  24. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +3683 -1249
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +56 -15
  26. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +56 -15
  28. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  29. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
  30. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +3683 -1249
  31. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +56 -15
  32. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  33. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +56 -15
  34. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  35. package/ios/ShortKitSDK.xcframework.bak/Info.plist +43 -0
  36. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  37. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
  38. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +28917 -0
  39. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +824 -0
  40. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  41. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +824 -0
  42. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  43. package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  44. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  45. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
  46. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +28917 -0
  47. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +824 -0
  48. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  49. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +824 -0
  50. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  51. package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  52. package/ios/ShortKitWidgetNativeView.swift +3 -3
  53. package/package.json +1 -1
  54. package/src/ShortKitCarouselOverlaySurface.tsx +55 -0
  55. package/src/ShortKitCommands.ts +31 -0
  56. package/src/ShortKitContext.ts +6 -24
  57. package/src/ShortKitFeed.tsx +124 -41
  58. package/src/ShortKitLoadingSurface.tsx +24 -0
  59. package/src/ShortKitOverlaySurface.tsx +313 -0
  60. package/src/ShortKitPlayer.tsx +30 -9
  61. package/src/ShortKitProvider.tsx +28 -285
  62. package/src/index.ts +9 -3
  63. package/src/serialization.ts +20 -39
  64. package/src/specs/NativeShortKitModule.ts +74 -45
  65. package/src/specs/ShortKitFeedViewNativeComponent.ts +3 -2
  66. package/src/types.ts +84 -16
  67. package/src/useShortKit.ts +1 -3
  68. package/src/useShortKitPlayer.ts +7 -7
  69. package/android/src/main/java/com/shortkit/reactnative/ShortKitCarouselOverlayBridge.kt +0 -48
  70. package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +0 -128
  71. package/ios/ShortKitCarouselOverlayBridge.swift +0 -219
  72. package/ios/ShortKitOverlayBridge.swift +0 -111
  73. package/src/CarouselOverlayManager.tsx +0 -70
  74. package/src/OverlayManager.tsx +0 -87
  75. package/src/useShortKitCarousel.ts +0 -29
@@ -0,0 +1,570 @@
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.view.MotionEvent
8
+ import android.widget.FrameLayout
9
+ import com.facebook.react.ReactApplication
10
+ import com.facebook.react.bridge.Arguments
11
+ import com.facebook.react.interfaces.fabric.ReactSurface
12
+ import com.facebook.react.runtime.ReactSurfaceImpl
13
+ import com.shortkit.sdk.ShortKitPlayer
14
+ import com.shortkit.sdk.VttCue
15
+ import com.shortkit.sdk.model.ContentItem
16
+ import com.shortkit.sdk.model.FeedScrollPhase
17
+ import com.shortkit.sdk.model.PlayerState
18
+ import com.shortkit.sdk.overlay.FeedOverlay
19
+ import kotlinx.coroutines.CoroutineScope
20
+ import kotlinx.coroutines.Dispatchers
21
+ import kotlinx.coroutines.SupervisorJob
22
+ import kotlinx.coroutines.cancel
23
+ import kotlinx.coroutines.launch
24
+ import org.json.JSONObject
25
+ import java.util.UUID
26
+
27
+ /**
28
+ * A [FrameLayout] that conforms to [FeedOverlay] and hosts a React Native
29
+ * Fabric [ReactSurface] for rendering the developer's React overlay component
30
+ * inside a feed cell.
31
+ *
32
+ * Each cell gets its own instance (via the overlay factory). The surface is
33
+ * created eagerly on the first [attach] call and reused across cell reuse cycles.
34
+ *
35
+ * Surface properties are set ONCE per item (in [configure]) to provide initial
36
+ * values. All subsequent dynamic state changes flow through native module events
37
+ * routed by [surfaceId], triggering React re-renders instead of Fabric remounts.
38
+ *
39
+ * Android equivalent of `react_native_sdk/ios/ReactOverlayHost.swift`.
40
+ */
41
+ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
42
+
43
+ private companion object {
44
+ const val TAG = "SK:OverlayHost"
45
+ }
46
+
47
+ // ------------------------------------------------------------------
48
+ // Configuration
49
+ // ------------------------------------------------------------------
50
+
51
+ /** Suffix for the surface module name (e.g. "news" -> "ShortKitOverlay_news"). */
52
+ var overlayName: String = "Default"
53
+
54
+ /** Unique identifier for this overlay instance, used for event routing. */
55
+ val surfaceId: String = UUID.randomUUID().toString()
56
+
57
+ // ------------------------------------------------------------------
58
+ // Fabric layout workaround
59
+ // ------------------------------------------------------------------
60
+
61
+ private val layoutHandler = Handler(Looper.getMainLooper())
62
+
63
+ /** Reusable runnable to avoid lambda allocation on every requestLayout. */
64
+ private val layoutRunnable = Runnable {
65
+ measure(
66
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
67
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
68
+ )
69
+ layout(left, top, right, bottom)
70
+ }
71
+
72
+ /**
73
+ * Fabric may suppress layout propagation to native child views inside
74
+ * the SDK's RecyclerView cells. Override to force a manual layout pass.
75
+ */
76
+ override fun requestLayout() {
77
+ super.requestLayout()
78
+ if (!isInLayoutPass) {
79
+ @Suppress("UNNECESSARY_SAFE_CALL")
80
+ layoutHandler?.post(layoutRunnable)
81
+ }
82
+ }
83
+
84
+ // ------------------------------------------------------------------
85
+ // State
86
+ // ------------------------------------------------------------------
87
+
88
+ private var surface: ReactSurface? = null
89
+ private var player: ShortKitPlayer? = null
90
+ private var currentItem: ContentItem? = null
91
+ private var isActive: Boolean = false
92
+
93
+ // Player state cache — always updated by flow subscriptions,
94
+ // but only emitted to JS when isActive == true.
95
+ private var cachedPlayerState: String = "idle"
96
+ private var cachedIsMuted: Boolean = true
97
+ private var cachedPlaybackRate: Double = 1.0
98
+ private var cachedCaptionsEnabled: Boolean = false
99
+ private var cachedActiveCue: JSONObject? = null
100
+ private var cachedFeedScrollPhase: String? = null
101
+
102
+ // Time coalescing (250ms)
103
+ private var cachedCurrentTime: Double = 0.0
104
+ private var cachedDuration: Double = 0.0
105
+ private var cachedBuffered: Double = 0.0
106
+ private var timeDirty: Boolean = false
107
+ private val handler = Handler(Looper.getMainLooper())
108
+ private var timeCoalesceRunnable: Runnable? = null
109
+
110
+ private var flowScope: CoroutineScope? = null
111
+ private var isInLayoutPass: Boolean = false
112
+
113
+ // ------------------------------------------------------------------
114
+ // Init
115
+ // ------------------------------------------------------------------
116
+
117
+ /** Height of the scrubber touch area in dp (matches styles.scrubberTouchArea). */
118
+ private val scrubberTouchHeightPx: Int
119
+ get() = (40 * resources.displayMetrics.density).toInt()
120
+
121
+ init {
122
+ setBackgroundColor(android.graphics.Color.TRANSPARENT)
123
+ }
124
+
125
+ // ------------------------------------------------------------------
126
+ // Touch: prevent feed scroll when scrubbing
127
+ // ------------------------------------------------------------------
128
+
129
+ /**
130
+ * When the user touches the bottom scrubber zone, immediately tell the
131
+ * parent RecyclerView to stop intercepting touch events. This prevents
132
+ * vertical feed swipes from stealing the horizontal scrub gesture.
133
+ *
134
+ * Must be done in native (not JS) because RecyclerView's
135
+ * onInterceptTouchEvent fires before the RN touch system processes
136
+ * the PanResponder grant.
137
+ */
138
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
139
+ if (ev.action == MotionEvent.ACTION_DOWN && height > 0) {
140
+ if (ev.y > height - scrubberTouchHeightPx) {
141
+ parent?.requestDisallowInterceptTouchEvent(true)
142
+ }
143
+ }
144
+ return super.onInterceptTouchEvent(ev)
145
+ }
146
+
147
+ // ------------------------------------------------------------------
148
+ // FeedOverlay
149
+ // ------------------------------------------------------------------
150
+
151
+ override fun attach(player: ShortKitPlayer) {
152
+ this.player = player
153
+ resubscribeToPlayer(player)
154
+ }
155
+
156
+ /** Whether the surface has been started at least once. */
157
+ private var surfaceHasStarted: Boolean = false
158
+
159
+ override fun configure(item: ContentItem) {
160
+ val isSameItem = item.id == currentItem?.id
161
+ currentItem = item
162
+ isActive = false
163
+ timeDirty = false
164
+ stopTimeCoalescing()
165
+
166
+ // Reset ALL cached state so recycled cells don't flash stale values.
167
+ cachedCurrentTime = 0.0
168
+ cachedDuration = 0.0
169
+ cachedBuffered = 0.0
170
+ cachedPlayerState = "idle"
171
+ cachedActiveCue = null
172
+ cachedFeedScrollPhase = null
173
+
174
+ if (surface == null) {
175
+ createSurfaceIfNeeded()
176
+ surfaceHasStarted = true
177
+ } else if (!surfaceHasStarted) {
178
+ (surface as? ReactSurfaceImpl)?.updateInitProps(buildInitialPropsBundle())
179
+ surfaceHasStarted = true
180
+ } else if (isSameItem) {
181
+ // Same item = deactivation only. The overlay already has this
182
+ // item's data from a previous swipe. Just deactivate (isActive
183
+ // set to false above, timer stopped). No event emission — avoids
184
+ // broadcasting to all 7 surfaces for no visual change.
185
+ } else {
186
+ // Different item — send via event for React tree diff (not
187
+ // updateInitProps which causes full Fabric remount).
188
+ val params = Arguments.createMap().apply {
189
+ putString("surfaceId", surfaceId)
190
+ putString("item", ShortKitBridge.serializeContentItemToJSON(item))
191
+ }
192
+ ShortKitBridge.shared?.emitEvent("onOverlayItemChanged", params)
193
+ }
194
+ // Pre-size the surface view
195
+ val parentView = parent as? android.view.View
196
+ val w = if (width > 0) width
197
+ else if (parentView != null && parentView.width > 0) parentView.width
198
+ else context.resources.displayMetrics.widthPixels
199
+ val h = if (height > 0) height
200
+ else if (parentView != null && parentView.height > 0) parentView.height
201
+ else context.resources.displayMetrics.heightPixels
202
+ measureAndLayoutSurfaceView(w, h)
203
+ }
204
+
205
+ override fun activatePlayback() {
206
+ isActive = true
207
+ startTimeCoalescing()
208
+
209
+ // Defer the event burst to the next tick.
210
+ handler.post {
211
+ if (isActive) {
212
+ emitFullState()
213
+ }
214
+ }
215
+ }
216
+
217
+ override fun fadeOutForTransition() {
218
+ // No-op. The JS overlay component handles fade via feedScrollPhase
219
+ // prop (dragging/settled), not these events. iOS ReactOverlayHost
220
+ // doesn't implement these methods at all. The previous implementation
221
+ // serialized the full ContentItem to JSON and emitted across the
222
+ // bridge to a listener that doesn't exist — 2-15ms of pure waste
223
+ // on every swipe.
224
+ }
225
+
226
+ override fun restoreFromTransition() {
227
+ // No-op. See fadeOutForTransition comment.
228
+ }
229
+
230
+ // ------------------------------------------------------------------
231
+ // View lifecycle
232
+ // ------------------------------------------------------------------
233
+
234
+ override fun onAttachedToWindow() {
235
+ super.onAttachedToWindow()
236
+
237
+ // Re-subscribe to player flows if we were detached and reattached
238
+ // (RecyclerView cell recycling). onDetachedFromWindow cancels flowScope.
239
+ if (flowScope == null) {
240
+ player?.let { resubscribeToPlayer(it) }
241
+ }
242
+
243
+ // Force self-measurement using parent dimensions. The SDK adds us
244
+ // with MATCH_PARENT but RecyclerView may not have laid out the cell yet.
245
+ val parentView = parent as? android.view.View
246
+ if (parentView != null && parentView.width > 0 && parentView.height > 0 && (width == 0 || height == 0)) {
247
+ val wSpec = MeasureSpec.makeMeasureSpec(parentView.width, MeasureSpec.EXACTLY)
248
+ val hSpec = MeasureSpec.makeMeasureSpec(parentView.height, MeasureSpec.EXACTLY)
249
+ measure(wSpec, hSpec)
250
+ layout(0, 0, parentView.width, parentView.height)
251
+ }
252
+ }
253
+
254
+ override fun onDetachedFromWindow() {
255
+ super.onDetachedFromWindow()
256
+ // Cancel flow subscriptions to avoid emitting events for off-screen cells.
257
+ // Do NOT stop the surface — keep JS alive so subscriptions persist across
258
+ // RecyclerView detach/reattach cycles. This matches iOS behavior where the
259
+ // surface is never stopped (UICollectionView doesn't detach from window).
260
+ flowScope?.cancel()
261
+ flowScope = null
262
+ isActive = false
263
+ stopTimeCoalescing()
264
+ }
265
+
266
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
267
+ super.onSizeChanged(w, h, oldw, oldh)
268
+ if (w > 0 && h > 0) {
269
+ measureAndLayoutSurfaceView(w, h)
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Continuously enforce size on the ReactSurfaceView child.
275
+ *
276
+ * ReactSurfaceView.onMeasure only sizes correctly with EXACTLY mode
277
+ * specs. Fabric's async layout may reset the surface view to 0x0 after
278
+ * our initial measure. Override onLayout to re-push the correct
279
+ * dimensions on every layout pass — mirrors iOS's layoutSubviews()
280
+ * calling setMinimumSize/setMaximumSize in ReactOverlayHost.swift.
281
+ */
282
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
283
+ isInLayoutPass = true
284
+ try {
285
+ super.onLayout(changed, left, top, right, bottom)
286
+ val w = right - left
287
+ val h = bottom - top
288
+ if (w > 0 && h > 0) {
289
+ measureAndLayoutSurfaceView(w, h)
290
+ } else {
291
+ // Host still 0x0 — try parent dimensions
292
+ val parentView = parent as? android.view.View
293
+ val pw = parentView?.width ?: 0
294
+ val ph = parentView?.height ?: 0
295
+ if (pw > 0 && ph > 0) {
296
+ measureAndLayoutSurfaceView(pw, ph)
297
+ }
298
+ }
299
+ } finally {
300
+ isInLayoutPass = false
301
+ }
302
+ }
303
+
304
+ private fun measureAndLayoutSurfaceView(w: Int, h: Int) {
305
+ val sv = surface?.view ?: return
306
+ if (sv.width == w && sv.height == h) return // already correct
307
+ val wSpec = MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY)
308
+ val hSpec = MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
309
+ sv.measure(wSpec, hSpec)
310
+ sv.layout(0, 0, w, h)
311
+ }
312
+
313
+ // ------------------------------------------------------------------
314
+ // Surface Creation
315
+ // ------------------------------------------------------------------
316
+
317
+ private fun createSurfaceIfNeeded() {
318
+ if (surface != null) return
319
+
320
+ val appContext = context.applicationContext
321
+ val reactHost = (appContext as? ReactApplication)?.reactHost
322
+
323
+ if (reactHost == null) {
324
+ android.util.Log.e(TAG, "[$surfaceId] createSurface FAILED: reactHost is null")
325
+ return
326
+ }
327
+ val moduleName = "ShortKitOverlay_$overlayName"
328
+ val initialProps = buildInitialPropsBundle()
329
+
330
+ val newSurface = reactHost.createSurface(context, moduleName, initialProps)
331
+ surface = newSurface
332
+
333
+ val surfaceView = newSurface.view
334
+ if (surfaceView != null) {
335
+ surfaceView.layoutParams = LayoutParams(
336
+ LayoutParams.MATCH_PARENT,
337
+ LayoutParams.MATCH_PARENT
338
+ )
339
+ addView(surfaceView)
340
+ } else {
341
+ android.util.Log.e(TAG, "[$surfaceId] createSurface: surfaceView is NULL")
342
+ }
343
+
344
+ newSurface.start()
345
+
346
+ // The host may still be 0x0 at this point. Use the parent's dimensions.
347
+ val parentView = parent as? android.view.View
348
+ val w = if (width > 0) width else parentView?.width ?: 0
349
+ val h = if (height > 0) height else parentView?.height ?: 0
350
+ if (w > 0 && h > 0) {
351
+ measureAndLayoutSurfaceView(w, h)
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Build a Bundle with the full initial state for the surface.
357
+ * Mirrors iOS's pushInitialProperties().
358
+ */
359
+ private fun buildInitialPropsBundle(): Bundle = Bundle().apply {
360
+ putString("surfaceId", surfaceId)
361
+ putBoolean("isActive", false)
362
+ putString("playerState", "idle")
363
+ putBoolean("isMuted", cachedIsMuted)
364
+ putDouble("playbackRate", cachedPlaybackRate)
365
+ putBoolean("captionsEnabled", cachedCaptionsEnabled)
366
+ currentItem?.let { item ->
367
+ putString("item", ShortKitBridge.serializeContentItemToJSON(item))
368
+ }
369
+ cachedActiveCue?.let { cue ->
370
+ putString("activeCue", cue.toString())
371
+ }
372
+ cachedFeedScrollPhase?.let { putString("feedScrollPhase", it) }
373
+ }
374
+
375
+ // ------------------------------------------------------------------
376
+ // Player Subscriptions
377
+ // ------------------------------------------------------------------
378
+
379
+ private fun resubscribeToPlayer(player: ShortKitPlayer) {
380
+ flowScope?.cancel()
381
+ val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
382
+ flowScope = scope
383
+
384
+ // Player state
385
+ scope.launch {
386
+ player.playerState.collect { state ->
387
+ cachedPlayerState = ShortKitBridge.playerStateString(state)
388
+ if (isActive) {
389
+ val params = Arguments.createMap().apply {
390
+ putString("surfaceId", surfaceId)
391
+ putString("playerState", cachedPlayerState)
392
+ }
393
+ ShortKitBridge.shared?.emitEvent("onOverlayPlayerStateChanged", params)
394
+ }
395
+ }
396
+ }
397
+
398
+ // Muted
399
+ scope.launch {
400
+ player.isMuted.collect { muted ->
401
+ cachedIsMuted = muted
402
+ if (isActive) {
403
+ val params = Arguments.createMap().apply {
404
+ putString("surfaceId", surfaceId)
405
+ putBoolean("isMuted", cachedIsMuted)
406
+ }
407
+ ShortKitBridge.shared?.emitEvent("onOverlayMutedChanged", params)
408
+ }
409
+ }
410
+ }
411
+
412
+ // Playback rate
413
+ scope.launch {
414
+ player.playbackRate.collect { rate ->
415
+ cachedPlaybackRate = rate.toDouble()
416
+ if (isActive) {
417
+ val params = Arguments.createMap().apply {
418
+ putString("surfaceId", surfaceId)
419
+ putDouble("playbackRate", cachedPlaybackRate)
420
+ }
421
+ ShortKitBridge.shared?.emitEvent("onOverlayPlaybackRateChanged", params)
422
+ }
423
+ }
424
+ }
425
+
426
+ // Captions enabled
427
+ scope.launch {
428
+ player.captionsEnabled.collect { enabled ->
429
+ cachedCaptionsEnabled = enabled
430
+ if (isActive) {
431
+ val params = Arguments.createMap().apply {
432
+ putString("surfaceId", surfaceId)
433
+ putBoolean("captionsEnabled", cachedCaptionsEnabled)
434
+ }
435
+ ShortKitBridge.shared?.emitEvent("onOverlayCaptionsEnabledChanged", params)
436
+ }
437
+ }
438
+ }
439
+
440
+ // Active cue (ms -> seconds)
441
+ scope.launch {
442
+ player.activeCue.collect { cue ->
443
+ cachedActiveCue = if (cue != null) {
444
+ JSONObject().apply {
445
+ put("text", cue.text)
446
+ put("startTime", cue.startMs / 1000.0)
447
+ put("endTime", cue.endMs / 1000.0)
448
+ }
449
+ } else {
450
+ null
451
+ }
452
+ if (isActive) {
453
+ val params = Arguments.createMap().apply {
454
+ putString("surfaceId", surfaceId)
455
+ val cueJson = cachedActiveCue?.toString()
456
+ if (cueJson != null) {
457
+ putString("activeCue", cueJson)
458
+ } else {
459
+ putNull("activeCue")
460
+ }
461
+ }
462
+ ShortKitBridge.shared?.emitEvent("onOverlayActiveCueChanged", params)
463
+ }
464
+ }
465
+ }
466
+
467
+ // Time (ms -> seconds, coalesced via handler)
468
+ scope.launch {
469
+ player.time.collect { time ->
470
+ cachedCurrentTime = time.currentMs / 1000.0
471
+ cachedDuration = time.durationMs / 1000.0
472
+ cachedBuffered = time.bufferedMs / 1000.0
473
+ if (isActive) {
474
+ timeDirty = true
475
+ }
476
+ }
477
+ }
478
+
479
+ // Feed scroll phase
480
+ scope.launch {
481
+ player.feedScrollPhase.collect { phase ->
482
+ cachedFeedScrollPhase = when (phase) {
483
+ is FeedScrollPhase.Dragging -> JSONObject().apply {
484
+ put("phase", "dragging")
485
+ put("fromId", phase.fromId)
486
+ }.toString()
487
+ is FeedScrollPhase.Settled -> """{"phase":"settled"}"""
488
+ }
489
+ if (isActive) {
490
+ val params = Arguments.createMap().apply {
491
+ putString("surfaceId", surfaceId)
492
+ putString("feedScrollPhase", cachedFeedScrollPhase)
493
+ }
494
+ ShortKitBridge.shared?.emitEvent("onOverlayFeedScrollPhaseChanged", params)
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ // ------------------------------------------------------------------
501
+ // Time Coalescing (250ms)
502
+ // ------------------------------------------------------------------
503
+
504
+ private fun startTimeCoalescing() {
505
+ stopTimeCoalescing()
506
+ val runnable = object : Runnable {
507
+ override fun run() {
508
+ // Suppress time updates while seeking. On iOS, AVPlayer's time
509
+ // observer naturally stops firing during seeks. On Android,
510
+ // ExoPlayer's time flow keeps emitting the stale pre-seek
511
+ // position, causing the progress bar to oscillate between the
512
+ // seek target and the old position. Skip until seek completes.
513
+ if (isActive && timeDirty && cachedPlayerState != "seeking") {
514
+ emitTimeUpdate()
515
+ timeDirty = false
516
+ }
517
+ if (isActive) {
518
+ handler.postDelayed(this, 250)
519
+ }
520
+ }
521
+ }
522
+ timeCoalesceRunnable = runnable
523
+ handler.postDelayed(runnable, 250)
524
+ }
525
+
526
+ private fun stopTimeCoalescing() {
527
+ timeCoalesceRunnable?.let { handler.removeCallbacks(it) }
528
+ timeCoalesceRunnable = null
529
+ }
530
+
531
+ private fun emitTimeUpdate() {
532
+ val params = Arguments.createMap().apply {
533
+ putString("surfaceId", surfaceId)
534
+ putDouble("current", cachedCurrentTime)
535
+ putDouble("duration", cachedDuration)
536
+ putDouble("buffered", cachedBuffered)
537
+ }
538
+ ShortKitBridge.shared?.emitEvent("onOverlayTimeUpdate", params)
539
+ }
540
+
541
+ // ------------------------------------------------------------------
542
+ // Full State Emission
543
+ // ------------------------------------------------------------------
544
+
545
+ /**
546
+ * Emit all cached state as individual events. Called from [activatePlayback]
547
+ * (deferred) to synchronize JS state after cell reuse.
548
+ */
549
+ private fun emitFullState() {
550
+ val bridge = ShortKitBridge.shared ?: return
551
+
552
+ val params = Arguments.createMap().apply {
553
+ putString("surfaceId", surfaceId)
554
+ putBoolean("isActive", true)
555
+ putString("playerState", cachedPlayerState)
556
+ putBoolean("isMuted", cachedIsMuted)
557
+ putDouble("playbackRate", cachedPlaybackRate)
558
+ putBoolean("captionsEnabled", cachedCaptionsEnabled)
559
+ val cueJson = cachedActiveCue?.toString()
560
+ if (cueJson != null) {
561
+ putString("activeCue", cueJson)
562
+ } else {
563
+ putNull("activeCue")
564
+ }
565
+ cachedFeedScrollPhase?.let { putString("feedScrollPhase", it) }
566
+ ?: putNull("feedScrollPhase")
567
+ }
568
+ bridge.emitEvent("onOverlayFullState", params)
569
+ }
570
+ }