@shortkitsdk/react-native 0.2.11 → 0.2.14

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 (57) hide show
  1. package/android/build.gradle.kts +13 -1
  2. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +157 -54
  3. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +67 -56
  4. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +431 -0
  5. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +154 -26
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +160 -35
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +5 -0
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +45 -10
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +9 -0
  10. package/ios/ReactCarouselOverlayHost.swift +37 -17
  11. package/ios/ReactOverlayHost.swift +33 -35
  12. package/ios/ReactVideoCarouselOverlayHost.swift +283 -0
  13. package/ios/ShortKitBridge.swift +78 -2
  14. package/ios/ShortKitFeedView.swift +24 -3
  15. package/ios/ShortKitModule.mm +6 -2
  16. package/ios/ShortKitSDK.xcframework/Info.plist +4 -4
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +2597 -389
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +69 -5
  19. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  20. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +69 -5
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  22. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  23. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +2597 -389
  24. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +69 -5
  25. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  26. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +69 -5
  27. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  28. package/ios/ShortKitSDK.xcframework/ios-arm64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +168 -0
  29. package/ios/ShortKitSDK.xcframework.bak2/Info.plist +43 -0
  30. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  31. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Info.plist +16 -0
  32. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +31351 -0
  33. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +865 -0
  34. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  35. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +865 -0
  36. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  37. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  38. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Headers/ShortKitSDK-Swift.h +418 -0
  39. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Info.plist +16 -0
  40. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +31351 -0
  41. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +865 -0
  42. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  43. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +865 -0
  44. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/Modules/module.modulemap +4 -0
  45. package/ios/ShortKitSDK.xcframework.bak2/ios-arm64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  46. package/package.json +1 -1
  47. package/src/ShortKitCarouselOverlaySurface.tsx +57 -2
  48. package/src/ShortKitContext.ts +2 -1
  49. package/src/ShortKitFeed.tsx +19 -1
  50. package/src/ShortKitOverlaySurface.tsx +148 -41
  51. package/src/ShortKitPlayer.tsx +25 -3
  52. package/src/ShortKitProvider.tsx +4 -2
  53. package/src/ShortKitVideoCarouselOverlaySurface.tsx +156 -0
  54. package/src/index.ts +8 -1
  55. package/src/serialization.ts +8 -0
  56. package/src/specs/NativeShortKitModule.ts +31 -1
  57. package/src/types.ts +45 -1
@@ -0,0 +1,4 @@
1
+ framework module ShortKitSDK {
2
+ header "ShortKitSDK-Swift.h"
3
+ export *
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shortkitsdk/react-native",
3
- "version": "0.2.11",
3
+ "version": "0.2.14",
4
4
  "description": "ShortKit React Native SDK — short-form video feed",
5
5
  "react-native": "src/index",
6
6
  "source": "src/index",
@@ -1,6 +1,7 @@
1
- import React, { useMemo } from 'react';
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
2
  import { AppRegistry } from 'react-native';
3
3
  import type { CarouselOverlayProps, ImageCarouselItem } from './types';
4
+ import NativeShortKitModule from './specs/NativeShortKitModule';
4
5
 
5
6
  const _carouselRegistry = new Map<string, React.ComponentType<CarouselOverlayProps>>();
6
7
 
@@ -24,7 +25,30 @@ export function registerCarouselOverlayComponent(
24
25
  });
25
26
  }
26
27
 
28
+ /** Subscribe to a named native event, filtered by surfaceId. */
29
+ function useOverlayEvent<T extends { surfaceId: string }>(
30
+ eventName: string,
31
+ surfaceId: string | undefined,
32
+ handler: (event: T) => void,
33
+ ) {
34
+ useEffect(() => {
35
+ if (!surfaceId) return;
36
+
37
+ let sub: { remove: () => void } | undefined;
38
+ const emitter = NativeShortKitModule?.[eventName as keyof typeof NativeShortKitModule];
39
+ if (typeof emitter === 'function') {
40
+ sub = (emitter as (cb: (e: T) => void) => { remove: () => void })((e: T) => {
41
+ if (e.surfaceId !== surfaceId) return;
42
+ handler(e);
43
+ });
44
+ }
45
+
46
+ return () => sub?.remove();
47
+ }, [surfaceId]);
48
+ }
49
+
27
50
  interface RawCarouselSurfaceProps {
51
+ surfaceId?: string;
28
52
  item?: string;
29
53
  }
