@shortkitsdk/react-native 0.2.24 → 0.2.26

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 (43) 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/ShortKitModule.kt +43 -0
  5. package/ios/ReactCarouselOverlayHost.swift +51 -3
  6. package/ios/ReactOverlayHost.swift +67 -7
  7. package/ios/ReactVideoCarouselOverlayHost.swift +181 -19
  8. package/ios/SKFabricSurfaceWrapper.mm +7 -1
  9. package/ios/ShortKitBridge.swift +85 -5
  10. package/ios/ShortKitFeedView.swift +70 -4
  11. package/ios/ShortKitFeedViewManager.mm +3 -0
  12. package/ios/ShortKitModule.mm +46 -3
  13. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  14. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +5273 -337
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +151 -7
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +151 -7
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  19. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  20. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  21. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +5273 -337
  22. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +151 -7
  23. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  24. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +151 -7
  25. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +5273 -337
  26. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +151 -7
  27. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +151 -7
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  31. package/package.json +1 -1
  32. package/src/ShortKitCarouselOverlaySurface.tsx +38 -10
  33. package/src/ShortKitCommands.ts +4 -0
  34. package/src/ShortKitFeed.tsx +65 -10
  35. package/src/ShortKitOverlaySurface.tsx +59 -23
  36. package/src/ShortKitProvider.tsx +2 -1
  37. package/src/ShortKitVideoCarouselOverlaySurface.tsx +51 -5
  38. package/src/index.ts +2 -0
  39. package/src/serialization.ts +37 -1
  40. package/src/specs/NativeShortKitModule.ts +68 -3
  41. package/src/specs/ShortKitFeedViewNativeComponent.ts +11 -0
  42. package/src/types.ts +85 -3
  43. package/src/useShortKitCarousel.ts +80 -0
@@ -10,39 +10,39 @@
10
10
  </data>
11
11
  <key>Info.plist</key>
12
12
  <data>
13
- aVOP/OHkEUzNyBoUHTT2iiGJbu4=
13
+ Iq8WNAyualDO11/gg6nPxoT/uLo=
14
14
  </data>
15
15
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json</key>
16
16
  <data>
17
- mxcDambiFIzUbFTPfCTaKwjGz/8=
17
+ Gzr/etd10eddI2LBjZqt4DbbwAo=
18
18
  </data>
19
19
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface</key>
20
20
  <data>
21
- wayE2tftrhjvaEAekKB20ZGO36g=
21
+ SY4J8Ap5bvNmlGr2eHB/mIEc9/4=
22
22
  </data>
23
23
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc</key>
24
24
  <data>
25
- OXtQVstuyP3hqDsSczrvdpR/BZY=
25
+ Jifa8RGekCm+oLvj1vexV+qHBxg=
26
26
  </data>
27
27
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface</key>
28
28
  <data>
29
- wayE2tftrhjvaEAekKB20ZGO36g=
29
+ SY4J8Ap5bvNmlGr2eHB/mIEc9/4=
30
30
  </data>
31
31
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json</key>
32
32
  <data>
33
- mxcDambiFIzUbFTPfCTaKwjGz/8=
33
+ Gzr/etd10eddI2LBjZqt4DbbwAo=
34
34
  </data>
35
35
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface</key>
36
36
  <data>
37
- c5HZCcsGjJPhu5ppN3Wq4SsCx2A=
37
+ RSB+lYIOsHV+EHcf6nw6updgwoE=
38
38
  </data>
39
39
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc</key>
40
40
  <data>
41
- IjquwfiAxSFG2n0IR/+e9HvnZCM=
41
+ 4SaiyfqnnCuP3NHBedJ86U5WUvk=
42
42
  </data>
43
43
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface</key>
44
44
  <data>
45
- c5HZCcsGjJPhu5ppN3Wq4SsCx2A=
45
+ RSB+lYIOsHV+EHcf6nw6updgwoE=
46
46
  </data>
47
47
  <key>Modules/module.modulemap</key>
48
48
  <data>
@@ -66,56 +66,56 @@
66
66
  <dict>
67
67
  <key>hash2</key>
