@shortkitsdk/react-native 0.2.23 → 0.2.25
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/README.md +151 -0
- package/android/libs/shortkit-release.aar +0 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +19 -1
- package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +10 -7
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +43 -0
- package/ios/ReactCarouselOverlayHost.swift +51 -3
- package/ios/ReactOverlayHost.swift +67 -7
- package/ios/ReactVideoCarouselOverlayHost.swift +181 -19
- package/ios/SKFabricSurfaceWrapper.mm +7 -1
- package/ios/ShortKitBridge.swift +140 -3
- package/ios/ShortKitFeedView.swift +20 -0
- package/ios/ShortKitFeedViewManager.mm +1 -0
- package/ios/ShortKitModule.mm +56 -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 +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +127 -5
- 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 +127 -5
- 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 +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +127 -5
- 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 +127 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +127 -5
- 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 +127 -5
- 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/ShortKitCarouselOverlaySurface.tsx +38 -10
- package/src/ShortKitCommands.ts +7 -0
- package/src/ShortKitContext.ts +6 -0
- package/src/ShortKitFeed.tsx +23 -7
- package/src/ShortKitOverlaySurface.tsx +59 -23
- package/src/ShortKitProvider.tsx +45 -1
- package/src/ShortKitVideoCarouselOverlaySurface.tsx +51 -5
- package/src/index.ts +4 -0
- package/src/serialization.ts +37 -1
- package/src/specs/NativeShortKitModule.ts +80 -2
- package/src/specs/ShortKitFeedViewNativeComponent.ts +8 -0
- package/src/types.ts +71 -2
- package/src/useShortKitCarousel.ts +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# ShortKit React Native SDK — Carousel Navigation API
|
|
2
|
+
|
|
3
|
+
> **Android support coming in the next release.** The carousel navigation API is currently iOS-only. Android stubs are present but return defaults.
|
|
4
|
+
|
|
5
|
+
## Video Carousel Navigation
|
|
6
|
+
|
|
7
|
+
Navigate videos within a carousel item using the `useShortKitCarousel()` hook or imperative commands.
|
|
8
|
+
|
|
9
|
+
### Hook-based Navigation
|
|
10
|
+
|
|
11
|
+
Access carousel state and imperative controls using `useShortKitCarousel()`:
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { useShortKitCarousel } from '@shortkitsdk/react-native';
|
|
15
|
+
import { View, Text, Button } from 'react-native';
|
|
16
|
+
|
|
17
|
+
function MyCarouselControls() {
|
|
18
|
+
const { activeIndex, videoCount, next, previous, setActiveIndex } = useShortKitCarousel();
|
|
19
|
+
|
|
20
|
+
if (activeIndex === null) {
|
|
21
|
+
return null; // no carousel currently active
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<View style={{ padding: 16 }}>
|
|
26
|
+
<Text style={{ marginBottom: 12 }}>
|
|
27
|
+
{activeIndex + 1} of {videoCount}
|
|
28
|
+
</Text>
|
|
29
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
30
|
+
<Button
|
|
31
|
+
title="Previous"
|
|
32
|
+
onPress={previous}
|
|
33
|
+
disabled={activeIndex === 0}
|
|
34
|
+
/>
|
|
35
|
+
<Button
|
|
36
|
+
title="Next"
|
|
37
|
+
onPress={next}
|
|
38
|
+
disabled={activeIndex === videoCount - 1}
|
|
39
|
+
/>
|
|
40
|
+
</View>
|
|
41
|
+
</View>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The hook returns:
|
|
47
|
+
- `activeIndex`: Current video index in the carousel, or `null` if no carousel is active
|
|
48
|
+
- `videoCount`: Total number of videos in the active carousel
|
|
49
|
+
- `activeCarouselItem`: The full carousel item object, or `null` if none
|
|
50
|
+
- `next()`: Advance to the next video (returns `false` at the last index)
|
|
51
|
+
- `previous()`: Go to the previous video (returns `false` at index 0)
|
|
52
|
+
- `setActiveIndex(index)`: Jump to a specific index (returns `false` if out of range)
|
|
53
|
+
|
|
54
|
+
### Imperative Commands (Inside Overlay Surfaces)
|
|
55
|
+
|
|
56
|
+
For overlay components that run in isolated React surfaces and cannot access context, use `ShortKitCommands` directly:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { ShortKitCommands } from '@shortkitsdk/react-native';
|
|
60
|
+
import { Pressable, Text } from 'react-native';
|
|
61
|
+
|
|
62
|
+
function CarouselOverlay() {
|
|
63
|
+
return (
|
|
64
|
+
<Pressable
|
|
65
|
+
onPress={() => {
|
|
66
|
+
const success = ShortKitCommands.carouselNext();
|
|
67
|
+
if (!success) {
|
|
68
|
+
// already at the last video
|
|
69
|
+
}
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<Text>Next Video</Text>
|
|
73
|
+
</Pressable>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Available carousel commands:
|
|
79
|
+
- `carouselNext()`: Advance to the next video (returns `false` if already at last index)
|
|
80
|
+
- `carouselPrevious()`: Go to the previous video (returns `false` if already at index 0)
|
|
81
|
+
- `carouselSetActiveIndex(index)`: Jump to a specific index (returns `false` if out of range)
|
|
82
|
+
|
|
83
|
+
### Completion Event Handling
|
|
84
|
+
|
|
85
|
+
Use the `onCarouselActiveVideoCompleted` callback on `<ShortKitFeed>` to react when a video completes playback:
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
<ShortKitFeed
|
|
89
|
+
onCarouselActiveVideoCompleted={(event) => {
|
|
90
|
+
console.log(`Video ${event.indexInCarousel} completed in carousel`);
|
|
91
|
+
|
|
92
|
+
if (event.wasLast && !event.willAutoAdvance) {
|
|
93
|
+
// Show an "End of carousel" call-to-action
|
|
94
|
+
showEndOfCarouselCTA({
|
|
95
|
+
contentItem: event.contentItem,
|
|
96
|
+
carouselItem: event.carouselItem,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Event properties:
|
|
104
|
+
- `surfaceId`: Identifier of the overlay surface
|
|
105
|
+
- `contentItem`: The video that just completed
|
|
106
|
+
- `indexInCarousel`: Index of the completed video within the carousel
|
|
107
|
+
- `carouselItem`: The full carousel item
|
|
108
|
+
- `wasLast`: Whether this was the last video in the carousel
|
|
109
|
+
- `willAutoAdvance`: Whether the carousel will automatically advance (always `false` for the last video)
|
|
110
|
+
|
|
111
|
+
### Behavior Contract
|
|
112
|
+
|
|
113
|
+
- **Boundary returns**: `next()` returns `false` when already at the last index; `previous()` returns `false` at index 0; `setActiveIndex()` returns `false` for out-of-range indices.
|
|
114
|
+
- **Last video loops**: The final video loops automatically; it does **not** advance to the next feed item.
|
|
115
|
+
- **Completion event only on natural end**: `onCarouselActiveVideoCompleted` fires when a video reaches the end naturally. It does **not** fire for user-initiated swipes or programmatic navigation.
|
|
116
|
+
- **Auto-advance**: When a non-final video completes, the SDK automatically advances to the next video (same as a user swipe). This behavior is suppressed if the user is mid-drag on the carousel.
|
|
117
|
+
|
|
118
|
+
### Example: End-of-Carousel CTA
|
|
119
|
+
|
|
120
|
+
Build a custom call-to-action when the carousel reaches its end:
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
function FeedWithCarouselCTA() {
|
|
124
|
+
const [showCTA, setShowCTA] = useState(false);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
<ShortKitFeed
|
|
129
|
+
onCarouselActiveVideoCompleted={(event) => {
|
|
130
|
+
if (event.wasLast) {
|
|
131
|
+
setShowCTA(true);
|
|
132
|
+
}
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
{showCTA && (
|
|
136
|
+
<View style={{ padding: 20, backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
|
137
|
+
<Text style={{ color: 'white', fontSize: 16, marginBottom: 12 }}>
|
|
138
|
+
You've seen all videos in this collection!
|
|
139
|
+
</Text>
|
|
140
|
+
<Button
|
|
141
|
+
title="Explore More"
|
|
142
|
+
onPress={() => {
|
|
143
|
+
setShowCTA(false);
|
|
144
|
+
}}
|
|
145
|
+
/>
|
|
146
|
+
</View>
|
|
147
|
+
)}
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
Binary file
|
|
@@ -163,11 +163,18 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
|
|
|
163
163
|
timeDirty = false
|
|
164
164
|
stopTimeCoalescing()
|
|
165
165
|
|
|
166
|
-
// Reset ALL cached state so recycled cells don't flash stale values
|
|
166
|
+
// Reset ALL cached state so recycled cells don't flash stale values
|
|
167
|
+
// from the previous item's player. Player-owned values (mute, rate,
|
|
168
|
+
// captions) are also reset; the new player's flow subscriptions will
|
|
169
|
+
// re-emit current values after attach(), so defaults are only visible
|
|
170
|
+
// for the single frame between configure() and first emission.
|
|
167
171
|
cachedCurrentTime = 0.0
|
|
168
172
|
cachedDuration = 0.0
|
|
169
173
|
cachedBuffered = 0.0
|
|
170
174
|
cachedPlayerState = "idle"
|
|
175
|
+
cachedIsMuted = true
|
|
176
|
+
cachedPlaybackRate = 1.0
|
|
177
|
+
cachedCaptionsEnabled = false
|
|
171
178
|
cachedActiveCue = null
|
|
172
179
|
cachedFeedScrollPhase = null
|
|
173
180
|
|
|
@@ -185,9 +192,20 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
|
|
|
185
192
|
} else {
|
|
186
193
|
// Different item — send via event for React tree diff (not
|
|
187
194
|
// updateInitProps which causes full Fabric remount).
|
|
195
|
+
// Includes full initial state so there's no stale-state window
|
|
196
|
+
// between configure() and activatePlayback(). Matches iOS.
|
|
188
197
|
val params = Arguments.createMap().apply {
|
|
189
198
|
putString("surfaceId", surfaceId)
|
|
190
199
|
putString("item", ShortKitBridge.serializeContentItemToJSON(item))
|
|
200
|
+
putBoolean("isActive", false)
|
|
201
|
+
putString("playerState", cachedPlayerState)
|
|
202
|
+
putBoolean("isMuted", cachedIsMuted)
|
|
203
|
+
putDouble("playbackRate", cachedPlaybackRate)
|
|
204
|
+
putBoolean("captionsEnabled", cachedCaptionsEnabled)
|
|
205
|
+
cachedActiveCue?.let { putString("activeCue", it.toString()) }
|
|
206
|
+
?: putNull("activeCue")
|
|
207
|
+
cachedFeedScrollPhase?.let { putString("feedScrollPhase", it) }
|
|
208
|
+
?: putNull("feedScrollPhase")
|
|
191
209
|
}
|
|
192
210
|
ShortKitBridge.shared?.emitEvent("onOverlayItemChanged", params)
|
|
193
211
|
}
|
|
@@ -17,7 +17,8 @@ import com.shortkit.sdk.model.FeedInput
|
|
|
17
17
|
import com.shortkit.sdk.model.FeedScrollPhase
|
|
18
18
|
import com.shortkit.sdk.model.FeedTransitionPhase
|
|
19
19
|
import com.shortkit.sdk.model.ImageCarouselItem
|
|
20
|
-
import com.shortkit.sdk.model.
|
|
20
|
+
import com.shortkit.sdk.model.VideoCarouselInput
|
|
21
|
+
import com.shortkit.sdk.model.VideoCarouselVideoInput
|
|
21
22
|
import com.shortkit.sdk.model.JsonValue
|
|
22
23
|
import com.shortkit.sdk.model.PlayerState
|
|
23
24
|
import com.shortkit.sdk.config.SurveyOverlayMode
|
|
@@ -366,22 +367,24 @@ class ShortKitBridge(
|
|
|
366
367
|
"videoCarousel" -> {
|
|
367
368
|
val itemObj = obj.optJSONObject("item") ?: continue
|
|
368
369
|
val videosArr = itemObj.optJSONArray("videos") ?: continue
|
|
369
|
-
val
|
|
370
|
+
val videoInputs = mutableListOf<VideoCarouselVideoInput>()
|
|
370
371
|
for (i in 0 until videosArr.length()) {
|
|
371
372
|
val videoObj = videosArr.getJSONObject(i)
|
|
372
|
-
|
|
373
|
+
val playbackId = videoObj.optString("playbackId", null) ?: continue
|
|
374
|
+
val fallbackUrl = videoObj.optString("fallbackUrl", null)
|
|
375
|
+
videoInputs.add(VideoCarouselVideoInput(playbackId, fallbackUrl))
|
|
373
376
|
}
|
|
374
|
-
if (
|
|
375
|
-
val
|
|
377
|
+
if (videoInputs.isEmpty()) continue
|
|
378
|
+
val carouselInput = VideoCarouselInput(
|
|
376
379
|
id = itemObj.getString("id"),
|
|
377
|
-
videos =
|
|
380
|
+
videos = videoInputs,
|
|
378
381
|
title = itemObj.optString("title", null),
|
|
379
382
|
description = itemObj.optString("description", null),
|
|
380
383
|
author = itemObj.optString("author", null),
|
|
381
384
|
section = itemObj.optString("section", null),
|
|
382
385
|
articleUrl = itemObj.optString("articleUrl", null),
|
|
383
386
|
)
|
|
384
|
-
result.add(FeedInput.VideoCarousel(
|
|
387
|
+
result.add(FeedInput.VideoCarousel(carouselInput))
|
|
385
388
|
}
|
|
386
389
|
}
|
|
387
390
|
}
|
|
@@ -144,6 +144,30 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
144
144
|
@ReactMethod
|
|
145
145
|
override fun skipToPrevious() { bridge?.skipToPrevious() }
|
|
146
146
|
|
|
147
|
+
// -----------------------------------------------------------------
|
|
148
|
+
// Carousel commands — stubs for PR 1.
|
|
149
|
+
// TODO: PR 2 — replace these stubs with real bridge to ShortKit.activeInstance.get().carousel
|
|
150
|
+
// -----------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
153
|
+
override fun carouselNext(): Boolean = false
|
|
154
|
+
|
|
155
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
156
|
+
override fun carouselPrevious(): Boolean = false
|
|
157
|
+
|
|
158
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
159
|
+
override fun carouselSetActiveIndex(index: Double): Boolean = false
|
|
160
|
+
|
|
161
|
+
// Carousel accessors — stubs for PR 1.
|
|
162
|
+
// onCarouselActiveVideoCompleted emitter intentionally unwired — never fires on Android in PR 1.
|
|
163
|
+
// PR 2 will subscribe to ShortKit.activeInstance.get().carousel.activeVideoCompleted.
|
|
164
|
+
|
|
165
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
166
|
+
override fun getCarouselActiveIndex(): Double = -1.0
|
|
167
|
+
|
|
168
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
169
|
+
override fun getCarouselVideoCount(): Double = 0.0
|
|
170
|
+
|
|
147
171
|
@ReactMethod
|
|
148
172
|
override fun setMuted(muted: Boolean) { bridge?.setMuted(muted) }
|
|
149
173
|
|
|
@@ -235,6 +259,21 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
235
259
|
b.getStoryboardData(playbackId) { result -> promise.resolve(result) }
|
|
236
260
|
}
|
|
237
261
|
|
|
262
|
+
// -----------------------------------------------------------------
|
|
263
|
+
// Download Management
|
|
264
|
+
// -----------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
@ReactMethod
|
|
267
|
+
override fun downloadVideo(itemId: String, mode: String, promise: Promise) {
|
|
268
|
+
// TODO: PR 2 — wire to ShortKit.activeInstance.get().downloadVideo
|
|
269
|
+
promise.reject("UNSUPPORTED", "downloadVideo not yet implemented on Android")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
@ReactMethod
|
|
273
|
+
override fun cancelDownload() {
|
|
274
|
+
// TODO: PR 2 — wire to ShortKit.activeInstance.get().cancelDownload
|
|
275
|
+
}
|
|
276
|
+
|
|
238
277
|
// -----------------------------------------------------------------
|
|
239
278
|
// Event emission
|
|
240
279
|
// -----------------------------------------------------------------
|
|
@@ -274,8 +313,12 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
|
|
|
274
313
|
"onOverlayFeedScrollPhaseChanged" -> emitOnOverlayFeedScrollPhaseChanged(params)
|
|
275
314
|
"onOverlayTimeUpdate" -> emitOnOverlayTimeUpdate(params)
|
|
276
315
|
"onOverlayFullState" -> emitOnOverlayFullState(params)
|
|
316
|
+
"onOverlayItemChanged" -> emitOnOverlayItemChanged(params)
|
|
277
317
|
"onCarouselActiveImageChanged" -> emitOnCarouselActiveImageChanged(params)
|
|
318
|
+
"onCarouselItemChanged" -> emitOnCarouselItemChanged(params)
|
|
278
319
|
"onVideoCarouselActiveVideoChanged" -> emitOnVideoCarouselActiveVideoChanged(params)
|
|
320
|
+
"onVideoCarouselItemChanged" -> emitOnVideoCarouselItemChanged(params)
|
|
321
|
+
"onCarouselActiveVideoCompleted" -> emitOnCarouselActiveVideoCompleted(params)
|
|
279
322
|
else -> {
|
|
280
323
|
android.util.Log.w("SK:Module", "sendEvent: unknown event name '$name', using legacy emitter")
|
|
281
324
|
reactApplicationContext
|
|
@@ -37,12 +37,26 @@ import ShortKitSDK
|
|
|
37
37
|
private var isActive = false
|
|
38
38
|
private var activeImageIndex = 0
|
|
39
39
|
|
|
40
|
+
/// Currently configured item id — used to detect item transitions so we can
|
|
41
|
+
/// emit an event instead of triggering a full Fabric remount.
|
|
42
|
+
private var currentItemId: String?
|
|
43
|
+
|
|
44
|
+
/// Whether initial props have been pushed to the surface at least once.
|
|
45
|
+
/// First configure must go through setProperties (for item + surfaceId).
|
|
46
|
+
/// Subsequent item changes use the event path.
|
|
47
|
+
private var hasPushedInitialProps: Bool = false
|
|
48
|
+
|
|
40
49
|
/// Serial queue for JPEG encoding + temp file writes (off main thread).
|
|
41
50
|
private static let imageWriteQueue = DispatchQueue(label: "com.shortkit.carousel-image-write", qos: .userInitiated)
|
|
42
51
|
|
|
43
52
|
/// Unique identifier for this overlay instance, used for event routing.
|
|
44
53
|
let surfaceId = UUID().uuidString
|
|
45
54
|
|
|
55
|
+
// Tracks the last bounds.size pushed to the surface. Used in layoutSubviews
|
|
56
|
+
// to skip redundant setSize calls that would otherwise trigger a Fabric
|
|
57
|
+
// layout recalc on every frame during scroll.
|
|
58
|
+
private var lastLayoutSize: CGSize = .zero
|
|
59
|
+
|
|
46
60
|
// MARK: - Init
|
|
47
61
|
|
|
48
62
|
override init(frame: CGRect) {
|
|
@@ -62,6 +76,9 @@ import ShortKitSDK
|
|
|
62
76
|
// MARK: - CarouselOverlay
|
|
63
77
|
|
|
64
78
|
public func configure(with item: ImageCarouselItem) {
|
|
79
|
+
let isSameItem = item.id == currentItemId
|
|
80
|
+
currentItemId = item.id
|
|
81
|
+
|
|
65
82
|
isActive = false
|
|
66
83
|
activeImageIndex = 0
|
|
67
84
|
createSurfaceIfNeeded()
|
|
@@ -103,20 +120,43 @@ import ShortKitSDK
|
|
|
103
120
|
)
|
|
104
121
|
}
|
|
105
122
|
|
|
106
|
-
|
|
107
|
-
|
|
123
|
+
guard let data = try? JSONEncoder().encode(modifiedItem),
|
|
124
|
+
let json = String(data: data, encoding: .utf8) else { return }
|
|
125
|
+
|
|
126
|
+
// Surface lifecycle on item change:
|
|
127
|
+
// - First mount: setProperties (via pendingProps if surface still
|
|
128
|
+
// installing; directly otherwise).
|
|
129
|
+
// - Subsequent item change with surface ready: emit
|
|
130
|
+
// onCarouselItemChanged for React diff (no remount).
|
|
131
|
+
// - Same item: no-op.
|
|
132
|
+
if !hasPushedInitialProps {
|
|
108
133
|
let props: [String: Any] = [
|
|
109
134
|
"surfaceId": surfaceId,
|
|
110
135
|
"item": json,
|
|
111
136
|
]
|
|
112
137
|
if let surface {
|
|
113
138
|
surface.setProperties(props)
|
|
139
|
+
hasPushedInitialProps = true
|
|
114
140
|
} else {
|
|
115
141
|
pendingProps = props
|
|
116
142
|
}
|
|
143
|
+
} else if !isSameItem {
|
|
144
|
+
emitItemChanged(json: json)
|
|
117
145
|
}
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
/// Emit onCarouselItemChanged with the new item JSON and a reset of
|
|
149
|
+
/// isActive/activeImageIndex. Replaces setProperties() on cell reuse so the
|
|
150
|
+
/// React tree does a diff instead of a full Fabric remount.
|
|
151
|
+
private func emitItemChanged(json: String) {
|
|
152
|
+
bridge?.emit("onCarouselItemChanged", body: [
|
|
153
|
+
"surfaceId": surfaceId,
|
|
154
|
+
"item": json,
|
|
155
|
+
"isActive": false,
|
|
156
|
+
"activeImageIndex": 0,
|
|
157
|
+
])
|
|
158
|
+
}
|
|
159
|
+
|
|
120
160
|
public func activatePlayback() {
|
|
121
161
|
isActive = true
|
|
122
162
|
bridge?.emit("onOverlayFullState", body: [
|
|
@@ -193,6 +233,7 @@ import ShortKitSDK
|
|
|
193
233
|
if let pending = pendingProps {
|
|
194
234
|
surf.setProperties(pending)
|
|
195
235
|
pendingProps = nil
|
|
236
|
+
hasPushedInitialProps = true
|
|
196
237
|
}
|
|
197
238
|
}
|
|
198
239
|
|
|
@@ -203,7 +244,14 @@ import ShortKitSDK
|
|
|
203
244
|
guard let surface else { return }
|
|
204
245
|
let size = bounds.size
|
|
205
246
|
guard size.width > 0, size.height > 0 else { return }
|
|
247
|
+
|
|
248
|
+
// Skip setSize when bounds haven't changed. UICollectionView calls
|
|
249
|
+
// layoutSubviews every frame during scroll; without this guard we'd
|
|
250
|
+
// trigger a Fabric layout recalc each time.
|
|
251
|
+
guard size != lastLayoutSize else { return }
|
|
252
|
+
lastLayoutSize = size
|
|
253
|
+
|
|
206
254
|
surface.setMinimumSize(size)
|
|
207
|
-
|
|
255
|
+
// setMaximumSize is a no-op in SKFabricSurfaceWrapper — not called.
|
|
208
256
|
}
|
|
209
257
|
}
|
|
@@ -55,6 +55,11 @@ import ShortKitSDK
|
|
|
55
55
|
private var timeCoalesceTimer: Timer?
|
|
56
56
|
private var timeDirty = false
|
|
57
57
|
|
|
58
|
+
// Tracks the last bounds.size pushed to the surface. Used in layoutSubviews
|
|
59
|
+
// to skip redundant setSize calls that would otherwise trigger a Fabric
|
|
60
|
+
// layout recalc on every frame during scroll.
|
|
61
|
+
private var lastLayoutSize: CGSize = .zero
|
|
62
|
+
|
|
58
63
|
// MARK: - Init
|
|
59
64
|
|
|
60
65
|
/// Height of the scrubber touch area at the bottom of the overlay.
|
|
@@ -135,9 +140,16 @@ import ShortKitSDK
|
|
|
135
140
|
timeCoalesceTimer?.invalidate()
|
|
136
141
|
timeCoalesceTimer = nil
|
|
137
142
|
|
|
138
|
-
// Reset cached state so recycled cells don't flash stale values
|
|
143
|
+
// Reset cached state so recycled cells don't flash stale values from
|
|
144
|
+
// the previous item's player. Player-owned values (mute, rate, captions)
|
|
145
|
+
// are also reset here; the new player's publishers will re-emit their
|
|
146
|
+
// current values after attach(), so the defaults are only visible for
|
|
147
|
+
// the single frame between configure() and the first publisher tick.
|
|
139
148
|
cachedTime = (0, 0, 0)
|
|
140
149
|
cachedPlayerState = "idle"
|
|
150
|
+
cachedIsMuted = true
|
|
151
|
+
cachedPlaybackRate = 1.0
|
|
152
|
+
cachedCaptionsEnabled = false
|
|
141
153
|
cachedActiveCue = nil
|
|
142
154
|
cachedActiveCueJson = nil
|
|
143
155
|
cachedFeedScrollPhase = nil
|
|
@@ -145,14 +157,42 @@ import ShortKitSDK
|
|
|
145
157
|
|
|
146
158
|
createSurfaceIfNeeded()
|
|
147
159
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
160
|
+
// Surface lifecycle on item change:
|
|
161
|
+
// - First mount (surface==nil): installSurface() will call
|
|
162
|
+
// pushInitialProperties() once the surface is ready.
|
|
163
|
+
// - Same item: no-op (overlay already has correct content).
|
|
164
|
+
// - Different item AND surface exists: emit onOverlayItemChanged
|
|
165
|
+
// event so React does a prop-diff update instead of the full
|
|
166
|
+
// Fabric remount that setProperties() would trigger.
|
|
167
|
+
// Matches the Android path in ReactOverlayHost.kt.
|
|
151
168
|
if surface != nil && !isSameItem {
|
|
152
|
-
|
|
169
|
+
emitItemChanged(item: item)
|
|
153
170
|
}
|
|
154
171
|
}
|
|
155
172
|
|
|
173
|
+
/// Emit a single onOverlayItemChanged event carrying full initial state
|
|
174
|
+
/// for the new item. Replaces the pushInitialProperties() → setProperties()
|
|
175
|
+
/// path on cell reuse, avoiding a full Fabric root remount per swipe.
|
|
176
|
+
private func emitItemChanged(item: ContentItem) {
|
|
177
|
+
guard let itemData = try? JSONEncoder().encode(item),
|
|
178
|
+
let itemJSON = String(data: itemData, encoding: .utf8) else { return }
|
|
179
|
+
|
|
180
|
+
bridge?.emit("onOverlayItemChanged", body: [
|
|
181
|
+
"surfaceId": surfaceId,
|
|
182
|
+
"item": itemJSON,
|
|
183
|
+
// Full initial state — matches what pushInitialProperties() used to
|
|
184
|
+
// set via setProperties(). Ensures no stale-state window between
|
|
185
|
+
// configure() and activatePlayback().
|
|
186
|
+
"isActive": false,
|
|
187
|
+
"playerState": cachedPlayerState,
|
|
188
|
+
"isMuted": cachedIsMuted,
|
|
189
|
+
"playbackRate": cachedPlaybackRate,
|
|
190
|
+
"captionsEnabled": cachedCaptionsEnabled,
|
|
191
|
+
"activeCue": cachedActiveCueJson ?? NSNull(),
|
|
192
|
+
"feedScrollPhase": cachedFeedScrollPhase ?? NSNull(),
|
|
193
|
+
])
|
|
194
|
+
}
|
|
195
|
+
|
|
156
196
|
public func activatePlayback() {
|
|
157
197
|
isActive = true
|
|
158
198
|
startTimeCoalescing()
|
|
@@ -243,8 +283,14 @@ import ShortKitSDK
|
|
|
243
283
|
let size = bounds.size
|
|
244
284
|
guard size.width > 0, size.height > 0 else { return }
|
|
245
285
|
|
|
286
|
+
// Skip setSize when bounds haven't changed. UICollectionView calls
|
|
287
|
+
// layoutSubviews every frame during scroll; without this guard we'd
|
|
288
|
+
// trigger a Fabric layout recalc each time.
|
|
289
|
+
guard size != lastLayoutSize else { return }
|
|
290
|
+
lastLayoutSize = size
|
|
291
|
+
|
|
246
292
|
surface.setMinimumSize(size)
|
|
247
|
-
|
|
293
|
+
// setMaximumSize is a no-op in SKFabricSurfaceWrapper — not called.
|
|
248
294
|
}
|
|
249
295
|
|
|
250
296
|
// MARK: - Player Subscriptions
|
|
@@ -342,6 +388,12 @@ import ShortKitSDK
|
|
|
342
388
|
switch phase {
|
|
343
389
|
case .dragging(let from):
|
|
344
390
|
self.isDragging = true
|
|
391
|
+
// Stop the time coalescing timer during drags. Time updates
|
|
392
|
+
// are suppressed while isDragging is true, so the timer
|
|
393
|
+
// would just wake the main thread to check timeDirty.
|
|
394
|
+
self.timeCoalesceTimer?.invalidate()
|
|
395
|
+
self.timeCoalesceTimer = nil
|
|
396
|
+
|
|
345
397
|
if let data = try? JSONSerialization.data(withJSONObject: ["phase": "dragging", "fromId": from]),
|
|
346
398
|
let json = String(data: data, encoding: .utf8) {
|
|
347
399
|
self.cachedFeedScrollPhase = json
|
|
@@ -351,6 +403,11 @@ import ShortKitSDK
|
|
|
351
403
|
self.isDragging = false
|
|
352
404
|
self.cachedFeedScrollPhase = "{\"phase\":\"settled\"}"
|
|
353
405
|
|
|
406
|
+
// Restart the timer now that the drag is over.
|
|
407
|
+
if self.isActive {
|
|
408
|
+
self.startTimeCoalescing()
|
|
409
|
+
}
|
|
410
|
+
|
|
354
411
|
// Re-sync all state that was suppressed during the drag.
|
|
355
412
|
// Handles both normal swipes (new item's activatePlayback
|
|
356
413
|
// will also fire) and cancelled swipes (same item, no
|
|
@@ -384,7 +441,10 @@ import ShortKitSDK
|
|
|
384
441
|
private func startTimeCoalescing() {
|
|
385
442
|
timeCoalesceTimer?.invalidate()
|
|
386
443
|
timeCoalesceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
|
|
387
|
-
guard let self
|
|
444
|
+
guard let self else { return }
|
|
445
|
+
// Timer is invalidated on drag start; this guard is belt-and-suspenders.
|
|
446
|
+
if self.isDragging { return }
|
|
447
|
+
guard self.timeDirty else { return }
|
|
388
448
|
self.timeDirty = false
|
|
389
449
|
self.bridge?.emit("onOverlayTimeUpdate", body: [
|
|
390
450
|
"surfaceId": self.surfaceId,
|