30
54
 
@@ -38,6 +62,31 @@ function CarouselSurfaceInner(props: InnerProps) {
38
62
  return null;
39
63
  }
40
64
 
65
+ const sid = props.surfaceId;
66
+
67
+ const [isActive, setIsActive] = useState(false);
68
+ const [activeImageIndex, setActiveImageIndex] = useState(0);
69
+
70
+ // isActive delivered via onOverlayFullState (same event as video overlay)
71
+ useOverlayEvent<{
72
+ surfaceId: string;
73
+ isActive: boolean;
74
+ playerState: string;
75
+ isMuted: boolean;
76
+ playbackRate: number;
77
+ captionsEnabled: boolean;
78
+ activeCue: string | null;
79
+ feedScrollPhase: string | null;
80
+ }>('onOverlayFullState', sid, (e) => {
81
+ setIsActive(e.isActive);
82
+ });
83
+
84
+ // activeImageIndex delivered via dedicated event
85
+ useOverlayEvent<{ surfaceId: string; activeImageIndex: number }>(
86
+ 'onCarouselActiveImageChanged', sid,
87
+ (e) => setActiveImageIndex(e.activeImageIndex),
88
+ );
89
+
41
90
  const item: ImageCarouselItem | null = useMemo(() => {
42
91
  if (!props.item) {
43
92
  return null;
@@ -51,5 +100,11 @@ function CarouselSurfaceInner(props: InnerProps) {
51
100
 
52
101
  if (!item) return null;
53
102
 
54
- return <Component item={item} />;
103
+ return (
104
+ <Component
105
+ item={item}
106
+ isActive={isActive}
107
+ activeImageIndex={activeImageIndex}
108
+ />
109
+ );
55
110
  }
@@ -3,6 +3,7 @@ import type {
3
3
  ContentItem,
4
4
  FeedConfig,
5
5
  FeedFilter,
6
+ FeedInput,
6
7
  PlayerTime,
7
8
  PlayerState,
8
9
  CaptionTrack,
@@ -45,7 +46,7 @@ export interface ShortKitContextValue {
45
46
  fetchContent: (limit?: number, filter?: FeedFilter) => Promise<ContentItem[]>;
46
47
 
47
48
  // Preload
48
- preloadFeed: (config?: Partial<FeedConfig>) => Promise<string>;
49
+ preloadFeed: (config?: Partial<FeedConfig>, items?: FeedInput[]) => Promise<string>;
49
50
 
50
51
  // Storyboard / seek thumbnails
51
52
  prefetchStoryboard: (playbackId: string) => void;
@@ -7,6 +7,7 @@ import { ShortKitContext } from './ShortKitContext';
7
7
  import { deserializeContentItem, serializeFeedConfig, serializeFeedInputs } from './serialization';
8
8
  import { registerOverlayComponent } from './ShortKitOverlaySurface';
9
9
  import { registerCarouselOverlayComponent } from './ShortKitCarouselOverlaySurface';
10
+ import { registerVideoCarouselOverlayComponent } from './ShortKitVideoCarouselOverlaySurface';
10
11
 
11
12
  function generateFeedId(): string {
12
13
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
@@ -41,6 +42,7 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
41
42
  onRefreshRequested,
42
43
  onDidFetchContentItems,
43
44
  onRemainingContentCountChange,
45
+ onFeedReady,
44
46
  } = props;
45
47
 
46
48
  const context = useContext(ShortKitContext);
@@ -79,6 +81,19 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
79
81
  return () => subscription.remove();
80
82
  }, [feedId, onRemainingContentCountChange]);
81
83
 
84
+ // Subscribe to per-feed ready event
85
+ useEffect(() => {
86
+ if (!NativeShortKitModule || !onFeedReady) return;
87
+
88
+ const subscription = NativeShortKitModule.onFeedReady((event) => {
89
+ if (event.feedId === feedId) {
90
+ onFeedReady();
91
+ }
92
+ });
93
+
94
+ return () => subscription.remove();
95
+ }, [feedId, onFeedReady]);
96
+
82
97
  // Register overlay components before native view mounts.
83
98
  // useLayoutEffect fires after commit but before paint — before
84
99
  // didMoveToWindow/onAttachedToWindow on the native view.
@@ -89,7 +104,10 @@ export const ShortKitFeed = forwardRef<ShortKitFeedHandle, ShortKitFeedProps>(
89
104
  if (config?.carouselOverlay && config.carouselOverlay !== 'none') {
90
105
  registerCarouselOverlayComponent(config.carouselOverlay.name, config.carouselOverlay.component);
91
106
  }
92
- }, [config?.overlay, config?.carouselOverlay]);
107
+ if (config?.videoCarouselOverlay && config.videoCarouselOverlay !== 'none') {
108
+ registerVideoCarouselOverlayComponent(config.videoCarouselOverlay.name, config.videoCarouselOverlay.component);
109
+ }
110
+ }, [config?.overlay, config?.carouselOverlay, config?.videoCarouselOverlay]);
93
111
 
