@shortkitsdk/react-native 0.2.5 → 0.2.11

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