@shortkitsdk/react-native 0.2.34 → 0.2.36
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/android/build.gradle.kts +8 -0
- package/android/libs/shortkit-release.aar +0 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +100 -47
- package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +54 -8
- package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +240 -27
- package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +151 -1
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +135 -6
- package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +15 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +21 -11
- package/android/src/main/java/com/shortkit/reactnative/ShortKitPackage.kt +0 -2
- package/android/src/test/java/com/shortkit/reactnative/ReactCarouselOverlayHostEmitTest.kt +134 -0
- package/android/src/test/java/com/shortkit/reactnative/ReactOverlayHostDragTest.kt +45 -0
- package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostDragTest.kt +69 -0
- package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostEmitTest.kt +144 -0
- package/android/src/test/java/com/shortkit/reactnative/ShortKitFeedViewActivePropTest.kt +57 -0
- package/ios/ReactOverlayHost.swift +10 -8
- package/ios/ReactVideoCarouselOverlayHost.swift +6 -5
- package/ios/ShortKitBridge.swift +18 -0
- package/ios/ShortKitModule.mm +5 -0
- package/ios/ShortKitPlayerNativeView.swift +36 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +1252 -82
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +28 -2
- 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 +28 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +1252 -82
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +28 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +28 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +1252 -82
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +28 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +28 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
- package/ios/ShortKitWidgetNativeView.swift +30 -3
- package/ios/ShortKitWidgetNativeViewManager.mm +1 -0
- package/package.json +1 -1
- package/src/ShortKitCommands.ts +20 -0
- package/src/ShortKitFeed.tsx +21 -0
- package/src/ShortKitPlayer.tsx +20 -1
- package/src/ShortKitWidget.tsx +63 -15
- package/src/specs/NativeShortKitModule.ts +10 -0
- package/src/specs/ShortKitWidgetViewNativeComponent.ts +15 -3
- package/src/types.ts +40 -0
- package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +0 -149
- package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerViewManager.kt +0 -35
- package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +0 -149
- package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetViewManager.kt +0 -30
|
@@ -4,6 +4,7 @@ import android.content.Context
|
|
|
4
4
|
import android.os.Bundle
|
|
5
5
|
import android.os.Handler
|
|
6
6
|
import android.os.Looper
|
|
7
|
+
import android.view.MotionEvent
|
|
7
8
|
import android.widget.FrameLayout
|
|
8
9
|
import com.facebook.react.ReactApplication
|
|
9
10
|
import com.facebook.react.bridge.Arguments
|
|
@@ -11,6 +12,7 @@ import com.facebook.react.interfaces.fabric.ReactSurface
|
|
|
11
12
|
import com.facebook.react.runtime.ReactSurfaceImpl
|
|
12
13
|
import com.shortkit.sdk.ShortKitPlayer
|
|
13
14
|
import com.shortkit.sdk.model.ContentItem
|
|
15
|
+
import com.shortkit.sdk.model.FeedScrollPhase
|
|
14
16
|
import com.shortkit.sdk.model.PlayerState
|
|
15
17
|
import com.shortkit.sdk.model.VideoCarouselItem
|
|
16
18
|
import com.shortkit.sdk.overlay.VideoCarouselOverlay
|
|
@@ -40,9 +42,17 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
40
42
|
const val TAG = "SK:VidCarouselHost"
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
// Touch handling: this view sits
|
|
44
|
-
//
|
|
45
|
-
//
|
|
45
|
+
// Touch handling: this view sits ABOVE the ViewPager2 in z-order
|
|
46
|
+
// (overlay_container is the last child of cell_video_carousel.xml's
|
|
47
|
+
// root FrameLayout). RN's pointerEvents="box-none" lets the React
|
|
48
|
+
// tree claim DOWN only for interactive children; the inner pager
|
|
49
|
+
// sees DOWN for empty-area touches via standard z-order dispatch.
|
|
50
|
+
//
|
|
51
|
+
// For touches that DO land on interactive overlay children — chiefly
|
|
52
|
+
// the scrubber drag — we additionally call requestDisallowIntercept
|
|
53
|
+
// on the parent chain so the inner carousel pager and the outer feed
|
|
54
|
+
// pager don't intercept on subsequent MOVE events. Mirrors the same
|
|
55
|
+
// idiom used in ReactOverlayHost for the regular feed overlay.
|
|
46
56
|
|
|
47
57
|
// ------------------------------------------------------------------
|
|
48
58
|
// Configuration
|
|
@@ -65,6 +75,62 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
65
75
|
/** Cached carouselItem JSON — updateInitProps replaces all props, doesn't merge. */
|
|
66
76
|
private var carouselItemJSON: String? = null
|
|
67
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Currently configured carousel item — used to detect item transitions so
|
|
80
|
+
* we can emit an event instead of triggering a full Fabric remount, and
|
|
81
|
+
* (SHO-15) to surface-filter onCarouselActiveVideoCompleted events so
|
|
82
|
+
* only the host bound to the just-completed video's carousel emits.
|
|
83
|
+
*/
|
|
84
|
+
internal var currentCarouselItem: VideoCarouselItem? = null
|
|
85
|
+
private set
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether initial props have been pushed at least once. First mount goes
|
|
89
|
+
* through applySurfaceProps + emit (matches iOS asymmetry); subsequent
|
|
90
|
+
* different-item rebinds emit only.
|
|
91
|
+
*/
|
|
92
|
+
internal var hasPushedInitialProps: Boolean = false
|
|
93
|
+
private set
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Tracks active drag phase. While true, per-frame player-state emissions
|
|
97
|
+
* (playerState, isMuted, time) are suppressed; emitFullState() re-syncs
|
|
98
|
+
* cached values on settle.
|
|
99
|
+
*/
|
|
100
|
+
internal var isDragging: Boolean = false
|
|
101
|
+
private set
|
|
102
|
+
|
|
103
|
+
/** Test-only setter for [isDragging]. */
|
|
104
|
+
internal var isDraggingForTest: Boolean
|
|
105
|
+
get() = isDragging
|
|
106
|
+
set(value) { isDragging = value }
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Test seam for emit-event interception. Production uses the default;
|
|
110
|
+
* tests overwrite to capture emitted (name, params) pairs.
|
|
111
|
+
*/
|
|
112
|
+
internal var emitEvent: (String, com.facebook.react.bridge.WritableMap) -> Unit =
|
|
113
|
+
{ name, params -> ShortKitBridge.shared?.emitEvent(name, params) }
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Test seam for WritableMap creation. Production uses Arguments.createMap()
|
|
117
|
+
* which requires JNI; tests overwrite with JavaOnlyMap() to avoid Robolectric
|
|
118
|
+
* NativeLoader issues. Same pattern as ReactCarouselOverlayHost.
|
|
119
|
+
*/
|
|
120
|
+
internal var createMap: () -> com.facebook.react.bridge.WritableMap =
|
|
121
|
+
{ com.facebook.react.bridge.Arguments.createMap() }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Test seam for ReactSurface creation. Production resolves from ReactHost.
|
|
125
|
+
* Tests inject a stub so the surface is non-null after applySurfaceProps()
|
|
126
|
+
* and hasPushedInitialProps can flip. Same pattern as ReactCarouselOverlayHost.
|
|
127
|
+
*/
|
|
128
|
+
internal var createSurface: (moduleName: String, initialProps: Bundle?) -> ReactSurface? =
|
|
129
|
+
{ moduleName, initialProps ->
|
|
130
|
+
(context.applicationContext as? ReactApplication)?.reactHost
|
|
131
|
+
?.createSurface(context, moduleName, initialProps)
|
|
132
|
+
}
|
|
133
|
+
|
|
68
134
|
// Player state
|
|
69
135
|
private var player: ShortKitPlayer? = null
|
|
70
136
|
private var isActive: Boolean = false
|
|
@@ -76,6 +142,17 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
76
142
|
private var timeDirty: Boolean = false
|
|
77
143
|
private val handler = Handler(Looper.getMainLooper())
|
|
78
144
|
private var timeCoalesceRunnable: Runnable? = null
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Bottom strip in which a DOWN signals "interactive overlay drag pending"
|
|
148
|
+
* (chiefly the scrubber). Width = scrubberTouchArea height (40dp) plus the
|
|
149
|
+
* sample's BOTTOM_SAFE_AREA (100dp) = 140dp. Matches the JS scrubberTouchArea
|
|
150
|
+
* geometry in NewsVideoCarouselOverlay.tsx; future overlays with non-default
|
|
151
|
+
* BOTTOM_SAFE_AREA will need a prop to communicate this down.
|
|
152
|
+
*/
|
|
153
|
+
private val interactiveBottomStripPx: Int
|
|
154
|
+
get() = (140 * resources.displayMetrics.density).toInt()
|
|
155
|
+
|
|
79
156
|
private var flowScope: CoroutineScope? = null
|
|
80
157
|
|
|
81
158
|
// ------------------------------------------------------------------
|
|
@@ -129,30 +206,49 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
129
206
|
// ------------------------------------------------------------------
|
|
130
207
|
|
|
131
208
|
override fun configure(item: VideoCarouselItem) {
|
|
209
|
+
val isSameItem = item.id == currentCarouselItem?.id
|
|
210
|
+
currentCarouselItem = item
|
|
211
|
+
|
|
132
212
|
isActive = false
|
|
213
|
+
isDragging = false
|
|
133
214
|
timeDirty = false
|
|
134
215
|
stopTimeCoalescing()
|
|
135
216
|
cachedCurrentTime = 0.0
|
|
136
217
|
cachedDuration = 0.0
|
|
137
218
|
cachedBuffered = 0.0
|
|
138
219
|
cachedPlayerState = "idle"
|
|
220
|
+
// Seed cachedIsMuted from the SDK's current value so the new
|
|
221
|
+
// surface's initialProps reflect actual mute state. The flow
|
|
222
|
+
// subscription (resubscribeToPlayer) will keep it in sync going
|
|
223
|
+
// forward, but its first emission can race with a host tap on the
|
|
224
|
+
// new cell, leading to a stale-true bug where the toggle target is
|
|
225
|
+
// computed wrong.
|
|
226
|
+
player?.isMuted?.value?.let { cachedIsMuted = it }
|
|
139
227
|
|
|
140
228
|
val json = Json.encodeToString(item)
|
|
141
229
|
carouselItemJSON = json
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
230
|
+
|
|
231
|
+
if (!hasPushedInitialProps) {
|
|
232
|
+
// First mount: BOTH applySurfaceProps AND emit. Matches iOS asymmetry
|
|
233
|
+
// at ReactVideoCarouselOverlayHost.swift:127-138.
|
|
234
|
+
applySurfaceProps(buildInitialPropsBundle(item, json))
|
|
235
|
+
// Only flip the flag if surface was actually created. If reactHost was
|
|
236
|
+
// null (createSurface seam returned null), surface remains null and
|
|
237
|
+
// pendingProps is parked — stay in "first mount" mode so the next
|
|
238
|
+
// configure() retries. Mirrors iOS pattern.
|
|
239
|
+
if (surface != null) {
|
|
240
|
+
hasPushedInitialProps = true
|
|
151
241
|
}
|
|
242
|
+
// Emit fires regardless of surface state — external subscribers
|
|
243
|
+
// should see the item update even when the in-surface listener
|
|
244
|
+
// isn't yet wired. iOS does the same at :135-137.
|
|
245
|
+
emitItemChanged(item, json)
|
|
246
|
+
} else if (!isSameItem) {
|
|
247
|
+
emitItemChanged(item, json)
|
|
152
248
|
}
|
|
153
|
-
|
|
249
|
+
// else: same item rebind — no emit, no Fabric prop push.
|
|
154
250
|
|
|
155
|
-
// Pre-size the surface view
|
|
251
|
+
// Pre-size the surface view (existing logic, unchanged).
|
|
156
252
|
val parentView = parent as? android.view.View
|
|
157
253
|
val w = if (width > 0) width
|
|
158
254
|
else if (parentView != null && parentView.width > 0) parentView.width
|
|
@@ -163,6 +259,58 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
163
259
|
measureAndLayoutSurfaceView(w, h)
|
|
164
260
|
}
|
|
165
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Build the initial-properties bundle for first-mount setProperties path.
|
|
264
|
+
* Mirrors iOS buildInitialProps(item:json:) at
|
|
265
|
+
* ReactVideoCarouselOverlayHost.swift:143-158.
|
|
266
|
+
*
|
|
267
|
+
* Note: when videos is empty, activeVideo + activeVideoIndex keys are
|
|
268
|
+
* OMITTED from the bundle (matches iOS first-mount behavior). The
|
|
269
|
+
* emit path uses a separate convention — it emits putNull("activeVideo")
|
|
270
|
+
* to match iOS NSNull(). See emitItemChanged() below.
|
|
271
|
+
*/
|
|
272
|
+
private fun buildInitialPropsBundle(item: VideoCarouselItem, json: String): Bundle =
|
|
273
|
+
Bundle().apply {
|
|
274
|
+
putString("surfaceId", surfaceId)
|
|
275
|
+
putString("carouselItem", json)
|
|
276
|
+
putBoolean("isActive", false)
|
|
277
|
+
putString("playerState", "idle")
|
|
278
|
+
putBoolean("isMuted", cachedIsMuted)
|
|
279
|
+
if (item.videos.isNotEmpty()) {
|
|
280
|
+
putString("activeVideo", ShortKitBridge.serializeContentItemToJSON(item.videos.first()))
|
|
281
|
+
putInt("activeVideoIndex", 0)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Emit onVideoCarouselItemChanged for React diff on cell reuse.
|
|
287
|
+
* Mirrors iOS emitItemChanged(item:json:) at
|
|
288
|
+
* ReactVideoCarouselOverlayHost.swift:162-183.
|
|
289
|
+
*
|
|
290
|
+
* Empty-videos: uses putNull("activeVideo") — NOT key omission. This
|
|
291
|
+
* matches iOS NSNull() and avoids the empty-string-falsy-check bug
|
|
292
|
+
* documented at iOS :175-180.
|
|
293
|
+
*/
|
|
294
|
+
private fun emitItemChanged(item: VideoCarouselItem, json: String) {
|
|
295
|
+
val params = createMap().apply {
|
|
296
|
+
putString("surfaceId", surfaceId)
|
|
297
|
+
putString("carouselItem", json)
|
|
298
|
+
putBoolean("isActive", false)
|
|
299
|
+
putString("playerState", "idle")
|
|
300
|
+
putBoolean("isMuted", cachedIsMuted)
|
|
301
|
+
putInt("activeVideoIndex", 0)
|
|
302
|
+
if (item.videos.isNotEmpty()) {
|
|
303
|
+
putString(
|
|
304
|
+
"activeVideo",
|
|
305
|
+
ShortKitBridge.serializeContentItemToJSON(item.videos.first()),
|
|
306
|
+
)
|
|
307
|
+
} else {
|
|
308
|
+
putNull("activeVideo")
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
emitEvent("onVideoCarouselItemChanged", params)
|
|
312
|
+
}
|
|
313
|
+
|
|
166
314
|
override fun updateActiveVideo(index: Int, item: ContentItem) {
|
|
167
315
|
// Only emit when active — matches ReactOverlayHost pattern.
|
|
168
316
|
// During initial setup, configure() sets the first video via surface props.
|
|
@@ -214,12 +362,12 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
214
362
|
scope.launch {
|
|
215
363
|
player.playerState.collect { state ->
|
|
216
364
|
cachedPlayerState = ShortKitBridge.playerStateString(state)
|
|
217
|
-
if (isActive) {
|
|
218
|
-
val params =
|
|
365
|
+
if (isActive && !isDragging) {
|
|
366
|
+
val params = createMap().apply {
|
|
219
367
|
putString("surfaceId", surfaceId)
|
|
220
368
|
putString("playerState", cachedPlayerState)
|
|
221
369
|
}
|
|
222
|
-
|
|
370
|
+
emitEvent("onOverlayPlayerStateChanged", params)
|
|
223
371
|
}
|
|
224
372
|
}
|
|
225
373
|
}
|
|
@@ -227,12 +375,12 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
227
375
|
scope.launch {
|
|
228
376
|
player.isMuted.collect { muted ->
|
|
229
377
|
cachedIsMuted = muted
|
|
230
|
-
if (isActive) {
|
|
231
|
-
val params =
|
|
378
|
+
if (isActive && !isDragging) {
|
|
379
|
+
val params = createMap().apply {
|
|
232
380
|
putString("surfaceId", surfaceId)
|
|
233
381
|
putBoolean("isMuted", cachedIsMuted)
|
|
234
382
|
}
|
|
235
|
-
|
|
383
|
+
emitEvent("onOverlayMutedChanged", params)
|
|
236
384
|
}
|
|
237
385
|
}
|
|
238
386
|
}
|
|
@@ -242,11 +390,56 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
242
390
|
cachedCurrentTime = time.currentMs / 1000.0
|
|
243
391
|
cachedDuration = time.durationMs / 1000.0
|
|
244
392
|
cachedBuffered = time.bufferedMs / 1000.0
|
|
245
|
-
if (isActive) {
|
|
393
|
+
if (isActive && !isDragging) {
|
|
246
394
|
timeDirty = true
|
|
247
395
|
}
|
|
248
396
|
}
|
|
249
397
|
}
|
|
398
|
+
|
|
399
|
+
scope.launch {
|
|
400
|
+
player.feedScrollPhase.collect { phase ->
|
|
401
|
+
when (phase) {
|
|
402
|
+
is FeedScrollPhase.Dragging -> {
|
|
403
|
+
isDragging = true
|
|
404
|
+
stopTimeCoalescing()
|
|
405
|
+
}
|
|
406
|
+
is FeedScrollPhase.Settled -> {
|
|
407
|
+
val wasDragging = isDragging
|
|
408
|
+
isDragging = false
|
|
409
|
+
if (isActive) {
|
|
410
|
+
startTimeCoalescing()
|
|
411
|
+
if (wasDragging) emitFullState()
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// SHO-15: subscribe to ShortKitCarousel.activeVideoCompleted and emit
|
|
419
|
+
// onCarouselActiveVideoCompleted to JS. Surface-filtering: only the
|
|
420
|
+
// currently-active host (one per active carousel cell) emits, AND the
|
|
421
|
+
// completed item's id must belong to this host's bound carousel.
|
|
422
|
+
scope.launch {
|
|
423
|
+
val carousel = com.shortkit.sdk.ShortKit.activeInstance()?.carousel ?: return@launch
|
|
424
|
+
carousel.activeVideoCompleted.collect { event ->
|
|
425
|
+
if (!isActive) return@collect
|
|
426
|
+
val ourCarousel = currentCarouselItem ?: return@collect
|
|
427
|
+
val belongs = ourCarousel.videos.any { it.id == event.contentItem.id }
|
|
428
|
+
if (!belongs) return@collect
|
|
429
|
+
|
|
430
|
+
val feedId = ShortKitBridge.shared?.activeSurfaceFeedId() ?: ""
|
|
431
|
+
val params = Arguments.createMap().apply {
|
|
432
|
+
putString("feedId", feedId)
|
|
433
|
+
putString("surfaceId", surfaceId)
|
|
434
|
+
putString("contentItem", ShortKitBridge.serializeContentItemToJSON(event.contentItem))
|
|
435
|
+
putInt("indexInCarousel", event.indexInCarousel)
|
|
436
|
+
putString("carouselItem", ShortKitBridge.serializeVideoCarouselItemToJSON(event.carouselItem))
|
|
437
|
+
putBoolean("wasLast", event.wasLast)
|
|
438
|
+
putBoolean("willAutoAdvance", event.willAutoAdvance)
|
|
439
|
+
}
|
|
440
|
+
ShortKitBridge.shared?.emitEvent("onCarouselActiveVideoCompleted", params)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
250
443
|
}
|
|
251
444
|
|
|
252
445
|
// ------------------------------------------------------------------
|
|
@@ -323,6 +516,24 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
323
516
|
}
|
|
324
517
|
}
|
|
325
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Pre-dispatch hook so a DOWN inside the scrubber strip immediately tells
|
|
521
|
+
* every ancestor (the cell, the inner carousel pager, and the outer feed
|
|
522
|
+
* pager) to stop intercepting touch events. Without this, the inner
|
|
523
|
+
* carousel pager's horizontal-pan recognizer and the outer feed pager's
|
|
524
|
+
* vertical-pan recognizer can grab a scrubber drag mid-stream.
|
|
525
|
+
*
|
|
526
|
+
* Mirrors ReactOverlayHost.onInterceptTouchEvent for the regular feed.
|
|
527
|
+
*/
|
|
528
|
+
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
|
529
|
+
if (ev.action == MotionEvent.ACTION_DOWN && height > 0) {
|
|
530
|
+
if (ev.y > height - interactiveBottomStripPx) {
|
|
531
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return super.onInterceptTouchEvent(ev)
|
|
535
|
+
}
|
|
536
|
+
|
|
326
537
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
327
538
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
328
539
|
if (w > 0 && h > 0) {
|
|
@@ -380,17 +591,18 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
380
591
|
private fun createSurfaceIfNeeded() {
|
|
381
592
|
if (surface != null) return
|
|
382
593
|
|
|
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
594
|
val moduleName = "ShortKitVideoCarouselOverlay_$videoCarouselOverlayName"
|
|
389
595
|
|
|
390
596
|
val initialProps = pendingProps
|
|
391
597
|
pendingProps = null
|
|
392
598
|
|
|
393
|
-
val newSurface =
|
|
599
|
+
val newSurface = createSurface(moduleName, initialProps)
|
|
600
|
+
if (newSurface == null) {
|
|
601
|
+
android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
|
|
602
|
+
// Restore pending props so a future createSurfaceIfNeeded() can retry.
|
|
603
|
+
if (initialProps != null) pendingProps = initialProps
|
|
604
|
+
return
|
|
605
|
+
}
|
|
394
606
|
surface = newSurface
|
|
395
607
|
|
|
396
608
|
newSurface.view?.let { surfaceView ->
|
|
@@ -423,6 +635,7 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
423
635
|
flowScope?.cancel()
|
|
424
636
|
flowScope = null
|
|
425
637
|
isActive = false
|
|
638
|
+
currentCarouselItem = null
|
|
426
639
|
stopTimeCoalescing()
|
|
427
640
|
if (surface?.isRunning == true) {
|
|
428
641
|
surface?.stop()
|
|
@@ -76,6 +76,25 @@ class ShortKitBridge(
|
|
|
76
76
|
// Static serialization helpers (called by overlay hosts)
|
|
77
77
|
// ------------------------------------------------------------------
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Serialize a [VideoCarouselItem] to a JSON string for bridge transport.
|
|
81
|
+
* SHO-15: used by ReactVideoCarouselOverlayHost when emitting
|
|
82
|
+
* onCarouselActiveVideoCompleted. Mirrors how
|
|
83
|
+
* ReactVideoCarouselOverlayHost.configure already serializes
|
|
84
|
+
* VideoCarouselItem via kotlinx.serialization.
|
|
85
|
+
*/
|
|
86
|
+
fun serializeVideoCarouselItemToJSON(item: com.shortkit.sdk.model.VideoCarouselItem?): String {
|
|
87
|
+
if (item == null) return "null"
|
|
88
|
+
return try {
|
|
89
|
+
kotlinx.serialization.json.Json.encodeToString(
|
|
90
|
+
com.shortkit.sdk.model.VideoCarouselItem.serializer(),
|
|
91
|
+
item,
|
|
92
|
+
)
|
|
93
|
+
} catch (_: Exception) {
|
|
94
|
+
"null"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
/**
|
|
80
99
|
* Serialize a [ContentItem] to a JSON string for bridge transport.
|
|
81
100
|
*/
|
|
@@ -349,7 +368,7 @@ class ShortKitBridge(
|
|
|
349
368
|
} catch (_: Exception) { null }
|
|
350
369
|
}
|
|
351
370
|
|
|
352
|
-
|
|
371
|
+
internal fun parseFeedInputs(json: String): List<FeedInput>? {
|
|
353
372
|
return try {
|
|
354
373
|
val arr = JSONArray(json)
|
|
355
374
|
val result = mutableListOf<FeedInput>()
|
|
@@ -535,7 +554,12 @@ class ShortKitBridge(
|
|
|
535
554
|
// Wire delegate
|
|
536
555
|
sdk.delegate = object : ShortKitDelegate {
|
|
537
556
|
override fun onContentTapped(contentId: String, index: Int) {
|
|
557
|
+
// Capture feedId synchronously — after the post-to-main hop
|
|
558
|
+
// the active surface may have shifted (user swiped tabs).
|
|
559
|
+
// Mirrors iOS ShortKitBridge.swift:1174 pattern.
|
|
560
|
+
val feedId = activeSurfaceFeedId()
|
|
538
561
|
val params = Arguments.createMap().apply {
|
|
562
|
+
putString("feedId", feedId)
|
|
539
563
|
putString("contentId", contentId)
|
|
540
564
|
putInt("index", index)
|
|
541
565
|
}
|
|
@@ -550,7 +574,9 @@ class ShortKitBridge(
|
|
|
550
574
|
lastProgressEmitTime = now
|
|
551
575
|
}
|
|
552
576
|
|
|
577
|
+
val feedId = activeSurfaceFeedId()
|
|
553
578
|
val params = Arguments.createMap().apply {
|
|
579
|
+
putString("feedId", feedId)
|
|
554
580
|
when (state) {
|
|
555
581
|
is ShortKitRefreshState.Idle -> {
|
|
556
582
|
putString("status", "idle")
|
|
@@ -574,11 +600,13 @@ class ShortKitBridge(
|
|
|
574
600
|
}
|
|
575
601
|
|
|
576
602
|
override fun onFeedContentFetched(items: List<ContentItem>) {
|
|
603
|
+
val feedId = activeSurfaceFeedId()
|
|
577
604
|
val arr = org.json.JSONArray()
|
|
578
605
|
for (item in items) {
|
|
579
606
|
arr.put(org.json.JSONObject(serializeContentItemToJSON(item)))
|
|
580
607
|
}
|
|
581
608
|
val params = Arguments.createMap().apply {
|
|
609
|
+
putString("feedId", feedId)
|
|
582
610
|
putString("items", arr.toString())
|
|
583
611
|
}
|
|
584
612
|
emitEventOnMain("onDidFetchContentItems", params)
|
|
@@ -640,6 +668,25 @@ class ShortKitBridge(
|
|
|
640
668
|
}
|
|
641
669
|
emitEventOnMain("onFeedReady", params)
|
|
642
670
|
}
|
|
671
|
+
// Per-feed pull-to-refresh — the JS-side ShortKitFeed subscribes
|
|
672
|
+
// to `onRefreshStateChangedPerFeed` (not the unscoped `onRefreshStateChanged`),
|
|
673
|
+
// so this is the event that actually drives the host's refresh
|
|
674
|
+
// handler. Mirrors iOS ShortKitBridge.swift:74-90 emit pattern.
|
|
675
|
+
fragment.onRefreshStateChanged = { state ->
|
|
676
|
+
// Throttle pulling events to max 1 per 16ms to avoid bridge saturation
|
|
677
|
+
if (state is ShortKitRefreshState.Pulling) {
|
|
678
|
+
val now = android.os.SystemClock.uptimeMillis()
|
|
679
|
+
if (now - lastProgressEmitTime >= 16L) {
|
|
680
|
+
lastProgressEmitTime = now
|
|
681
|
+
emitRefreshPerFeed(id, state)
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
emitRefreshPerFeed(id, state)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
fragment.onVideoCarouselCellTap = { payload ->
|
|
688
|
+
emitVideoCarouselCellTap(payload.id, payload.index, payload.pageIndex)
|
|
689
|
+
}
|
|
643
690
|
|
|
644
691
|
// Replay buffered operations after the fragment's view is created.
|
|
645
692
|
// commitNowAllowingStateLoss triggers onCreate but onViewCreated
|
|
@@ -669,6 +716,61 @@ class ShortKitBridge(
|
|
|
669
716
|
return feedRegistry[id]?.get()
|
|
670
717
|
}
|
|
671
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Emit `onRefreshStateChangedPerFeed` with feedId in the payload.
|
|
721
|
+
* Used by the per-feed [ShortKitFeedFragment.onRefreshStateChanged]
|
|
722
|
+
* hook wired in [registerFeedFragment]. Mirrors iOS
|
|
723
|
+
* `ShortKitBridge.swift:74-90` body construction.
|
|
724
|
+
*/
|
|
725
|
+
private fun emitRefreshPerFeed(feedId: String, state: ShortKitRefreshState) {
|
|
726
|
+
val params = Arguments.createMap().apply {
|
|
727
|
+
putString("feedId", feedId)
|
|
728
|
+
when (state) {
|
|
729
|
+
is ShortKitRefreshState.Idle -> {
|
|
730
|
+
putString("status", "idle")
|
|
731
|
+
putDouble("progress", 0.0)
|
|
732
|
+
}
|
|
733
|
+
is ShortKitRefreshState.Pulling -> {
|
|
734
|
+
putString("status", "pulling")
|
|
735
|
+
putDouble("progress", state.progress.toDouble())
|
|
736
|
+
}
|
|
737
|
+
is ShortKitRefreshState.Triggered -> {
|
|
738
|
+
putString("status", "triggered")
|
|
739
|
+
putDouble("progress", 0.0)
|
|
740
|
+
}
|
|
741
|
+
is ShortKitRefreshState.Refreshing -> {
|
|
742
|
+
putString("status", "refreshing")
|
|
743
|
+
putDouble("progress", 0.0)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
emitEventOnMain("onRefreshStateChangedPerFeed", params)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Reverse lookup: which registered feedId corresponds to the SDK's
|
|
752
|
+
* currently-active surface? Returns the empty string when no feed
|
|
753
|
+
* surface is active or when the active surface isn't a registered
|
|
754
|
+
* fragment (e.g. host-driven scenario without RN bridge mounting).
|
|
755
|
+
*
|
|
756
|
+
* Used by the three delegate-level emit sites
|
|
757
|
+
* (`onContentTapped`, `onRefreshStateChanged`, `onDidFetchContentItems`)
|
|
758
|
+
* so multi-feed setups route the callback to the originating
|
|
759
|
+
* `<ShortKitFeed>` instead of broadcasting to all mounted feeds.
|
|
760
|
+
*
|
|
761
|
+
* Mirrors iOS `ShortKitBridge.activeSurfaceFeedId()`
|
|
762
|
+
* (`react_native_sdk/ios/ShortKitBridge.swift:1174`). Capture this
|
|
763
|
+
* synchronously at delegate-call time; after any await/post the
|
|
764
|
+
* active surface may have shifted (user swiped tabs).
|
|
765
|
+
*/
|
|
766
|
+
fun activeSurfaceFeedId(): String {
|
|
767
|
+
val active = shortKit?.activeSurface ?: return ""
|
|
768
|
+
for ((id, ref) in feedRegistry) {
|
|
769
|
+
if (ref.get() === active) return id
|
|
770
|
+
}
|
|
771
|
+
return ""
|
|
772
|
+
}
|
|
773
|
+
|
|
672
774
|
fun registerFeed(id: String) {
|
|
673
775
|
// No-op — registerFeedFragment handles drain
|
|
674
776
|
}
|
|
@@ -735,6 +837,25 @@ class ShortKitBridge(
|
|
|
735
837
|
emitEvent.invoke(name, params)
|
|
736
838
|
}
|
|
737
839
|
|
|
840
|
+
/**
|
|
841
|
+
* Emit `onVideoCarouselCellTap` with the active surface's feedId and
|
|
842
|
+
* the cell-supplied (id, index, pageIndex). Mirrors iOS
|
|
843
|
+
* ShortKitBridge.swift:94-101.
|
|
844
|
+
*
|
|
845
|
+
* Called from the cell's onCellTap closure (installed in
|
|
846
|
+
* ShortKitFeedFragment) — see Task 5.
|
|
847
|
+
*/
|
|
848
|
+
internal fun emitVideoCarouselCellTap(itemId: String, index: Int, pageIndex: Int) {
|
|
849
|
+
val feedId = activeSurfaceFeedId()
|
|
850
|
+
val params = Arguments.createMap().apply {
|
|
851
|
+
putString("feedId", feedId)
|
|
852
|
+
putString("id", itemId)
|
|
853
|
+
putInt("index", index)
|
|
854
|
+
putInt("pageIndex", pageIndex)
|
|
855
|
+
}
|
|
856
|
+
emitEventOnMain("onVideoCarouselCellTap", params)
|
|
857
|
+
}
|
|
858
|
+
|
|
738
859
|
// ------------------------------------------------------------------
|
|
739
860
|
// Player commands
|
|
740
861
|
// ------------------------------------------------------------------
|
|
@@ -753,6 +874,24 @@ class ShortKitBridge(
|
|
|
753
874
|
fun skipToNext() { runOnMain { shortKit?.player?.skipToNext() } }
|
|
754
875
|
fun skipToPrevious() { runOnMain { shortKit?.player?.skipToPrevious() } }
|
|
755
876
|
fun setMuted(muted: Boolean) { runOnMain { shortKit?.player?.setMuted(muted) } }
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Toggle outer feed scroll on every registered feed. Note this
|
|
880
|
+
* intentionally diverges from iOS: iOS routes the same command only
|
|
881
|
+
* to the *active* feed (via player.onCommand), but Android fans out
|
|
882
|
+
* to all registered feeds — including masked-feed underlays. The
|
|
883
|
+
* user-visible effect is the same (the active feed's scroll is
|
|
884
|
+
* locked) and fanning out costs nothing, avoiding an active-surface
|
|
885
|
+
* lookup at this layer. Must run on main: viewPager.isUserInputEnabled
|
|
886
|
+
* mutates RecyclerView state, and feedRegistry is mutated on main
|
|
887
|
+
* during register/unregister — concurrent iteration would race.
|
|
888
|
+
*/
|
|
889
|
+
fun setFeedScrollEnabled(enabled: Boolean) = runOnMain {
|
|
890
|
+
feedRegistry.values.forEach { ref ->
|
|
891
|
+
ref.get()?.setFeedScrollEnabled(enabled)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
756
895
|
fun setPlaybackRate(rate: Double) { runOnMain { shortKit?.player?.setPlaybackRate(rate.toFloat()) } }
|
|
757
896
|
fun setCaptionsEnabled(enabled: Boolean) { runOnMain { shortKit?.player?.setCaptionsEnabled(enabled) } }
|
|
758
897
|
fun selectCaptionTrack(language: String) { runOnMain { shortKit?.player?.selectCaptionTrack(language) } }
|
|
@@ -762,6 +901,17 @@ class ShortKitBridge(
|
|
|
762
901
|
}
|
|
763
902
|
fun setMaxBitrate(bitrate: Double) { runOnMain { shortKit?.player?.setMaxBitrate(bitrate.toInt()) } }
|
|
764
903
|
|
|
904
|
+
// ---- SHO-15: ShortKitCarousel imperative methods + sync accessors ----
|
|
905
|
+
// ShortKitCarousel's onMain shim handles cross-thread routing internally,
|
|
906
|
+
// so we DON'T wrap with bridge.runOnMain — the namespace's threading
|
|
907
|
+
// contract already routes to main + blocks on the latch when called from
|
|
908
|
+
// a background thread.
|
|
909
|
+
fun carouselNext(): Boolean = shortKit?.carousel?.next() ?: false
|
|
910
|
+
fun carouselPrevious(): Boolean = shortKit?.carousel?.previous() ?: false
|
|
911
|
+
fun carouselSetActiveIndex(index: Int): Boolean = shortKit?.carousel?.setActiveIndex(index) ?: false
|
|
912
|
+
fun carouselActiveIndex(): Int = shortKit?.carousel?.activeIndexValue ?: -1
|
|
913
|
+
fun carouselVideoCount(): Int = shortKit?.carousel?.videoCountValue ?: 0
|
|
914
|
+
|
|
765
915
|
fun setUserId(userId: String) {
|
|
766
916
|
shortKit?.setUserId(userId)
|
|
767
917
|
}
|