94
112
  const serializedConfig = useMemo(
95
113
  () => (config ? serializeFeedConfig(config) : '{}'),
@@ -1,9 +1,11 @@
1
- import React, { useEffect, useMemo, useState } from 'react';
2
- import { AppRegistry, DeviceEventEmitter, Platform } from 'react-native';
1
+ import React, { useEffect, useState } from 'react';
2
+ import { AppRegistry, View, Text } from 'react-native';
3
3
  import type { OverlayProps, PlayerState, PlayerTime, FeedScrollPhase } from './types';
4
4
  import { deserializeContentItem } from './serialization';
5
5
  import NativeShortKitModule from './specs/NativeShortKitModule';
6
6
 
7
+ const SK_OVERLAY_TAG = '[ShortKit:OverlaySurface]';
8
+
7
9
  // Named registry — supports different overlay components per feed
8
10
  const _overlayRegistry = new Map<string, React.ComponentType<OverlayProps>>();
9
11
 
@@ -16,7 +18,9 @@ export function registerOverlayComponent(
16
18
  name: string,
17
19
  component: React.ComponentType<OverlayProps>,
18
20
  ) {
19
- if (_overlayRegistry.has(name)) return;
21
+ if (_overlayRegistry.has(name)) {
22
+ return;
23
+ }
20
24
  _overlayRegistry.set(name, component);
21
25
 
22
26
  const moduleName = `ShortKitOverlay_${name}`;
@@ -27,6 +31,47 @@ export function registerOverlayComponent(
27
31
  });
28
32
  }
29
33
 
34
+ /**
35
+ * Error boundary that catches crashes inside the overlay component.
36
+ * Without this, a crash in an isolated React surface is silent on Android.
37
+ */
38
+ class OverlayErrorBoundary extends React.Component<
39
+ { surfaceId?: string; overlayName: string; children: React.ReactNode },
40
+ { error: Error | null }
41
+ > {
42
+ state: { error: Error | null } = { error: null };
43
+
44
+ static getDerivedStateFromError(error: Error) {
45
+ return { error };
46
+ }
47
+
48
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
49
+ console.error(
50
+ `${SK_OVERLAY_TAG} CRASH in overlay '${this.props.overlayName}' (surfaceId=${this.props.surfaceId}):`,
51
+ error.message,
52
+ '\nComponent stack:',
53
+ info.componentStack,
54
+ );
55
+ }
56
+
57
+ render() {
58
+ if (this.state.error) {
59
+ // Render a visible red indicator in __DEV__ so it's obvious
60
+ if (__DEV__) {
61
+ return (
62
+ <View style={{ flex: 1, backgroundColor: 'rgba(255,0,0,0.3)', justifyContent: 'center', alignItems: 'center', padding: 20 }}>
63
+ <Text style={{ color: 'white', fontSize: 14, textAlign: 'center' }}>
64
+ {`Overlay '${this.props.overlayName}' crashed:\n${this.state.error.message}`}
65
+ </Text>
66
+ </View>
67
+ );
68
+ }
69
+ return null;
70
+ }
71
+ return this.props.children;
72
+ }
73
+ }
74
+
30
75
  /** Raw props received from native appProperties (set once per item in configure()). */
