@shortkitsdk/react-native 0.2.24 → 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 (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 +43 -1
  10. package/ios/ShortKitFeedView.swift +20 -0
  11. package/ios/ShortKitFeedViewManager.mm +1 -0
  12. package/ios/ShortKitModule.mm +33 -0
  13. package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
  14. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +3590 -382
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +104 -5
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +104 -5
  19. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  20. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  21. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  22. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +3590 -382
  23. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +104 -5
  24. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  25. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +104 -5
  26. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +3590 -382
  27. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +104 -5
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +104 -5
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  31. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  32. package/package.json +1 -1
  33. package/src/ShortKitCarouselOverlaySurface.tsx +38 -10
  34. package/src/ShortKitCommands.ts +4 -0
  35. package/src/ShortKitFeed.tsx +23 -7
  36. package/src/ShortKitOverlaySurface.tsx +59 -23
  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 +51 -2
  41. package/src/specs/ShortKitFeedViewNativeComponent.ts +8 -0
  42. package/src/types.ts +27 -1
  43. package/src/useShortKitCarousel.ts +80 -0
@@ -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}
@@ -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
  }
@@ -177,12 +177,38 @@ type CarouselActiveImageEvent = Readonly<{
177
177
  activeImageIndex: Int32;
178
178
  }>;
179
179
 
180
+ type CarouselItemChangedEvent = Readonly<{
181
+ surfaceId: string;
182
+ item: string; // JSON-serialized ImageCarouselItem (with local file:// URLs where cached)
183
+ isActive: boolean;
184
+ activeImageIndex: Int32;
185
+ }>;
186
+
180
187
  type VideoCarouselActiveVideoEvent = Readonly<{
181
188
  surfaceId: string;
182
189
  activeVideo: string;
183
190
  activeVideoIndex: Int32;
184
191
  }>;
185
192
 
