@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.
- package/README.md +151 -0
- package/android/libs/shortkit-release.aar +0 -0
- package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +19 -1
- package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +10 -7
- package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +43 -0
- package/ios/ReactCarouselOverlayHost.swift +51 -3
- package/ios/ReactOverlayHost.swift +67 -7
- package/ios/ReactVideoCarouselOverlayHost.swift +181 -19
- package/ios/SKFabricSurfaceWrapper.mm +7 -1
- package/ios/ShortKitBridge.swift +140 -3
- package/ios/ShortKitFeedView.swift +20 -0
- package/ios/ShortKitFeedViewManager.mm +1 -0
- package/ios/ShortKitModule.mm +56 -0
- package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +127 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +127 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +127 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +127 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +4745 -456
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +127 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +127 -5
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
- package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
- package/package.json +1 -1
- package/src/ShortKitCarouselOverlaySurface.tsx +38 -10
- package/src/ShortKitCommands.ts +7 -0
- package/src/ShortKitContext.ts +6 -0
- package/src/ShortKitFeed.tsx +23 -7
- package/src/ShortKitOverlaySurface.tsx +59 -23
- package/src/ShortKitProvider.tsx +45 -1
- package/src/ShortKitVideoCarouselOverlaySurface.tsx +51 -5
- package/src/index.ts +4 -0
- package/src/serialization.ts +37 -1
- package/src/specs/NativeShortKitModule.ts +80 -2
- package/src/specs/ShortKitFeedViewNativeComponent.ts +8 -0
- package/src/types.ts +71 -2
- 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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
112
|
+
setEventItem(JSON.parse(e.item));
|
|
96
113
|
} catch {
|
|
97
|
-
|
|
114
|
+
setEventItem(null);
|
|
98
115
|
}
|
|
99
|
-
|
|
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}
|
package/src/ShortKitCommands.ts
CHANGED
|
@@ -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;
|
package/src/ShortKitContext.ts
CHANGED
|
@@ -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);
|
package/src/ShortKitFeed.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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}
|
package/src/ShortKitProvider.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/serialization.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|