68
68
  <data>
69
- s3LSfH8aLgl3mic08rsi958gHLv6ImNC8MsZtFoy8u0=
69
+ isVaXgRh+uHcpvhnLI/Xw6vyUa6t68QJDw+sElYsnNw=
70
70
  </data>
71
71
  </dict>
72
72
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface</key>
73
73
  <dict>
74
74
  <key>hash2</key>
75
75
  <data>
76
- cYm595F0j0GSGb/GBTR7QHYSuFeUw9ZTPc8F87PKzvA=
76
+ 1/jxH+/lRA97UGj3ZA78cxyVLWeDthh0KFAHyutYAGs=
77
77
  </data>
78
78
  </dict>
79
79
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc</key>
80
80
  <dict>
81
81
  <key>hash2</key>
82
82
  <data>
83
- 9FsW2XMD0E0AiZ7mHDBgLg/OeCcSwB2BmWG3PuPqPwM=
83
+ 1ZNQBox7b3AwcLy9/eOdX9J9kLPO6aiDoKklf4b+uDw=
84
84
  </data>
85
85
  </dict>
86
86
  <key>Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface</key>
87
87
  <dict>
88
88
  <key>hash2</key>
89
89
  <data>
90
- cYm595F0j0GSGb/GBTR7QHYSuFeUw9ZTPc8F87PKzvA=
90
+ 1/jxH+/lRA97UGj3ZA78cxyVLWeDthh0KFAHyutYAGs=
91
91
  </data>
92
92
  </dict>
93
93
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json</key>
94
94
  <dict>
95
95
  <key>hash2</key>
96
96
  <data>
97
- s3LSfH8aLgl3mic08rsi958gHLv6ImNC8MsZtFoy8u0=
97
+ isVaXgRh+uHcpvhnLI/Xw6vyUa6t68QJDw+sElYsnNw=
98
98
  </data>
99
99
  </dict>
100
100
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface</key>
101
101
  <dict>
102
102
  <key>hash2</key>
103
103
  <data>
104
- QRoqxRRNskUOSMbf+RlD6fMBj0mmhgmOres7jwvebiQ=
104
+ LcZQmnn2zALd1t49D1Ky9DkTE/qLtiA/Y8X7G8X0tpA=
105
105
  </data>
106
106
  </dict>
107
107
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc</key>
108
108
  <dict>
109
109
  <key>hash2</key>
110
110
  <data>
111
- R1p/WNYI3bnLOrWsIezOXSJRZEwJ/t8FuoDZCBZQhHg=
111
+ rFL1rnzbA6I/ynZT99TT87vcqERbX/oj+lvvjIITisM=
112
112
  </data>
113
113
  </dict>
114
114
  <key>Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface</key>
115
115
  <dict>
116
116
  <key>hash2</key>
117
117
  <data>
118
- QRoqxRRNskUOSMbf+RlD6fMBj0mmhgmOres7jwvebiQ=
118
+ LcZQmnn2zALd1t49D1Ky9DkTE/qLtiA/Y8X7G8X0tpA=
119
119
  </data>
120
120
  </dict>
121
121
  <key>Modules/module.modulemap</key>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shortkitsdk/react-native",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "ShortKit React Native SDK — short-form video feed",
5
5
  "react-native": "src/index",
6
6
  "source": "src/index",
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useMemo } from 'react';
1
+ import React, { useRef, useState, useEffect, useMemo } from 'react';
2
2
  import { AppRegistry } from 'react-native';
3
3
  import type { CarouselOverlayProps, ImageCarouselItem } from './types';
4
4
  import NativeShortKitModule from './specs/NativeShortKitModule';
