@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
@@ -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) =>
@@ -28,4 +32,7 @@ export const ShortKitCommands = {
28
32
  NativeShortKitModule?.prefetchStoryboard(playbackId),
29
33
  getStoryboardData: (playbackId: string): Promise<string | null> =>
30
34
  NativeShortKitModule?.getStoryboardData(playbackId) ?? Promise.resolve(null),
35
+ downloadVideo: (itemId: string, mode: 'nonInterruptive' | 'interruptive' = 'nonInterruptive'): Promise<string> =>
36
+ NativeShortKitModule?.downloadVideo(itemId, mode) ?? Promise.reject(new Error('ShortKit not initialized')),
37
+ cancelDownload: () => NativeShortKitModule?.cancelDownload(),
31
38
  } as const;
@@ -1,6 +1,7 @@
1
1
  import { createContext } from 'react';
2
2
  import type {
3
3
  ContentItem,
4
+ DownloadState,
4
5
  FeedConfig,
5
6
  FeedFilter,
6
7
  FeedInput,
@@ -51,6 +52,11 @@ export interface ShortKitContextValue {
51
52
  // Storyboard / seek thumbnails
52
53
  prefetchStoryboard: (playbackId: string) => void;
53
54
  getStoryboardData: (playbackId: string) => Promise<StoryboardData | null>;
55
+
56
+ // Download
57
+ downloadState: DownloadState;
58
+ downloadVideo: (itemId: string, mode?: 'nonInterruptive' | 'interruptive') => Promise<string>;
59
+ cancelDownload: () => void;
54
60
  }
55
61
 
56
62
  export const ShortKitContext = createContext<ShortKitContextValue | null>(null);
@@ -33,6 +33,7 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
33
33
  preloadId,
34
34
  style,
35
35
  startAtItemId,
36
+ seedThumbnailUrl,
36
37
  onLoop,
37
38
  onFeedTransition,
38
39
  onFormatChange,
@@ -42,6 +43,7 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
42
43
  onDidFetchContentItems,
43
44
  onRemainingContentCountChange,
44
45
  onFeedReady,
46
+ onCarouselActiveVideoCompleted,
45
47
  } = props;
46
48
 
47
49
  const isInitialized = useContext(ShortKitInitContext);
@@ -49,13 +51,6 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
49
51
  throw new Error('ShortKitFeed must be used within a ShortKitProvider');
50
52
  }
51
53
 
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
54
  // Stable feed instance ID — survives re-renders and React fast refresh
60
55
  const feedIdRef = useRef(generateFeedId());
61
56
  const feedId = feedIdRef.current;
@@ -204,6 +199,25 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
204
199
  );
205
200
  }
206
201
 
202
+ if (onCarouselActiveVideoCompleted) {
203
+ subscriptions.push(
204
+ NativeShortKitModule.onCarouselActiveVideoCompleted((event) => {
205
+ try {
206
+ onCarouselActiveVideoCompleted({
207
+ surfaceId: event.surfaceId,
208
+ contentItem: JSON.parse(event.contentItem),
209
+ indexInCarousel: event.indexInCarousel,
210
+ carouselItem: JSON.parse(event.carouselItem),
211
+ wasLast: event.wasLast,
212
+ willAutoAdvance: event.willAutoAdvance,
213
+ });
214
+ } catch (err) {
215
+ console.warn('[ShortKit] failed to parse onCarouselActiveVideoCompleted', err);
216
+ }
217
+ }),
218
+ );
219
+ }
220
+
207
221
  return () => {
208
222
  for (const sub of subscriptions) {
209
223
  sub.remove();
@@ -217,6 +231,7 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
217
231
  onDismiss,
218
232
  onRefreshStateChanged,
219
233
  onDidFetchContentItems,
234
+ onCarouselActiveVideoCompleted,
220
235
  ]);
221
236
 
222
237
  // ---------------------------------------------------------------------------
@@ -230,6 +245,7 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
230
245
  feedId={feedId}
231
246
  startAtItemId={startAtItemId}
232
247
  preloadId={preloadId}
248
+ seedThumbnailUrl={seedThumbnailUrl}
233
249
  />
234
250
  </View>
235
251
  );
