@shortkitsdk/react-native 0.2.35 → 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 +94 -46
- package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +46 -7
- package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +233 -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/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/Info.plist +5 -5
- 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 +932 -84
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +26 -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 +26 -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 +932 -84
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +26 -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 +26 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +932 -84
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +26 -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 +26 -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/package.json +1 -1
- package/src/ShortKitCommands.ts +20 -0
- package/src/ShortKitFeed.tsx +21 -0
- package/src/specs/NativeShortKitModule.ts +10 -0
- package/src/types.ts +35 -0
|
@@ -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,7 +206,11 @@ 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
|
|
@@ -146,20 +227,28 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
146
227
|
|
|
147
228
|
val json = Json.encodeToString(item)
|
|
148
229
|
carouselItemJSON = json
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
158
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)
|
|
159
248
|
}
|
|
160
|
-
|
|
249
|
+
// else: same item rebind — no emit, no Fabric prop push.
|
|
161
250
|
|
|
162
|
-
// Pre-size the surface view
|
|
251
|
+
// Pre-size the surface view (existing logic, unchanged).
|
|
163
252
|
val parentView = parent as? android.view.View
|
|
164
253
|
val w = if (width > 0) width
|
|
165
254
|
else if (parentView != null && parentView.width > 0) parentView.width
|
|
@@ -170,6 +259,58 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
170
259
|
measureAndLayoutSurfaceView(w, h)
|
|
171
260
|
}
|
|
172
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
|
+
|
|
173
314
|
override fun updateActiveVideo(index: Int, item: ContentItem) {
|
|
174
315
|
// Only emit when active — matches ReactOverlayHost pattern.
|
|
175
316
|
// During initial setup, configure() sets the first video via surface props.
|
|
@@ -221,12 +362,12 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
221
362
|
scope.launch {
|
|
222
363
|
player.playerState.collect { state ->
|
|
223
364
|
cachedPlayerState = ShortKitBridge.playerStateString(state)
|
|
224
|
-
if (isActive) {
|
|
225
|
-
val params =
|
|
365
|
+
if (isActive && !isDragging) {
|
|
366
|
+
val params = createMap().apply {
|
|
226
367
|
putString("surfaceId", surfaceId)
|
|
227
368
|
putString("playerState", cachedPlayerState)
|
|
228
369
|
}
|
|
229
|
-
|
|
370
|
+
emitEvent("onOverlayPlayerStateChanged", params)
|
|
230
371
|
}
|
|
231
372
|
}
|
|
232
373
|
}
|
|
@@ -234,12 +375,12 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
234
375
|
scope.launch {
|
|
235
376
|
player.isMuted.collect { muted ->
|
|
236
377
|
cachedIsMuted = muted
|
|
237
|
-
if (isActive) {
|
|
238
|
-
val params =
|
|
378
|
+
if (isActive && !isDragging) {
|
|
379
|
+
val params = createMap().apply {
|
|
239
380
|
putString("surfaceId", surfaceId)
|
|
240
381
|
putBoolean("isMuted", cachedIsMuted)
|
|
241
382
|
}
|
|
242
|
-
|
|
383
|
+
emitEvent("onOverlayMutedChanged", params)
|
|
243
384
|
}
|
|
244
385
|
}
|
|
245
386
|
}
|
|
@@ -249,11 +390,56 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
249
390
|
cachedCurrentTime = time.currentMs / 1000.0
|
|
250
391
|
cachedDuration = time.durationMs / 1000.0
|
|
251
392
|
cachedBuffered = time.bufferedMs / 1000.0
|
|
252
|
-
if (isActive) {
|
|
393
|
+
if (isActive && !isDragging) {
|
|
253
394
|
timeDirty = true
|
|
254
395
|
}
|
|
255
396
|
}
|
|
256
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
|
+
}
|
|
257
443
|
}
|
|
258
444
|
|
|
259
445
|
// ------------------------------------------------------------------
|
|
@@ -330,6 +516,24 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
330
516
|
}
|
|
331
517
|
}
|
|
332
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
|
+
|
|
333
537
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
334
538
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
335
539
|
if (w > 0 && h > 0) {
|
|
@@ -387,17 +591,18 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
387
591
|
private fun createSurfaceIfNeeded() {
|
|
388
592
|
if (surface != null) return
|
|
389
593
|
|
|
390
|
-
val reactHost = (context.applicationContext as? ReactApplication)?.reactHost
|
|
391
|
-
if (reactHost == null) {
|
|
392
|
-
android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
|
|
393
|
-
return
|
|
394
|
-
}
|
|
395
594
|
val moduleName = "ShortKitVideoCarouselOverlay_$videoCarouselOverlayName"
|
|
396
595
|
|
|
397
596
|
val initialProps = pendingProps
|
|
398
597
|
pendingProps = null
|
|
399
598
|
|
|
400
|
-
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
|
+
}
|
|
401
606
|
surface = newSurface
|
|
402
607
|
|
|
403
608
|
newSurface.view?.let { surfaceView ->
|
|
@@ -430,6 +635,7 @@ class ReactVideoCarouselOverlayHost(context: Context) : FrameLayout(context), Vi
|
|
|
430
635
|
flowScope?.cancel()
|
|
431
636
|
flowScope = null
|
|
432
637
|
isActive = false
|
|
638
|
+
currentCarouselItem = null
|
|
433
639
|
stopTimeCoalescing()
|
|
434
640
|
if (surface?.isRunning == true) {
|
|
435
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
|
}
|