193
+ type VideoCarouselItemChangedEvent = Readonly<{
194
+ surfaceId: string;
195
+ carouselItem: string; // JSON-serialized VideoCarouselItem
196
+ activeVideo: string | null; // JSON-serialized ContentItem; null when item has no videos
197
+ activeVideoIndex: Int32;
198
+ isActive: boolean;
199
+ playerState: string;
200
+ isMuted: boolean;
201
+ }>;
202
+
203
+ type CarouselActiveVideoCompletedEvent = Readonly<{
204
+ surfaceId: string;
205
+ contentItem: string; // JSON-serialized ContentItem
206
+ indexInCarousel: Int32;
207
+ carouselItem: string; // JSON-serialized VideoCarouselItem
208
+ wasLast: boolean;
209
+ willAutoAdvance: boolean;
210
+ }>;
211
+
186
212
  type OverlayFullStateEvent = Readonly<{
187
213
  surfaceId: string;
188
214
  isActive: boolean;
@@ -190,8 +216,20 @@ type OverlayFullStateEvent = Readonly<{
190
216
  isMuted: boolean;
191
217
  playbackRate: Double;
192
218
  captionsEnabled: boolean;
193
- activeCue: string;
194
- feedScrollPhase: string;
219
+ activeCue: string | null;
220
+ feedScrollPhase: string | null;
221
+ }>;
222
+
223
+ type OverlayItemChangedEvent = Readonly<{
224
+ surfaceId: string;
225
+ item: string; // JSON-serialized ContentItem
226
+ isActive: boolean;
227
+ playerState: string;
228
+ isMuted: boolean;
229
+ playbackRate: Double;
230
+ captionsEnabled: boolean;
231
+ activeCue: string | null;
232
+ feedScrollPhase: string | null;
195
233
  }>;
196
234
 
197
235
  export interface Spec extends TurboModule {
@@ -217,6 +255,9 @@ export interface Spec extends TurboModule {
217
255
  seekAndPlay(seconds: Double): void;
218
256
  skipToNext(): void;
219
257
  skipToPrevious(): void;
258
+ carouselNext(): boolean;
259
+ carouselPrevious(): boolean;
260
+ carouselSetActiveIndex(index: Int32): boolean;
220
261
  setMuted(muted: boolean): void;
221
262
  setPlaybackRate(rate: Double): void;
222
263
  setCaptionsEnabled(enabled: boolean): void;
@@ -239,6 +280,10 @@ export interface Spec extends TurboModule {
239
280
  downloadVideo(itemId: string, mode: string): Promise<string>;
240
281
  cancelDownload(): void;
241
282
 
283
+ // --- Carousel accessors ---
284
+ getCarouselActiveIndex(): Int32;
285
+ getCarouselVideoCount(): Int32;
286
+
242
287
  // --- Event emitters ---
243
288
  readonly onPlayerStateChanged: EventEmitter<PlayerStateEvent>;
244
289
  readonly onCurrentItemChanged: EventEmitter<CurrentItemEvent>;
@@ -270,8 +315,12 @@ export interface Spec extends TurboModule {
270
315
  readonly onOverlayFeedScrollPhaseChanged: EventEmitter<OverlayFeedScrollPhaseEvent>;
271
316
  readonly onOverlayTimeUpdate: EventEmitter<OverlayTimeUpdateEvent>;
272
317
  readonly onOverlayFullState: EventEmitter<OverlayFullStateEvent>;
318
+ readonly onOverlayItemChanged: EventEmitter<OverlayItemChangedEvent>;
273
319
  readonly onCarouselActiveImageChanged: EventEmitter<CarouselActiveImageEvent>;
320
+ readonly onCarouselItemChanged: EventEmitter<CarouselItemChangedEvent>;
274
321
  readonly onVideoCarouselActiveVideoChanged: EventEmitter<VideoCarouselActiveVideoEvent>;
322
+ readonly onVideoCarouselItemChanged: EventEmitter<VideoCarouselItemChangedEvent>;
323
+ readonly onCarouselActiveVideoCompleted: EventEmitter<CarouselActiveVideoCompletedEvent>;
275
324
 
276
325
  // --- Download events ---
277
326
  readonly onDownloadStarted: EventEmitter<DownloadStartedEvent>;
@@ -6,6 +6,14 @@ export interface NativeProps extends ViewProps {
6
6
  feedId?: string;
7
7
  startAtItemId?: string;
8
8
  preloadId?: string;
9
+ /**
10
+ * URL of a thumbnail already rendered/cached on the host side (e.g. the
11
+ * grid tile the user tapped to open this feed). When expo-image or FastImage
12
+ * is used on iOS, SDK fetches the decoded UIImage from SDWebImage's memory
13
+ * cache and paints it onto the first cell with zero network and zero
14
+ * latency. Falls back to network fetch for non-SDWebImage clients.
15
+ */
16
+ seedThumbnailUrl?: string;
9
17
  }
10
18
 
11
19
  export default codegenNativeComponent<NativeProps>(
package/src/types.ts CHANGED
@@ -113,6 +113,8 @@ export interface VideoCarouselItem {
113
113
  articleUrl?: string;
114
114
  }
115
115
 
116
+ export type ContentOrigin = 'ios_upload' | 'other';
117
+
116
118
  /**
117
119
  * Per-slide input for a VideoCarouselInput. Mirrors the `{ type: 'video' }`
118
120
  * FeedInput variant — host apps pass a playback ID and the SDK constructs
@@ -120,6 +122,7 @@ export interface VideoCarouselItem {
120
122
  */
121
123
  export interface VideoCarouselVideoInput {
122
124
  playbackId: string;
125
+ origin?: ContentOrigin;
123
126
  fallbackUrl?: string;
124
127
  }
125
128
 
@@ -142,7 +145,7 @@ export interface VideoCarouselInput {
142
145
  }
143
146
 
144
147
  export type FeedInput =
145
- | { type: 'video'; playbackId: string; fallbackUrl?: string }
148
+ | { type: 'video'; playbackId: string; origin?: ContentOrigin; fallbackUrl?: string }
146
149
  | { type: 'imageCarousel'; item: ImageCarouselItem }
147
150
  | { type: 'videoCarousel'; item: VideoCarouselInput };
148
151
 
@@ -300,6 +303,17 @@ export interface ShortKitFeedProps {
300
303
  style?: ViewStyle;
301
304
  /** Item ID to scroll to on initial load. */
302
305
  startAtItemId?: string;
306
+ /**
307
+ * URL of the thumbnail already displayed in your UI (e.g. the grid tile
308
+ * the user just tapped). If your app uses expo-image or FastImage, the SDK
309
+ * will extract the already-decoded UIImage from SDWebImage's memory cache
310
+ * and paint it on the first feed cell with zero network overhead — closing
311
+ * the black-frame gap before video playback begins.
312
+ *
313
+ * Falls back silently (no extra network beyond the existing fallback) for
314
+ * apps on React Native's core <Image> or other image libraries.
315
+ */
316
+ seedThumbnailUrl?: string;
303
317
  onLoop?: (event: LoopEvent) => void;
304
318
  onFeedTransition?: (event: FeedTransitionEvent) => void;
305
319
  onFormatChange?: (event: FormatChangeEvent) => void;
@@ -316,6 +330,15 @@ export interface ShortKitFeedProps {
316
330
  /** Called once when this feed has loaded content and assigned a player
317
331
  * to the first cell. Use to dismiss a splash screen or loading overlay. */
318
332
  onFeedReady?: () => void;
333
+ /** Called when the active video in a video carousel completes playback. */
334
+ onCarouselActiveVideoCompleted?: (event: {
335
+ surfaceId: string;
336
+ contentItem: ContentItem;
337
+ indexInCarousel: number;
338
+ carouselItem: VideoCarouselItem;
339
+ wasLast: boolean;
340
+ willAutoAdvance: boolean;
341
+ }) => void;
319
342
  }
320
343
 
321
344
  /**
@@ -382,6 +405,9 @@ export interface ShortKitWidgetProps {
382
405
 
383
406
  // --- Hook Return Types ---
384
407
 
408
+ export type { ShortKitCarouselState } from './useShortKitCarousel';
409
+
410
+
385
411
  export interface ShortKitPlayerState {
386
412
  playerState: PlayerState;
387
413
  currentItem: ContentItem | null;
@@ -0,0 +1,80 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import NativeShortKitModule from './specs/NativeShortKitModule';
3
+ import { ShortKitCommands } from './ShortKitCommands';
4
+ import type { VideoCarouselItem } from './types';
5
+
6
+ export interface ShortKitCarouselState {
7
+ /** Index of the currently active video in the carousel, or null if no carousel is active. */
8
+ activeIndex: number | null;
9
+ /** Number of videos in the active carousel item, or 0 if none. */
10
+ videoCount: number;
11
+ /** The active carousel item, or null if no carousel is active. */
12
+ activeCarouselItem: VideoCarouselItem | null;
13
+ /** Advance to the next video in the carousel. Returns true if the command was dispatched. */
14
+ next: () => boolean;
15
+ /** Go back to the previous video in the carousel. Returns true if the command was dispatched. */
16
+ previous: () => boolean;
17
+ /** Jump to a specific index in the carousel. Returns true if the command was dispatched. */
18
+ setActiveIndex: (index: number) => boolean;
19
+ }
20
+
21
+ /**
22
+ * Hook to access video carousel state and commands.
23
+ *
24
+ * Subscribes to `onVideoCarouselItemChanged` and
25
+ * `onVideoCarouselActiveVideoChanged` from the native TurboModule and surfaces
26
+ * carousel state as reactive values. The imperative commands (`next`,
27
+ * `previous`, `setActiveIndex`) delegate to `ShortKitCommands`.
28
+ *
29
+ * Does not require a `<ShortKitProvider>` — it subscribes directly to native
30
+ * events and is suitable for use inside overlay components.
31
+ */
32
+ export function useShortKitCarousel(): ShortKitCarouselState {
33
+ const [activeIndex, setActiveIndexState] = useState<number | null>(() => {
34
+ const v = NativeShortKitModule?.getCarouselActiveIndex?.() ?? -1;
35
+ return v < 0 ? null : v;
36
+ });
37
+ const [activeCarouselItem, setActiveCarouselItem] = useState<VideoCarouselItem | null>(null);
38
+
39
+ useEffect(() => {
40
+ if (!NativeShortKitModule) return;
41
+ const subItem = NativeShortKitModule.onVideoCarouselItemChanged((e: any) => {
42
+ try {
43
+ const item = e.carouselItem
44
+ ? (JSON.parse(e.carouselItem) as VideoCarouselItem)
45
+ : null;
46
+ setActiveCarouselItem(item);
47
+ setActiveIndexState(
48
+ typeof e.activeVideoIndex === 'number' ? e.activeVideoIndex : null,
49
+ );
50
+ } catch {
51
+ // ignore parse errors
52
+ }
53
+ });
54
+ const subIdx = NativeShortKitModule.onVideoCarouselActiveVideoChanged((e: any) => {
55
+ setActiveIndexState(
56
+ typeof e.activeVideoIndex === 'number' ? e.activeVideoIndex : null,
57
+ );
58
+ });
59
+ return () => {
60
+ subItem?.remove?.();
61
+ subIdx?.remove?.();
62
+ };
63
+ }, []);
64
+
65
+ const next = useCallback(() => ShortKitCommands.carouselNext(), []);
66
+ const previous = useCallback(() => ShortKitCommands.carouselPrevious(), []);
67
+ const setActiveIndex = useCallback(
68
+ (i: number) => ShortKitCommands.carouselSetActiveIndex(i),
69
+ [],
70
+ );
71
+
72
+ return {
73
+ activeIndex,
74
+ videoCount: activeCarouselItem?.videos?.length ?? 0,
75
+ activeCarouselItem,
76
+ next,
77
+ previous,
78
+ setActiveIndex,
79
+ };
80
+ }