@@ -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}
@@ -6,6 +6,7 @@ import type { ShortKitContextValue } from './ShortKitContext';
6
6
  import type {
7
7
  ShortKitProviderProps,
8
8
  ContentItem,
9
+ DownloadState,
9
10
  FeedConfig,
10
11
  FeedFilter,
11
12
  FeedInput,
@@ -23,6 +24,7 @@ import {
23
24
  deserializePlayerTime,
24
25
  } from './serialization';
25
26
  import NativeShortKitModule from './specs/NativeShortKitModule';
27
+ import { ShortKitCommands } from './ShortKitCommands';
26
28
  import { registerLoadingComponent } from './ShortKitLoadingSurface';
27
29
 
28
30
  // ---------------------------------------------------------------------------
@@ -41,6 +43,7 @@ interface State {
41
43
  prefetchedAheadCount: number;
42
44
  isActive: boolean;
43
45
  feedScrollPhase: FeedScrollPhase | null;
46
+ downloadState: DownloadState;
44
47
  }
45
48
 
46
49
  const initialState: State = {
@@ -55,6 +58,7 @@ const initialState: State = {
55
58
  prefetchedAheadCount: 0,
56
59
  isActive: false,
57
60
  feedScrollPhase: null,
61
+ downloadState: { status: 'idle', progress: 0 },
58
62
  };
59
63
 
60
64
  type Action =
@@ -67,7 +71,11 @@ type Action =
67
71
  | { type: 'CAPTION_TRACK'; payload: CaptionTrack | null }
68
72
  | { type: 'CUE'; payload: { text: string; startTime: number; endTime: number } | null }
69
73
  | { type: 'PREFETCH_COUNT'; payload: number }
70
- | { type: 'FEED_SCROLL_PHASE'; payload: FeedScrollPhase };
74
+ | { type: 'FEED_SCROLL_PHASE'; payload: FeedScrollPhase }
75
+ | { type: 'DOWNLOAD_STARTED'; itemId: string }
76
+ | { type: 'DOWNLOAD_PROGRESS'; itemId: string; progress: number }
77
+ | { type: 'DOWNLOAD_COMPLETED'; itemId: string; fileUrl: string }
78
+ | { type: 'DOWNLOAD_FAILED'; itemId: string; error: string };
71
79
 
72
80
  function reducer(state: State, action: Action): State {
73
81
  switch (action.type) {
@@ -101,6 +109,14 @@ function reducer(state: State, action: Action): State {
101
109
  return { ...state, prefetchedAheadCount: action.payload };
102
110
  case 'FEED_SCROLL_PHASE':
103
111
  return { ...state, feedScrollPhase: action.payload };
112
+ case 'DOWNLOAD_STARTED':
113
+ return { ...state, downloadState: { status: 'downloading', itemId: action.itemId, progress: 0 } };
114
+ case 'DOWNLOAD_PROGRESS':
115
+ return { ...state, downloadState: { ...state.downloadState, progress: action.progress } };
116
+ case 'DOWNLOAD_COMPLETED':
117
+ return { ...state, downloadState: { status: 'completed', itemId: action.itemId, progress: 1, fileUrl: action.fileUrl } };
118
+ case 'DOWNLOAD_FAILED':
119
+ return { ...state, downloadState: { status: 'failed', itemId: action.itemId, progress: 0, error: action.error } };
104
120
  default:
105
121
  return state;
106
122
  }
@@ -327,6 +343,26 @@ export function ShortKitProvider({
327
343
  }),
328
344
  );
329
345
 
346
+ // Download started
347
+ const downloadStartedSub = NativeShortKitModule.onDownloadStarted((event) => {
348
+ dispatch({ type: 'DOWNLOAD_STARTED', itemId: event.itemId });
349
+ });
350
+
351
+ // Download progress
352
+ const downloadProgressSub = NativeShortKitModule.onDownloadProgress((event) => {
353
+ dispatch({ type: 'DOWNLOAD_PROGRESS', itemId: event.itemId, progress: event.progress });
354
+ });
355
+
356
+ // Download completed
357
+ const downloadCompletedSub = NativeShortKitModule.onDownloadCompleted((event) => {
358
+ dispatch({ type: 'DOWNLOAD_COMPLETED', itemId: event.itemId, fileUrl: event.fileUrl });
359
+ });
360
+
361
+ // Download failed
362
+ const downloadFailedSub = NativeShortKitModule.onDownloadFailed((event) => {
363
+ dispatch({ type: 'DOWNLOAD_FAILED', itemId: event.itemId, error: event.error });
364
+ });
365
+
330
366
  // Note: Feed-level callback events (onDidLoop, onFeedTransition,
331
367
  // onDismiss, etc.) are subscribed by the ShortKitFeed component
332
368
  // not here. The provider only manages state-driving events.
@@ -335,6 +371,10 @@ export function ShortKitProvider({
335
371
  for (const sub of subscriptions) {
336
372
  sub.remove();
337
373
  }
374
+ downloadStartedSub.remove();
375
+ downloadProgressSub.remove();
376
+ downloadCompletedSub.remove();
377
+ downloadFailedSub.remove();
338
378
  };
339
379
  }, []);
340
380
 
@@ -447,6 +487,7 @@ export function ShortKitProvider({
447
487
  prefetchedAheadCount: state.prefetchedAheadCount,
448
488
  isActive: state.isActive,
449
489
  feedScrollPhase: state.feedScrollPhase,
490
+ downloadState: state.downloadState,
450
491
 
451
492
  // Commands
452
493
  play,
@@ -467,6 +508,8 @@ export function ShortKitProvider({
467
508
  preloadFeed: preloadFeedCmd,
468
509
  prefetchStoryboard: prefetchStoryboardCmd,
469
510
  getStoryboardData: getStoryboardDataCmd,
511
+ downloadVideo: ShortKitCommands.downloadVideo,
512
+ cancelDownload: ShortKitCommands.cancelDownload,
470
513
  }),
471
514
  [
472
515
  state.playerState,
@@ -480,6 +523,7 @@ export function ShortKitProvider({
480
523
  state.prefetchedAheadCount,
481
524
  state.isActive,
482
525
  state.feedScrollPhase,
526
+ state.downloadState,
483
527
  play,
484
528
  pause,
485
529
  seek,
@@ -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,
@@ -20,6 +21,8 @@ export type {
20
21
  CarouselImage,
21
22
  ImageCarouselItem,
22
23
  VideoCarouselItem,
24
+ VideoCarouselVideoInput,
25
+ VideoCarouselInput,
23
26
  FeedInput,
24
27
  JSONValue,
25
28
  CaptionTrack,
@@ -38,6 +41,7 @@ export type {
38
41
  ShortKitPlayerProps,
39
42
  ShortKitWidgetProps,
40
43
  ShortKitPlayerState,
44
+ ShortKitCarouselState,
41
45
  ShortKitRefreshState,
42
46
  StoryboardData,
43
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
  }