@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.
- package/ShortKitReactNative.podspec +1 -0
- package/android/build.gradle.kts +17 -1
- package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +379 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactLoadingHost.kt +40 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +570 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +1029 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +212 -219
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +17 -3
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +157 -742
- package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +11 -2
- package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +2 -2
- package/ios/ReactCarouselOverlayHost.swift +177 -0
- package/ios/ReactLoadingHost.swift +38 -0
- package/ios/ReactOverlayHost.swift +444 -0
- package/ios/SKFabricSurfaceWrapper.h +18 -0
- package/ios/SKFabricSurfaceWrapper.mm +57 -0
- package/ios/ShortKitBridge.swift +220 -63
- package/ios/ShortKitFeedView.swift +82 -228
- package/ios/ShortKitFeedViewManager.mm +3 -2
- package/ios/ShortKitModule.mm +69 -37
- package/ios/ShortKitPlayerNativeView.swift +39 -8
- package/ios/ShortKitReactNative-Bridging-Header.h +2 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +3683 -1249
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +56 -15
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +56 -15
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +1 -1
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +3683 -1249
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +56 -15
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +56 -15
- package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework.bak/Info.plist +43 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +28917 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +824 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +824 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +28917 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +824 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +824 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
- package/ios/ShortKitSDK.xcframework.bak/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitWidgetNativeView.swift +3 -3
- package/package.json +1 -1
- package/src/ShortKitCarouselOverlaySurface.tsx +55 -0
- package/src/ShortKitCommands.ts +31 -0
- package/src/ShortKitContext.ts +6 -24
- package/src/ShortKitFeed.tsx +124 -41
- package/src/ShortKitLoadingSurface.tsx +24 -0
- package/src/ShortKitOverlaySurface.tsx +313 -0
- package/src/ShortKitPlayer.tsx +30 -9
- package/src/ShortKitProvider.tsx +28 -285
- package/src/index.ts +9 -3
- package/src/serialization.ts +20 -39
- package/src/specs/NativeShortKitModule.ts +74 -45
- package/src/specs/ShortKitFeedViewNativeComponent.ts +3 -2
- package/src/types.ts +84 -16
- package/src/useShortKit.ts +1 -3
- package/src/useShortKitPlayer.ts +7 -7
- package/android/src/main/java/com/shortkit/reactnative/ShortKitCarouselOverlayBridge.kt +0 -48
- package/android/src/main/java/com/shortkit/reactnative/ShortKitOverlayBridge.kt +0 -128
- package/ios/ShortKitCarouselOverlayBridge.swift +0 -219
- package/ios/ShortKitOverlayBridge.swift +0 -111
- package/src/CarouselOverlayManager.tsx +0 -70
- package/src/OverlayManager.tsx +0 -87
- 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
|
+
}
|