@@ -31,6 +31,12 @@ function useOverlayEvent<T extends { surfaceId: string }>(
31
31
  surfaceId: string | undefined,
32
32
  handler: (event: T) => void,
33
33
  ) {
34
+ // Ref-based handler capture so the subscription always calls the latest
35
+ // closure. Effect depends on [eventName, surfaceId] only — re-subscribing
36
+ // on every render would churn native subscriptions.
37
+ const handlerRef = useRef(handler);
38
+ handlerRef.current = handler;
39
+
34
40
  useEffect(() => {
35
41
  if (!surfaceId) return;
36
42
 
@@ -39,12 +45,12 @@ function useOverlayEvent<T extends { surfaceId: string }>(
39
45
  if (typeof emitter === 'function') {
40
46
  sub = (emitter as (cb: (e: T) => void) => { remove: () => void })((e: T) => {
41
47
  if (e.surfaceId !== surfaceId) return;
42
- handler(e);
48
+ handlerRef.current(e);
43
49
  });
44
50
  }
45
51
 
46
52
  return () => sub?.remove();
47
- }, [surfaceId]);
53
+ }, [eventName, surfaceId]);
48
54
  }
49
55
 
50
56
  interface RawCarouselSurfaceProps {
@@ -87,21 +93,43 @@ function CarouselSurfaceInner(props: InnerProps) {
87
93
  (e) => setActiveImageIndex(e.activeImageIndex),
88
94
  );
89
95
 
90
- const item: ImageCarouselItem | null = useMemo(() => {
91
- if (!props.item) {
92
- return null;
93
- }
96
+ // Initial item from surface props (set once on first mount).
97
+ const initialItem: ImageCarouselItem | null = useMemo(() => {
98
+ if (!props.item) return null;
99
+ try { return JSON.parse(props.item); } catch { return null; }
100
+ }, [props.item]);
101
+
102
+ // Subsequent item changes arrive via event — React diff instead of a full
103
+ // Fabric remount on cell reuse. Payload also resets isActive/activeImageIndex.
104
+ const [eventItem, setEventItem] = useState<ImageCarouselItem | null>(null);
105
+ useOverlayEvent<{
106
+ surfaceId: string;
107
+ item: string;
108
+ isActive: boolean;
109
+ activeImageIndex: number;
110
+ }>('onCarouselItemChanged', sid, (e) => {
94
111
  try {
95
- return JSON.parse(props.item);
112
+ setEventItem(JSON.parse(e.item));
96
113
  } catch {
97
- return null;
114
+ setEventItem(null);
98
115
  }
99
- }, [props.item]);
116
+ setIsActive(e.isActive);
117
+ setActiveImageIndex(e.activeImageIndex);
118
+ });
119
+
120
+ // Prefer event-sourced item (set after first item change) over the initial
121
+ // props-sourced value. Initial mount still uses props; all subsequent
122
+ // transitions are event-driven.
123
+ const item = eventItem ?? initialItem;
100
124
 
101
125
  if (!item) return null;
102
126
 
103
127
  return (
128
+ // key={item.id} preserves "fresh mount per item" for the user's component
129
+ // — their local state resets cleanly across items — while the SDK wrapper
130
+ // above stays mounted and keeps its bridge event subscriptions alive.
104
131
  <Component
132
+ key={item.id}
105
133
  item={item}
106
134
  isActive={isActive}
107
135
  activeImageIndex={activeImageIndex}
@@ -14,6 +14,10 @@ export const ShortKitCommands = {
14
14
  seekAndPlay: (seconds: number) => NativeShortKitModule?.seekAndPlay(seconds),
15
15
  skipToNext: () => NativeShortKitModule?.skipToNext(),
16
16
  skipToPrevious: () => NativeShortKitModule?.skipToPrevious(),
17
+ carouselNext: (): boolean => NativeShortKitModule?.carouselNext() ?? false,
18
+ carouselPrevious: (): boolean => NativeShortKitModule?.carouselPrevious() ?? false,
19
+ carouselSetActiveIndex: (index: number): boolean =>
20
+ NativeShortKitModule?.carouselSetActiveIndex(index) ?? false,
17
21
  setMuted: (muted: boolean) => NativeShortKitModule?.setMuted(muted),
18
22
  setPlaybackRate: (rate: number) => NativeShortKitModule?.setPlaybackRate(rate),
19
23
  setCaptionsEnabled: (enabled: boolean) =>
@@ -31,8 +31,11 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
31
31
  const {
32
32
  config,
33
33
  preloadId,
34
+ feedItems,
35
+ active,
34
36
  style,
35
37
  startAtItemId,
38
+ seedThumbnailUrl,
36
39
  onLoop,
37
40
  onFeedTransition,
38
41
  onFormatChange,
@@ -42,6 +45,8 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
42
45
  onDidFetchContentItems,
43
46
  onRemainingContentCountChange,
44
47
  onFeedReady,
48
+ onCarouselActiveVideoCompleted,
49
+ onVideoCarouselCellTap,
45
50
  } = props;
46
51
 
47
52
  const isInitialized = useContext(ShortKitInitContext);
@@ -49,21 +54,21 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
49
54
  throw new Error('ShortKitFeed must be used within a ShortKitProvider');
50
55
  }
51
56
 
52
- // ⚡ TEMP — remove after verifying. Should only log on mount, not on every swipe/time update.
53
- const renderCount = useRef(0);
54
- renderCount.current++;
55
- if (renderCount.current <= 3 || renderCount.current % 100 === 0) {
56
- console.log(`[SK:Feed] render #${renderCount.current}`);
57
- }
58
-
59
57
  // Stable feed instance ID — survives re-renders and React fast refresh
60
58
  const feedIdRef = useRef(generateFeedId());
61
59
  const feedId = feedIdRef.current;
62
60
 
63
61
  // Expose per-feed imperative methods on the ref
64
62
  useImperativeHandle(ref, () => ({
65
- setFeedItems: (items: FeedInput[]) => {
66
- NativeShortKitModule?.setFeedItems(feedId, serializeFeedInputs(items));
63
+ setFeedItems: (items: FeedInput[], options?: { startAt?: string }) => {
64
+ NativeShortKitModule?.setFeedItems(
65
+ feedId,
66
+ serializeFeedInputs(items),
67
+ options?.startAt ?? null,
68
+ );
69
+ },
70
+ scrollToItem: (id: string, options?: { animated?: boolean }) => {
71
+ NativeShortKitModule?.scrollFeedToItem(feedId, id, options?.animated ?? false);
67
72
  },
68
73
  appendFeedItems: (items: FeedInput[]) => {
69
74
  NativeShortKitModule?.appendFeedItems(feedId, serializeFeedInputs(items));
@@ -100,6 +105,23 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
100
105
  return () => subscription.remove();
101
106
  }, [feedId, onFeedReady]);
102
107
 
108
+ // Subscribe to per-feed video-carousel cell-tap events
109
+ useEffect(() => {
110
+ if (!NativeShortKitModule || !onVideoCarouselCellTap) return;
111
+
112
+ const subscription = NativeShortKitModule.onVideoCarouselCellTap((event) => {
113
+ if (event.feedId === feedId) {
114
+ onVideoCarouselCellTap({
115
+ id: event.id,
116
+ index: event.index,
117
+ pageIndex: event.pageIndex,
118
+ });
119
+ }
120
+ });
121
+
122
+ return () => subscription.remove();
123
+ }, [feedId, onVideoCarouselCellTap]);
124
+
103
125
  // Register overlay components before native view mounts.
104
126
  // useLayoutEffect fires after commit but before paint — before
105
127
  // didMoveToWindow/onAttachedToWindow on the native view.
@@ -120,6 +142,13 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
120
142
  [config],
121
143
  );
122
144
 
145
+ // Serialize feedItems once — only the initial value matters (mount-time
146
+ // prop). Post-mount item updates use the imperative ref methods.
147
+ const feedItemsJSON = useMemo(
148
+ () => (feedItems && feedItems.length > 0 ? serializeFeedInputs(feedItems) : undefined),
149
+ [feedItems],
150
+ );
151
+
123
152
  // ---------------------------------------------------------------------------
124
153
  // Subscribe to feed-level events and forward to callback props
125
154
  // ---------------------------------------------------------------------------
@@ -181,7 +210,10 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
181
210
 
182
211
  if (onRefreshStateChanged) {
183
212
  subscriptions.push(
184
- NativeShortKitModule.onRefreshStateChanged((event) => {
213
+ NativeShortKitModule.onRefreshStateChangedPerFeed((event) => {
214
+ // Filter to this feed instance — prevents other feeds' refresh
215
+ // events from triggering this consumer's refresh handler.
216
+ if (event.feedId !== feedId && event.feedId !== '') return;
185
217
  const state: ShortKitRefreshState =
186
218
  event.status === 'pulling'
187
219
  ? { status: 'pulling', progress: event.progress }
@@ -204,6 +236,25 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
204
236
  );
205
237
  }
206
238
 
239
+ if (onCarouselActiveVideoCompleted) {
240
+ subscriptions.push(
241
+ NativeShortKitModule.onCarouselActiveVideoCompleted((event) => {
242
+ try {
243
+ onCarouselActiveVideoCompleted({
244
+ surfaceId: event.surfaceId,
245
+ contentItem: JSON.parse(event.contentItem),
246
+ indexInCarousel: event.indexInCarousel,
247
+ carouselItem: JSON.parse(event.carouselItem),
248
+ wasLast: event.wasLast,
249
+ willAutoAdvance: event.willAutoAdvance,
250
+ });
251
+ } catch (err) {
252
+ console.warn('[ShortKit] failed to parse onCarouselActiveVideoCompleted', err);
253
+ }
254
+ }),
255
+ );
256
+ }
257
+
207
258
  return () => {
208
259
  for (const sub of subscriptions) {
209
260
  sub.remove();
@@ -217,6 +268,7 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
217
268
  onDismiss,
218
269
  onRefreshStateChanged,
219
270
  onDidFetchContentItems,
271
+ onCarouselActiveVideoCompleted,
220
272
  ]);
221
273
 
222
274
  // ---------------------------------------------------------------------------
@@ -230,6 +282,9 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
230
282
  feedId={feedId}
231
283
  startAtItemId={startAtItemId}
232
284
  preloadId={preloadId}
285
+ seedThumbnailUrl={seedThumbnailUrl}
286
+ feedItemsJSON={feedItemsJSON}
287
+ active={active}
233
288
  />
234
289
  </View>
235
290
  );
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useEffect, useRef, useState } from 'react';
2
2
  import { AppRegistry, View, Text } from 'react-native';
3
3
  import type { OverlayProps, PlayerState, PlayerTime, FeedScrollPhase } from './types';
4
4
  import { deserializeContentItem } from './serialization';
@@ -101,6 +101,12 @@ function useOverlayEvent<T extends { surfaceId: string }>(
101
101
  surfaceId: string | undefined,
102
102
  handler: (event: T) => void,
103
103
  ) {
104
+ // Stash the latest handler in a ref so the subscription below always calls
105
+ // the current closure. The effect deliberately depends on [eventName, surfaceId]
106
+ // only — re-subscribing on every render would churn native subscriptions.
107
+ const handlerRef = useRef(handler);
108
+ handlerRef.current = handler;
109
+
104
110
  useEffect(() => {
105
111
  if (!surfaceId) return;
106
112
 
@@ -112,12 +118,12 @@ function useOverlayEvent<T extends { surfaceId: string }>(
112
118
  if (typeof emitter === 'function') {
113
119
  sub = (emitter as (cb: (e: T) => void) => { remove: () => void })((e: T) => {
114
120
  if (e.surfaceId !== surfaceId) return;
115
- handler(e);
121
+ handlerRef.current(e);
116
122
  });
117
123
  }
118
124
 
119
125
  return () => sub?.remove();
120
- }, [surfaceId]);
126
+ }, [eventName, surfaceId]);
121
127
  }
122
128
 
123
129
  function ShortKitOverlaySurfaceInner(props: InnerProps) {
@@ -131,12 +137,12 @@ function ShortKitOverlaySurfaceInner(props: InnerProps) {
131
137
  props.item ? deserializeContentItem(props.item) : null,
132
138
  );
133
139
 
134
- // Sync item from props when surface is first mounted or remounted via updateInitProps
140
+ // Sync item from props when surface is first mounted or remounted via updateInitProps.
141
+ // Synchronous setState during render — React restarts with new state before
142
+ // committing, so the stale frame is never painted.
135
143
  const prevPropsItemRef = React.useRef(props.item);
136
144
  if (props.item !== prevPropsItemRef.current) {
137
145
  prevPropsItemRef.current = props.item;
138
- // Synchronous state update during render — React restarts with new state
139
- // before committing, so stale frame is never painted.
140
146
  setItem(props.item ? deserializeContentItem(props.item) : null);
141
147
  }
142
148
 
@@ -188,23 +194,45 @@ function ShortKitOverlaySurfaceInner(props: InnerProps) {
188
194
 
189
195
  // --- Event subscriptions (filtered by surfaceId) ---
190
196
 
191
- // Item changed via event (not updateInitProps) — triggers React DIFF
192
- // instead of full remount. Uses DeviceEventEmitter because this event
193
- // is not in the TurboModule spec (avoids native codegen changes).
194
- useEffect(() => {
195
- if (!sid) return;
196
- const { DeviceEventEmitter } = require('react-native');
197
- const sub = DeviceEventEmitter.addListener('onOverlayItemChanged', (e: { surfaceId: string; item: string }) => {
198
- if (e.surfaceId !== sid) return;
199
- const newItem = e.item ? deserializeContentItem(e.item) : null;
200
- if (newItem) setItem(newItem);
201
- // Reset playback state for the new item
202
- setIsActive(false);
203
- setPlayerState('idle' as PlayerState);
204
- setTime({ current: 0, duration: 0, buffered: 0 });
205
- });
206
- return () => sub.remove();
207
- }, [sid]);
197
+ // Item changed via event — triggers React DIFF instead of a full Fabric
198
+ // remount. Now a first-class codegen event (see NativeShortKitModule.ts),
199
+ // which routes through the JSI EventEmitterCallback on both platforms.
200
+ // Payload carries full initial state to match what pushInitialProperties()
201
+ // would have set, avoiding any stale-state window before activatePlayback().
202
+ useOverlayEvent<{
203
+ surfaceId: string;
204
+ item: string;
205
+ isActive: boolean;
206
+ playerState: string;
207
+ isMuted: boolean;
208
+ playbackRate: number;
209
+ captionsEnabled: boolean;
210
+ activeCue: string | null;
211
+ feedScrollPhase: string | null;
212
+ }>('onOverlayItemChanged', sid, (e) => {
213
+ const newItem = e.item ? deserializeContentItem(e.item) : null;
214
+ if (newItem) setItem(newItem);
215
+ // Apply ALL initial state — matches the old setProperties() behavior.
216
+ setIsActive(e.isActive);
217
+ setPlayerState(e.playerState as PlayerState);
218
+ setIsMuted(e.isMuted);
219
+ setPlaybackRate(e.playbackRate);
220
+ setCaptionsEnabled(e.captionsEnabled);
221
+ setTime({ current: 0, duration: 0, buffered: 0 });
222
+ const rawCue = e.activeCue ?? null;
223
+ lastActiveCueJsonRef.current = rawCue;
224
+ try {
225
+ setActiveCue(rawCue ? JSON.parse(rawCue) : null);
226
+ } catch {
227
+ setActiveCue(null);
228
+ }
229
+ try {
230
+ const next = e.feedScrollPhase ? JSON.parse(e.feedScrollPhase) : null;
231
+ setFeedScrollPhase(next);
232
+ } catch {
233
+ setFeedScrollPhase(null);
234
+ }
235
+ });
208
236
 
209
237
  useOverlayEvent<{ surfaceId: string; isActive: boolean }>(
210
238
  'onOverlayActiveChanged', sid,
@@ -310,7 +338,15 @@ function ShortKitOverlaySurfaceInner(props: InnerProps) {
310
338
 
311
339
  return (
312
340
  <OverlayErrorBoundary surfaceId={sid} overlayName={props.overlayName}>
341
+ {/*
342
+ key={item.id} preserves the legacy "fresh mount per item" contract
343
+ for the user's overlay component (their local useState/useRef reset
344
+ cleanly across items). The SDK wrapper above stays mounted, so
345
+ bridge event subscriptions and SDK-side state survive cell reuse
346
+ instead of churning on every swipe.
347
+ */}
313
348
  <Component
349
+ key={item.id}
314
350
  item={item}
315
351
  isActive={isActive}
316
352
  playerState={playerState}
@@ -19,6 +19,7 @@ import type {
19
19
  } from './types';
20
20
  import {
21
21
  serializeFeedConfig,
22
+ serializeFeedInputs,
22
23
  deserializePlayerState,
23
24
  deserializeContentItem,
24
25
  deserializePlayerTime,
@@ -466,7 +467,7 @@ export function ShortKitProvider({
466
467
  const preloadFeedCmd = useCallback(async (config?: Partial<FeedConfig>, items?: FeedInput[]): Promise<string> => {
467
468
  if (!NativeShortKitModule) return '';
468
469
  const configJSON = config ? serializeFeedConfig(config as FeedConfig) : '{}';
469
- const itemsJSON = items ? JSON.stringify(items) : null;
470
+ const itemsJSON = items ? serializeFeedInputs(items) : null;
470
471
  return NativeShortKitModule.preloadFeed(configJSON, itemsJSON);
471
472
  }, []);
472
473
 
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useMemo } from 'react';
1
+ import React, { useRef, useState, useEffect, useMemo } from 'react';
2
2
  import { AppRegistry } from 'react-native';
3
3
  import type { VideoCarouselOverlayProps, VideoCarouselItem, ContentItem, PlayerTime, PlayerState } from './types';
4
4
  import NativeShortKitModule from './specs/NativeShortKitModule';
@@ -31,6 +31,12 @@ function useOverlayEvent<T extends { surfaceId: string }>(
31
31
  surfaceId: string | undefined,
32
32
  handler: (event: T) => void,
33
33
  ) {
34
+ // Ref-based handler capture so the subscription always calls the latest
35
+ // closure. Effect depends on [eventName, surfaceId] only — re-subscribing
36
+ // on every render would churn native subscriptions.
37
+ const handlerRef = useRef(handler);
38
+ handlerRef.current = handler;
39
+
34
40
  useEffect(() => {
35
41
  if (!surfaceId) return;
36
42
 
@@ -39,12 +45,12 @@ function useOverlayEvent<T extends { surfaceId: string }>(
39
45
  if (typeof emitter === 'function') {
40
46
  sub = (emitter as (cb: (e: T) => void) => { remove: () => void })((e: T) => {
41
47
  if (e.surfaceId !== surfaceId) return;
42
- handler(e);
48
+ handlerRef.current(e);
43
49
  });
44
50
  }
45
51
 
46
52
  return () => sub?.remove();
47
- }, [surfaceId]);
53
+ }, [eventName, surfaceId]);
48
54
  }
49
55
 
50
56
  interface RawProps {
@@ -140,11 +146,51 @@ function VideoCarouselSurfaceInner(props: InnerProps) {
140
146
  },
141
147
  );
142
148
 
143
- if (!carouselItem || !activeVideo) return null;
149
+ // Carousel item itself changes via event — React diff instead of a full
150
+ // Fabric remount on cell reuse. Payload carries full initial state to match
151
+ // the old setProperties() path.
152
+ const [eventCarouselItem, setEventCarouselItem] = useState<VideoCarouselItem | null>(null);
153
+ useOverlayEvent<{
154
+ surfaceId: string;
155
+ carouselItem: string;
156
+ activeVideo: string;
157
+ activeVideoIndex: number;
158
+ isActive: boolean;
159
+ playerState: string;
160
+ isMuted: boolean;
161
+ }>('onVideoCarouselItemChanged', sid, (e) => {
162
+ try {
163
+ setEventCarouselItem(JSON.parse(e.carouselItem));
164
+ } catch {
165
+ setEventCarouselItem(null);
166
+ }
167
+ try {
168
+ setActiveVideo(e.activeVideo ? JSON.parse(e.activeVideo) : null);
169
+ } catch {
170
+ setActiveVideo(null);
171
+ }
172
+ setActiveVideoIndex(e.activeVideoIndex);
173
+ setIsActive(e.isActive);
174
+ setPlayerState(e.playerState as PlayerState);
175
+ setIsMuted(e.isMuted);
176
+ setTime({ current: 0, duration: 0, buffered: 0 });
177
+ });
178
+
179
+ // Prefer the event-sourced carousel item (set after first item change) over
180
+ // the initial props-sourced value. The initial mount still goes through
181
+ // props, but all subsequent item transitions are event-driven.
182
+ const effectiveCarouselItem = eventCarouselItem ?? carouselItem;
183
+
184
+ if (!effectiveCarouselItem || !activeVideo) return null;
144
185
 
145
186
  return (
187
+ // key={effectiveCarouselItem.id} preserves the legacy "fresh mount per
188
+ // carousel item" contract for the user's component — their local state
189
+ // resets cleanly across items — while the SDK wrapper above stays mounted
190
+ // and keeps its bridge event subscriptions alive.
146
191
  <Component
147
- carouselItem={carouselItem}
192
+ key={effectiveCarouselItem.id}
193
+ carouselItem={effectiveCarouselItem}
148
194
  activeVideo={activeVideo}
149
195
  activeVideoIndex={activeVideoIndex}
150
196
  isActive={isActive}
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export { ShortKitPlayer } from './ShortKitPlayer';
4
4
  export { ShortKitWidget } from './ShortKitWidget';
5
5
  export { useShortKitPlayer } from './useShortKitPlayer';
6
6
  export { useShortKit } from './useShortKit';
7
+ export { useShortKitCarousel } from './useShortKitCarousel';
7
8
  export type {
8
9
  FeedConfig,
9
10
  FeedHeight,
@@ -40,6 +41,7 @@ export type {
40
41
  ShortKitPlayerProps,
41
42
  ShortKitWidgetProps,
42
43
  ShortKitPlayerState,
44
+ ShortKitCarouselState,
43
45
  ShortKitRefreshState,
44
46
  StoryboardData,
45
47
  StoryboardTile,
@@ -4,6 +4,7 @@ import type {
4
4
  FeedInput,
5
5
  PlayerState,
6
6
  PlayerTime,
7
+ VideoCarouselVideoInput,
7
8
  } from './types';
8
9
 
9
10
  /**
@@ -88,9 +89,44 @@ export function deserializePlayerTime(event: {
88
89
  };
89
90
  }
90
91
 
92
+ /**
93
+ * Serialize a single VideoCarouselVideoInput slide for the bridge.
94
+ * Always emits `origin` (defaults to 'other') for a consistent native shape.
95
+ */
96
+ function serializeVideoCarouselSlide(
97
+ slide: VideoCarouselVideoInput,
98
+ ): Record<string, unknown> {
99
+ return {
100
+ playbackId: slide.playbackId,
101
+ origin: slide.origin ?? 'other',
102
+ ...(slide.fallbackUrl != null ? { fallbackUrl: slide.fallbackUrl } : {}),
103
+ };
104
+ }
105
+
91
106
  /**
92
107
  * Serialize FeedInput[] to a JSON string for the bridge.
108
+ * For video items, `origin` is included and defaults to 'other' when omitted.
93
109
  */
94
110
  export function serializeFeedInputs(items: FeedInput[]): string {
95
- return JSON.stringify(items);
111
+ const serialized = items.map((input) => {
112
+ if (input.type === 'video') {
113
+ return {
114
+ type: 'video',
115
+ playbackId: input.playbackId,
116
+ origin: input.origin ?? 'other',
117
+ ...(input.fallbackUrl != null ? { fallbackUrl: input.fallbackUrl } : {}),
118
+ };
119
+ }
120
+ if (input.type === 'videoCarousel') {
121
+ return {
122
+ type: 'videoCarousel',
123
+ item: {
124
+ ...input.item,
125
+ videos: input.item.videos.map(serializeVideoCarouselSlide),
126
+ },
127
+ };
128
+ }
129
+ return input;
130
+ });
131
+ return JSON.stringify(serialized);
96
132
  }