31
76
  interface RawOverlaySurfaceProps {
32
77
  surfaceId?: string;
@@ -47,14 +92,9 @@ interface InnerProps extends RawOverlaySurfaceProps {
47
92
  /**
48
93
  * Subscribe to a native overlay event, filtered by surfaceId.
49
94
  *
50
- * iOS and Android new arch emit events through different paths:
51
- * - iOS: _eventEmitterCallback (codegen path) only the codegen EventEmitter
52
- * subscriptions (NativeShortKitModule.onXxx) receive these events.
53
- * - Android: getJSModule(RCTDeviceEventEmitter).emit() — only
54
- * DeviceEventEmitter.addListener() receives these events (codegen
55
- * EventEmitter subscriptions do NOT receive them on Android new arch).
56
- *
57
- * We branch per platform to use the path that actually delivers events.
95
+ * Both iOS and Android (new arch) emit events through the codegen
96
+ * EventEmitter path (mEventEmitterCallback / JSI direct channel).
97
+ * We subscribe via the codegen emitter method on the TurboModule.
58
98
  */
59
99
  function useOverlayEvent<T extends { surfaceId: string }>(
60
100
  eventName: string,
@@ -66,19 +106,11 @@ function useOverlayEvent<T extends { surfaceId: string }>(
66
106
 
67
107
  let sub: { remove: () => void } | undefined;
68
108
 
69
- if (Platform.OS === 'ios') {
70
- // iOS new arch: events come through the codegen EventEmitterCallback.
71
- // Access the codegen emitter method by name on the TurboModule.
72
- const emitter = NativeShortKitModule?.[eventName as keyof typeof NativeShortKitModule];
73
- if (typeof emitter === 'function') {
74
- sub = (emitter as (cb: (e: T) => void) => { remove: () => void })((e: T) => {
75
- if (e.surfaceId !== surfaceId) return;
76
- handler(e);
77
- });
78
- }
79
- } else {
80
- // Android new arch: events come through RCTDeviceEventEmitter.emit().
81
- sub = DeviceEventEmitter.addListener(eventName, (e: T) => {
109
+ // Both platforms: events come through the codegen EventEmitterCallback.
110
+ // Access the codegen emitter method by name on the TurboModule.
111
+ const emitter = NativeShortKitModule?.[eventName as keyof typeof NativeShortKitModule];
112
+ if (typeof emitter === 'function') {
113
+ sub = (emitter as (cb: (e: T) => void) => { remove: () => void })((e: T) => {
82
114
  if (e.surfaceId !== surfaceId) return;
83
115
  handler(e);
84
116
  });
@@ -90,13 +122,24 @@ function useOverlayEvent<T extends { surfaceId: string }>(
90
122
 
91
123
  function ShortKitOverlaySurfaceInner(props: InnerProps) {
92
124
  const Component = _overlayRegistry.get(props.overlayName);
93
- if (!Component) return null;
125
+ if (!Component) {
126
+ console.error(`${SK_OVERLAY_TAG} ShortKitOverlaySurfaceInner: Component NOT FOUND in registry for '${props.overlayName}'. Registered names: [${Array.from(_overlayRegistry.keys()).join(', ')}]`);
127
+ return null;
128
+ }
94
129
 
95
- const item = useMemo(
96
- () => (props.item ? deserializeContentItem(props.item) : null),
97
- [props.item],
130
+ const [item, setItem] = useState(() =>
131
+ props.item ? deserializeContentItem(props.item) : null,
98
132
  );
99
133
 
134
+ // Sync item from props when surface is first mounted or remounted via updateInitProps
135
+ const prevPropsItemRef = React.useRef(props.item);
136
+ if (props.item !== prevPropsItemRef.current) {
137
+ 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
+ setItem(props.item ? deserializeContentItem(props.item) : null);
141
+ }
142
+
100
143
  const sid = props.surfaceId;
101
144
 
102
145
  // Initialize state from surface properties (set once in configure()).
@@ -141,6 +184,24 @@ function ShortKitOverlaySurfaceInner(props: InnerProps) {
141
184
 
142
185
  // --- Event subscriptions (filtered by surfaceId) ---
143
186
 
187
+ // Item changed via event (not updateInitProps) — triggers React DIFF
188
+ // instead of full remount. Uses DeviceEventEmitter because this event
189
+ // is not in the TurboModule spec (avoids native codegen changes).
190
+ useEffect(() => {
191
+ if (!sid) return;
192
+ const { DeviceEventEmitter } = require('react-native');
193
+ const sub = DeviceEventEmitter.addListener('onOverlayItemChanged', (e: { surfaceId: string; item: string }) => {
194
+ if (e.surfaceId !== sid) return;
195
+ const newItem = e.item ? deserializeContentItem(e.item) : null;
196
+ if (newItem) setItem(newItem);
197
+ // Reset playback state for the new item
198
+ setIsActive(false);
199
+ setPlayerState('idle' as PlayerState);
200
+ setTime({ current: 0, duration: 0, buffered: 0 });
201
+ });
202
+ return () => sub.remove();
203
+ }, [sid]);
204
+
144
205
  useOverlayEvent<{ surfaceId: string; isActive: boolean }>(
145
206
  'onOverlayActiveChanged', sid,
146
207
  (e) => setIsActive(e.isActive),
@@ -175,7 +236,16 @@ function ShortKitOverlaySurfaceInner(props: InnerProps) {
175
236
  'onOverlayFeedScrollPhaseChanged', sid,
176
237
  (e) => {
177
238
  try {
178
- setFeedScrollPhase(e.feedScrollPhase ? JSON.parse(e.feedScrollPhase) : null);
239
+ const next = e.feedScrollPhase ? JSON.parse(e.feedScrollPhase) : null;
240
+ // Stabilize object reference: only update state if the phase actually
241
+ // changed. Prevents unnecessary re-renders of memo'd overlay components
242
+ // that receive feedScrollPhase as a prop (new JSON.parse object !== old).
243
+ setFeedScrollPhase(prev => {
244
+ if (prev?.phase === next?.phase && prev?.fromId === next?.fromId) {
245
+ return prev;
246
+ }
247
+ return next;
248
+ });
179
249
  } catch {
180
250
  setFeedScrollPhase(null);
181
251
  }
@@ -187,19 +257,56 @@ function ShortKitOverlaySurfaceInner(props: InnerProps) {
187
257
  (e) => setTime({ current: e.current, duration: e.duration, buffered: e.buffered }),
188
258
  );
189
259
 
190
- if (!item) return null;
260
+ // Batched full-state sync — replaces 7 individual events on swipe settle.
261
+ // All setState calls within one handler = one React render (auto-batched).
262
+ useOverlayEvent<{
263
+ surfaceId: string;
264
+ isActive: boolean;
265
+ playerState: string;
266
+ isMuted: boolean;
267
+ playbackRate: number;
268
+ captionsEnabled: boolean;
269
+ activeCue: string | null;
270
+ feedScrollPhase: string | null;
271
+ }>('onOverlayFullState', sid, (e) => {
272
+ setIsActive(e.isActive);
273
+ setPlayerState(e.playerState as PlayerState);
274
+ setIsMuted(e.isMuted);
275
+ setPlaybackRate(e.playbackRate);
276
+ setCaptionsEnabled(e.captionsEnabled);
277
+ try {
278
+ setActiveCue(e.activeCue ? JSON.parse(e.activeCue) : null);
279
+ } catch {
280
+ setActiveCue(null);
281
+ }
282
+ try {
283
+ const next = e.feedScrollPhase ? JSON.parse(e.feedScrollPhase) : null;
284
+ setFeedScrollPhase(prev => {
285
+ if (prev?.phase === next?.phase && prev?.fromId === next?.fromId) return prev;
286
+ return next;
287
+ });
288
+ } catch {
289
+ setFeedScrollPhase(null);
290
+ }
291
+ });
292
+
293
+ if (!item) {
294
+ return null;
295
+ }
191
296
 
192
297
  return (
193
- <Component
194
- item={item}
195
- isActive={isActive}
196
- playerState={playerState}
197
- time={time}
198
- isMuted={isMuted}
199
- playbackRate={playbackRate}
200
- captionsEnabled={captionsEnabled}
201
- activeCue={activeCue}
202
- feedScrollPhase={feedScrollPhase}
203
- />
298
+ <OverlayErrorBoundary surfaceId={sid} overlayName={props.overlayName}>
299
+ <Component
300
+ item={item}
301
+ isActive={isActive}
302
+ playerState={playerState}
303
+ time={time}
304
+ isMuted={isMuted}
305
+ playbackRate={playbackRate}
306
+ captionsEnabled={captionsEnabled}
307
+ activeCue={activeCue}
308
+ feedScrollPhase={feedScrollPhase}
309
+ />
310
+ </OverlayErrorBoundary>
204
311
  );
205
312
  }
@@ -17,11 +17,13 @@ import ShortKitPlayerView from './specs/ShortKitPlayerViewNativeComponent';
17
17
  export function ShortKitPlayer(props: ShortKitPlayerProps) {
18
18
  const { config, contentItem, active, style } = props;
19
19
 
20
+ const clickAction = config?.clickAction ?? 'feed';
21
+
20
22
  const serializedConfig = useMemo(() => {
21
23
  const cfg = config ?? {};
22
24
  return JSON.stringify({
23
25
  cornerRadius: cfg.cornerRadius ?? 12,
24
- clickAction: cfg.clickAction ?? 'feed',
26
+ clickAction: clickAction,
25
27
  autoplay: cfg.autoplay ?? true,
26
28
  loop: cfg.loop ?? true,
27
29
  muteOnStart: cfg.muteOnStart ?? true,
@@ -31,20 +33,40 @@ export function ShortKitPlayer(props: ShortKitPlayerProps) {
31
33
  : { type: 'custom' }
32
34
  : 'none',
33
35
  });
34
- }, [config]);
36
+ }, [config, clickAction]);
35
37
 
36
38
  const serializedItem = useMemo(() => {
37
39
  if (!contentItem) return undefined;
38
40
  return JSON.stringify(contentItem);
39
41
  }, [contentItem]);
40
42
 
43
+ // When clickAction is "none", the native view should not participate in
44
+ // Fabric's JS-side hit testing. Without this, Fabric on Android sees the
45
+ // native component as the touch target and never routes the touch to a
46
+ // parent Pressable — even though the native view returns false from
47
+ // dispatchTouchEvent. iOS doesn't need this because UIKit's responder
48
+ // chain bubbles unhandled touches automatically.
49
+ //
50
+ // "box-none" on the container means the container View itself is
51
+ // transparent to touches (passes through to parent), but children can
52
+ // still receive touches if needed. "none" on the native view means it
53
+ // is fully invisible to hit testing.
54
+ const nativeTouchPassthrough = clickAction === 'none';
55
+
41
56
  return (
42
- <View style={[styles.container, style]}>
57
+ <View
58
+ style={[styles.container, style]}
59
+ pointerEvents={nativeTouchPassthrough ? 'box-none' : 'auto'}
60
+ onStartShouldSetResponder={() => {
61
+ return false;
62
+ }}
63
+ >
43
64
  <ShortKitPlayerView
44
65
  style={styles.player}
45
66
  config={serializedConfig}
46
67
  contentItem={serializedItem}
47
68
  active={active}
69
+ pointerEvents={nativeTouchPassthrough ? 'none' : 'auto'}
48
70
  />
49
71
  </View>
50
72
  );
@@ -8,6 +8,7 @@ import type {
8
8
  ContentItem,
9
9
  FeedConfig,
10
10
  FeedFilter,
11
+ FeedInput,
11
12
  PlayerTime,
12
13
  PlayerState,
13
14
  CaptionTrack,
@@ -420,10 +421,11 @@ export function ShortKitProvider({
420
421
  }
421
422
  }, []);
422
423
 
423
- const preloadFeedCmd = useCallback(async (config?: Partial<FeedConfig>): Promise<string> => {
424
+ const preloadFeedCmd = useCallback(async (config?: Partial<FeedConfig>, items?: FeedInput[]): Promise<string> => {
424
425
  if (!NativeShortKitModule) return '';
425
426
  const configJSON = config ? serializeFeedConfig(config as FeedConfig) : '{}';
426
- return NativeShortKitModule.preloadFeed(configJSON);
427
+ const itemsJSON = items ? JSON.stringify(items) : null;
428
+ return NativeShortKitModule.preloadFeed(configJSON, itemsJSON);
427
429
  }, []);
428
430
 
429
431
  // -----------------------------------------------------------------------
@@ -0,0 +1,156 @@
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { AppRegistry } from 'react-native';
3
+ import type { VideoCarouselOverlayProps, VideoCarouselItem, ContentItem, PlayerTime, PlayerState } from './types';
4
+ import NativeShortKitModule from './specs/NativeShortKitModule';
5
+
6
+ const _videoCarouselRegistry = new Map<string, React.ComponentType<VideoCarouselOverlayProps>>();
7
+
8
+ /**
9
+ * Register a named video carousel overlay component for rendering inside feed cells.
10
+ * Called by ShortKitFeed on mount via useLayoutEffect.
11
+ * Idempotent — registering the same name twice is a no-op.
12
+ */
13
+ export function registerVideoCarouselOverlayComponent(
14
+ name: string,
15
+ component: React.ComponentType<VideoCarouselOverlayProps>,
16
+ ) {
17
+ if (_videoCarouselRegistry.has(name)) return;
18
+ _videoCarouselRegistry.set(name, component);
19
+
20
+ const moduleName = `ShortKitVideoCarouselOverlay_${name}`;
21
+ AppRegistry.registerComponent(moduleName, () => {
22
+ return function NamedVideoCarouselSurface(props: RawProps) {
23
+ return <VideoCarouselSurfaceInner {...props} overlayName={name} />;
24
+ };
25
+ });
26
+ }
27
+
28
+ /** Subscribe to a named native event, filtered by surfaceId. */
29
+ function useOverlayEvent<T extends { surfaceId: string }>(
30
+ eventName: string,
31
+ surfaceId: string | undefined,
32
+ handler: (event: T) => void,
33
+ ) {
34
+ useEffect(() => {
35
+ if (!surfaceId) return;
36
+
37
+ let sub: { remove: () => void } | undefined;
38
+ const emitter = NativeShortKitModule?.[eventName as keyof typeof NativeShortKitModule];
39
+ if (typeof emitter === 'function') {
40
+ sub = (emitter as (cb: (e: T) => void) => { remove: () => void })((e: T) => {
41
+ if (e.surfaceId !== surfaceId) return;
42
+ handler(e);
43
+ });
44
+ }
45
+
46
+ return () => sub?.remove();
47
+ }, [surfaceId]);
48
+ }
49
+
50
+ interface RawProps {
51
+ surfaceId?: string;
52
+ carouselItem?: string;
53
+ activeVideo?: string;
54
+ activeVideoIndex?: number;
55
+ isActive?: boolean;
56
+ playerState?: string;
57
+ isMuted?: boolean;
58
+ }
59
+
60
+ interface InnerProps extends RawProps {
61
+ overlayName: string;
62
+ }
63
+
64
+ function VideoCarouselSurfaceInner(props: InnerProps) {
65
+ const Component = _videoCarouselRegistry.get(props.overlayName);
66
+ if (!Component) return null;
67
+
68
+ const sid = props.surfaceId;
69
+
70
+ // Playback state — initialized from surface props, updated via events
71
+ const [isActive, setIsActive] = useState(props.isActive ?? false);
72
+ const [playerState, setPlayerState] = useState<PlayerState>((props.playerState as PlayerState) ?? 'idle');
73
+ const [isMuted, setIsMuted] = useState(props.isMuted ?? true);
74
+ const [time, setTime] = useState<PlayerTime>({ current: 0, duration: 0, buffered: 0 });
75
+
76
+ // Batched full-state sync
77
+ useOverlayEvent<{
78
+ surfaceId: string;
79
+ isActive: boolean;
80
+ playerState: string;
81
+ isMuted: boolean;
82
+ playbackRate: number;
83
+ captionsEnabled: boolean;
84
+ activeCue: string | null;
85
+ feedScrollPhase: string | null;
86
+ }>('onOverlayFullState', sid, (e) => {
87
+ setIsActive(e.isActive);
88
+ setPlayerState(e.playerState as PlayerState);
89
+ setIsMuted(e.isMuted);
90
+ });
91
+
92
+ // Individual state updates
93
+ useOverlayEvent<{ surfaceId: string; playerState: string }>(
94
+ 'onOverlayPlayerStateChanged', sid,
95
+ (e) => setPlayerState(e.playerState as PlayerState),
96
+ );
97
+
98
+ useOverlayEvent<{ surfaceId: string; isMuted: boolean }>(
99
+ 'onOverlayMutedChanged', sid,
100
+ (e) => setIsMuted(e.isMuted),
101
+ );
102
+
103
+ // Time updates (250ms coalesced)
104
+ useOverlayEvent<{ surfaceId: string; current: number; duration: number; buffered: number }>(
105
+ 'onOverlayTimeUpdate', sid,
106
+ (e) => setTime({ current: e.current, duration: e.duration, buffered: e.buffered }),
107
+ );
108
+
109
+ // Carousel item — from surface props (set once in configure())
110
+ const carouselItem: VideoCarouselItem | null = useMemo(() => {
111
+ if (!props.carouselItem) return null;
112
+ try { return JSON.parse(props.carouselItem); } catch { return null; }
113
+ }, [props.carouselItem]);
114
+
115
+ // Active video — initialized from surface props, updated via events on swipe
116
+ const initialActiveVideo: ContentItem | null = useMemo(() => {
117
+ if (!props.activeVideo) return null;
118
+ try { return JSON.parse(props.activeVideo); } catch { return null; }
119
+ }, [props.activeVideo]);
120
+
121
+ const [activeVideo, setActiveVideo] = useState<ContentItem | null>(initialActiveVideo);
122
+ const [activeVideoIndex, setActiveVideoIndex] = useState(props.activeVideoIndex ?? 0);
123
+
124
+ // Sync from props when surface is reconfigured (new carousel item)
125
+ useEffect(() => {
126
+ setActiveVideo(initialActiveVideo);
127
+ setActiveVideoIndex(props.activeVideoIndex ?? 0);
128
+ }, [initialActiveVideo]);
129
+
130
+ // Active video changes via event (no Fabric remount)
131
+ useOverlayEvent<{ surfaceId: string; activeVideo: string; activeVideoIndex: number }>(
132
+ 'onVideoCarouselActiveVideoChanged', sid,
133
+ (e) => {
134
+ try {
135
+ setActiveVideo(JSON.parse(e.activeVideo));
136
+ } catch {
137
+ // ignore parse errors
138
+ }
139
+ setActiveVideoIndex(e.activeVideoIndex);
140
+ },
141
+ );
142
+
143
+ if (!carouselItem || !activeVideo) return null;
144
+
145
+ return (
146
+ <Component
147
+ carouselItem={carouselItem}
148
+ activeVideo={activeVideo}
149
+ activeVideoIndex={activeVideoIndex}
150
+ isActive={isActive}
151
+ time={time}
152
+ playerState={playerState}
153
+ isMuted={isMuted}
154
+ />
155
+ );
156
+ }