@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.
Files changed (46) hide show
  1. package/README.md +151 -0
  2. package/android/libs/shortkit-release.aar +0 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +19 -1
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +10 -7
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +43 -0
  6. package/ios/ReactCarouselOverlayHost.swift +51 -3
  7. package/ios/ReactOverlayHost.swift +67 -7
  8. package/ios/ReactVideoCarouselOverlayHost.swift +181 -19
  9. package/ios/SKFabricSurfaceWrapper.mm +7 -1
  10. package/ios/ShortKitBridge.swift +140 -3
  11. package/ios/ShortKitFeedView.swift +20 -0
  12. package/ios/ShortKitFeedViewManager.mm +1 -0
  13. package/ios/ShortKitModule.mm +56 -0
  14. package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +4745 -456
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +127 -5
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  19. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +127 -5
  20. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  22. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  23. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +4745 -456
  24. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +127 -5
  25. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +127 -5
  27. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +4745 -456
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +127 -5
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +127 -5
  31. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  32. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  33. package/package.json +1 -1
  34. package/src/ShortKitCarouselOverlaySurface.tsx +38 -10
  35. package/src/ShortKitCommands.ts +7 -0
  36. package/src/ShortKitContext.ts +6 -0
  37. package/src/ShortKitFeed.tsx +23 -7
  38. package/src/ShortKitOverlaySurface.tsx +59 -23
  39. package/src/ShortKitProvider.tsx +45 -1
  40. package/src/ShortKitVideoCarouselOverlaySurface.tsx +51 -5
  41. package/src/index.ts +4 -0
  42. package/src/serialization.ts +37 -1
  43. package/src/specs/NativeShortKitModule.ts +80 -2
  44. package/src/specs/ShortKitFeedViewNativeComponent.ts +8 -0
  45. package/src/types.ts +71 -2
  46. 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.VideoCarouselItem
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 videos = mutableListOf<ContentItem>()
370
+ val videoInputs = mutableListOf<VideoCarouselVideoInput>()
370
371
  for (i in 0 until videosArr.length()) {
371
372
  val videoObj = videosArr.getJSONObject(i)
372
- parseContentItem(videoObj)?.let { videos.add(it) }
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 (videos.isEmpty()) continue
375
- val carouselItem = VideoCarouselItem(
377
+ if (videoInputs.isEmpty()) continue
378
+ val carouselInput = VideoCarouselInput(
376
379
  id = itemObj.getString("id"),
377
- videos = 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(carouselItem))
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
- if let data = try? JSONEncoder().encode(modifiedItem),
107
- let json = String(data: data, encoding: .utf8) {
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
- surface.setMaximumSize(size)
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
- // Only push properties if the surface already exists.
149
- // If the surface is still being created asynchronously,
150
- // installSurface() will call pushInitialProperties() when ready.
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
- pushInitialProperties()
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
- surface.setMaximumSize(size)
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, self.timeDirty else { return }
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,