ai-design-system 0.1.107 → 0.1.108
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/components/composites/ScenePlayer/index.ts +8 -2
- package/components/composites/{ScenePlayer/ScenePlayer.stories.tsx → StoryPlayer/StoryPlayer.stories.tsx} +36 -35
- package/components/composites/{ScenePlayer/ScenePlayer.tsx → StoryPlayer/StoryPlayer.tsx} +133 -98
- package/components/composites/StoryPlayer/index.ts +8 -0
- package/components/composites/index.ts +3 -3
- package/components/index.ts +3 -0
- package/dist/index.cjs +99 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +74 -61
- package/dist/index.js +99 -90
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
export { ScenePlayer } from "
|
|
2
|
-
export type {
|
|
1
|
+
export { StoryPlayer, ScenePlayer } from "@/components/composites/StoryPlayer";
|
|
2
|
+
export type {
|
|
3
|
+
StoryPlayerProps,
|
|
4
|
+
ScenePlayerProps,
|
|
5
|
+
StorySegment,
|
|
6
|
+
StoryItem,
|
|
7
|
+
SceneItem,
|
|
8
|
+
} from "@/components/composites/StoryPlayer";
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import {
|
|
2
|
+
import { StoryPlayer, type StorySegment } from "./StoryPlayer";
|
|
3
3
|
import { fn } from "@storybook/test";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* StoryPlayer Composite Stories
|
|
7
7
|
*
|
|
8
|
-
* Full-screen segmented video player for
|
|
9
|
-
* mute toggle, step zones, and
|
|
8
|
+
* Full-screen segmented video player for story-by-story review with progress tracks,
|
|
9
|
+
* mute toggle, step zones, and prompt action.
|
|
10
10
|
*/
|
|
11
11
|
const meta = {
|
|
12
|
-
title: "Composites/
|
|
13
|
-
component:
|
|
12
|
+
title: "Composites/StoryPlayer",
|
|
13
|
+
component: StoryPlayer,
|
|
14
14
|
tags: ["autodocs"],
|
|
15
15
|
parameters: { layout: "fullscreen" },
|
|
16
16
|
args: {
|
|
@@ -18,68 +18,69 @@ const meta = {
|
|
|
18
18
|
onClose: fn(),
|
|
19
19
|
onSubmit: fn(),
|
|
20
20
|
onPublish: fn(),
|
|
21
|
+
onStoryChange: fn(),
|
|
21
22
|
onSceneChange: fn(),
|
|
22
23
|
onPlayingChange: fn(),
|
|
23
24
|
onDownload: fn(),
|
|
24
25
|
},
|
|
25
|
-
} satisfies Meta<typeof
|
|
26
|
+
} satisfies Meta<typeof StoryPlayer>;
|
|
26
27
|
|
|
27
28
|
export default meta;
|
|
28
29
|
type Story = StoryObj<typeof meta>;
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
+
const mockStories: StorySegment[] = [
|
|
31
32
|
{
|
|
32
33
|
id: "001",
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
segmentNumber: 1,
|
|
35
|
+
totalSegments: 3,
|
|
35
36
|
start_sec: 0,
|
|
36
37
|
duration_sec: 1.6,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
scene_clip_url:
|
|
38
|
+
caption: "iOS apps used to mean one thing",
|
|
39
|
+
clip_url:
|
|
40
40
|
"https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene001/clip.mp4",
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
id: "002",
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
segmentNumber: 2,
|
|
45
|
+
totalSegments: 3,
|
|
46
46
|
start_sec: 1.6,
|
|
47
47
|
duration_sec: 1.5,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
scene_clip_url:
|
|
48
|
+
caption: "You needed a Mac",
|
|
49
|
+
clip_url:
|
|
51
50
|
"https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene002/clip.mp4",
|
|
52
51
|
},
|
|
53
52
|
{
|
|
54
53
|
id: "003",
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
segmentNumber: 3,
|
|
55
|
+
totalSegments: 3,
|
|
57
56
|
start_sec: 3.1,
|
|
58
57
|
duration_sec: 1.5,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
scene_clip_url:
|
|
58
|
+
caption: "And nobody told the AI",
|
|
59
|
+
clip_url:
|
|
62
60
|
"https://hel1.your-objectstorage.com/waypoint-s3-dev/01M32NJZHXKCX1CQKVEVA49SJT/playground-1790017380397-6ffe0ec0/artifacts/scenes/scene003/clip.mp4",
|
|
63
61
|
},
|
|
64
62
|
];
|
|
65
63
|
|
|
66
|
-
/**
|
|
67
|
-
* Default usage with multiple scenes
|
|
68
|
-
*/
|
|
69
64
|
export const Default: Story = {
|
|
70
65
|
args: {
|
|
71
|
-
|
|
72
|
-
title: "
|
|
73
|
-
|
|
66
|
+
stories: mockStories,
|
|
67
|
+
title: "Building iOS App on Devin",
|
|
68
|
+
isPlaying: true,
|
|
74
69
|
},
|
|
75
70
|
};
|
|
76
71
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
72
|
+
export const Paused: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
stories: mockStories,
|
|
75
|
+
title: "Building iOS App on Devin",
|
|
76
|
+
isPlaying: false,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const Muted: Story = {
|
|
81
81
|
args: {
|
|
82
|
-
|
|
83
|
-
title: "
|
|
82
|
+
stories: mockStories,
|
|
83
|
+
title: "Building iOS App on Devin",
|
|
84
|
+
initialMuted: true,
|
|
84
85
|
},
|
|
85
86
|
};
|
|
@@ -8,28 +8,39 @@ import { PromptInput, type PromptInputVariant } from "@/components/composites/Pr
|
|
|
8
8
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
|
9
9
|
import { cn } from "@/lib/utils";
|
|
10
10
|
|
|
11
|
-
export interface
|
|
12
|
-
id
|
|
11
|
+
export interface StorySegment {
|
|
12
|
+
id?: string;
|
|
13
13
|
sceneNumber?: number;
|
|
14
|
+
storyNumber?: number;
|
|
15
|
+
segmentNumber?: number;
|
|
14
16
|
totalScenes?: number;
|
|
17
|
+
totalStories?: number;
|
|
18
|
+
totalSegments?: number;
|
|
15
19
|
start_sec?: number;
|
|
16
20
|
duration_sec?: number;
|
|
17
21
|
templateId?: string;
|
|
18
22
|
caption?: string;
|
|
19
|
-
|
|
23
|
+
clip_url?: string;
|
|
24
|
+
story_clip_url?: string;
|
|
25
|
+
scene_clip_url?: string;
|
|
26
|
+
video_url?: string;
|
|
20
27
|
scene_audio_clip_url?: string;
|
|
21
28
|
[key: string]: unknown;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
export
|
|
25
|
-
|
|
31
|
+
export type StoryItem = StorySegment;
|
|
32
|
+
export type SceneItem = StorySegment;
|
|
33
|
+
|
|
34
|
+
export interface StoryPlayerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit"> {
|
|
35
|
+
stories?: StorySegment[];
|
|
36
|
+
scenes?: StorySegment[];
|
|
37
|
+
segments?: StorySegment[];
|
|
26
38
|
title?: string;
|
|
27
|
-
|
|
28
|
-
avatarColor?: string;
|
|
39
|
+
initialStoryIndex?: number;
|
|
29
40
|
initialSceneIndex?: number;
|
|
30
|
-
isMuted?: boolean;
|
|
31
41
|
isPlaying?: boolean;
|
|
32
42
|
onPlayingChange?: (isPlaying: boolean) => void;
|
|
43
|
+
initialMuted?: boolean;
|
|
33
44
|
videoUrl?: string;
|
|
34
45
|
downloadUrl?: string;
|
|
35
46
|
onDownload?: () => void;
|
|
@@ -38,31 +49,37 @@ export interface ScenePlayerProps extends Omit<React.HTMLAttributes<HTMLDivEleme
|
|
|
38
49
|
placeholder?: string;
|
|
39
50
|
promptValue?: string;
|
|
40
51
|
onPromptValueChange?: (value: string) => void;
|
|
41
|
-
onSubmit?: (
|
|
42
|
-
message: PromptInputMessage,
|
|
43
|
-
event: FormEvent<HTMLFormElement>
|
|
44
|
-
) => void | Promise<void>;
|
|
52
|
+
onSubmit?: (message: PromptInputMessage, event: FormEvent<HTMLFormElement>) => void;
|
|
45
53
|
loading?: boolean;
|
|
46
54
|
onStop?: () => void;
|
|
47
55
|
enableSpeech?: boolean;
|
|
48
56
|
enableAttachments?: boolean;
|
|
49
57
|
promptInputVariant?: PromptInputVariant;
|
|
50
|
-
onPublish?: (
|
|
58
|
+
onPublish?: (currentStory: StorySegment, text?: string) => void;
|
|
51
59
|
publishPlaceholder?: string;
|
|
52
|
-
|
|
60
|
+
onStoryChange?: (storyIndex: number, story: StorySegment) => void;
|
|
61
|
+
onSceneChange?: (sceneIndex: number, scene: StorySegment) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type ScenePlayerProps = StoryPlayerProps;
|
|
65
|
+
|
|
66
|
+
function getClipUrl(item?: StorySegment): string | undefined {
|
|
67
|
+
if (!item) return undefined;
|
|
68
|
+
return item.clip_url || item.scene_clip_url || item.story_clip_url || item.video_url;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
|
-
export const
|
|
71
|
+
export const StoryPlayer = React.forwardRef<HTMLDivElement, StoryPlayerProps>(
|
|
56
72
|
(
|
|
57
73
|
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
stories,
|
|
75
|
+
scenes,
|
|
76
|
+
segments,
|
|
77
|
+
title = "Story Player",
|
|
78
|
+
initialStoryIndex,
|
|
62
79
|
initialSceneIndex = 0,
|
|
63
|
-
isMuted: initialMuted = false,
|
|
64
80
|
isPlaying: isPlayingProp,
|
|
65
81
|
onPlayingChange,
|
|
82
|
+
initialMuted = false,
|
|
66
83
|
videoUrl,
|
|
67
84
|
downloadUrl,
|
|
68
85
|
onDownload,
|
|
@@ -79,6 +96,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
79
96
|
promptInputVariant = "row",
|
|
80
97
|
onPublish,
|
|
81
98
|
publishPlaceholder = "Ask a question or provide instructions...",
|
|
99
|
+
onStoryChange,
|
|
82
100
|
onSceneChange,
|
|
83
101
|
className,
|
|
84
102
|
...props
|
|
@@ -86,27 +104,32 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
86
104
|
ref
|
|
87
105
|
) => {
|
|
88
106
|
const handleBack = onBack || onClose;
|
|
107
|
+
const items = React.useMemo(
|
|
108
|
+
() => stories || scenes || segments || [],
|
|
109
|
+
[stories, scenes, segments]
|
|
110
|
+
);
|
|
111
|
+
const startingIndex = initialStoryIndex ?? initialSceneIndex ?? 0;
|
|
89
112
|
|
|
90
|
-
const [
|
|
91
|
-
if (
|
|
92
|
-
return
|
|
113
|
+
const [currentStoryIndex, setCurrentStoryIndex] = React.useState<number>(() => {
|
|
114
|
+
if (startingIndex >= 0 && startingIndex < items.length) {
|
|
115
|
+
return startingIndex;
|
|
93
116
|
}
|
|
94
117
|
return 0;
|
|
95
118
|
});
|
|
96
119
|
|
|
97
|
-
// Dual-video buffer for gapless playback between
|
|
98
|
-
// One slot actively plays the current
|
|
120
|
+
// Dual-video buffer for gapless playback between story segments:
|
|
121
|
+
// One slot actively plays the current story segment, while the other slot preloads the upcoming segment in the background.
|
|
99
122
|
const [activeSlot, setActiveSlot] = React.useState<0 | 1>(0);
|
|
100
123
|
const [slot0Index, setSlot0Index] = React.useState<number>(() => {
|
|
101
|
-
if (
|
|
102
|
-
return
|
|
124
|
+
if (startingIndex >= 0 && startingIndex < items.length) {
|
|
125
|
+
return startingIndex;
|
|
103
126
|
}
|
|
104
127
|
return 0;
|
|
105
128
|
});
|
|
106
129
|
const [slot1Index, setSlot1Index] = React.useState<number>(() => {
|
|
107
|
-
if (
|
|
108
|
-
const init =
|
|
109
|
-
return (init + 1) %
|
|
130
|
+
if (items.length > 1) {
|
|
131
|
+
const init = startingIndex >= 0 && startingIndex < items.length ? startingIndex : 0;
|
|
132
|
+
return (init + 1) % items.length;
|
|
110
133
|
}
|
|
111
134
|
return 0;
|
|
112
135
|
});
|
|
@@ -142,17 +165,18 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
142
165
|
});
|
|
143
166
|
}, []);
|
|
144
167
|
|
|
145
|
-
const
|
|
168
|
+
const activeStory = items[currentStoryIndex] || items[0];
|
|
146
169
|
|
|
147
|
-
const
|
|
170
|
+
const goToStory = React.useCallback(
|
|
148
171
|
(targetIdx: number) => {
|
|
149
|
-
if (!
|
|
150
|
-
const clampedIdx = (targetIdx +
|
|
151
|
-
|
|
152
|
-
|
|
172
|
+
if (!items.length) return;
|
|
173
|
+
const clampedIdx = (targetIdx + items.length) % items.length;
|
|
174
|
+
onStoryChange?.(clampedIdx, items[clampedIdx]);
|
|
175
|
+
onSceneChange?.(clampedIdx, items[clampedIdx]);
|
|
176
|
+
setCurrentStoryIndex(clampedIdx);
|
|
153
177
|
isTransitioningRef.current = false;
|
|
154
178
|
|
|
155
|
-
if (
|
|
179
|
+
if (items.length <= 1) {
|
|
156
180
|
setSlot0Index(clampedIdx);
|
|
157
181
|
const v0 = videoRef0.current;
|
|
158
182
|
if (v0) {
|
|
@@ -173,7 +197,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
173
197
|
const nextActiveSlot: 0 | 1 = activeSlot === 0 ? 1 : 0;
|
|
174
198
|
const nextVideo = nextActiveSlot === 0 ? videoRef0.current : videoRef1.current;
|
|
175
199
|
const currentVideo = activeSlot === 0 ? videoRef0.current : videoRef1.current;
|
|
176
|
-
const
|
|
200
|
+
const segmentAfterTarget = (clampedIdx + 1) % items.length;
|
|
177
201
|
|
|
178
202
|
// 1. Immediately start next video synchronously (0ms transition gap)
|
|
179
203
|
if (nextVideo) {
|
|
@@ -194,18 +218,18 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
194
218
|
currentVideo.pause();
|
|
195
219
|
}
|
|
196
220
|
|
|
197
|
-
// 3. Swap slots and prepare idle slot to preload upcoming
|
|
221
|
+
// 3. Swap slots and prepare idle slot to preload upcoming segment
|
|
198
222
|
if (nextActiveSlot === 0) {
|
|
199
223
|
setSlot0Index(clampedIdx);
|
|
200
|
-
setSlot1Index(
|
|
224
|
+
setSlot1Index(segmentAfterTarget);
|
|
201
225
|
} else {
|
|
202
226
|
setSlot1Index(clampedIdx);
|
|
203
|
-
setSlot0Index(
|
|
227
|
+
setSlot0Index(segmentAfterTarget);
|
|
204
228
|
}
|
|
205
229
|
|
|
206
230
|
setActiveSlot(nextActiveSlot);
|
|
207
231
|
},
|
|
208
|
-
[
|
|
232
|
+
[items, activeSlot, isPlaying, isMuted, onStoryChange, onSceneChange]
|
|
209
233
|
);
|
|
210
234
|
|
|
211
235
|
const handlePromptSubmit = React.useCallback(
|
|
@@ -215,41 +239,41 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
215
239
|
return;
|
|
216
240
|
}
|
|
217
241
|
if (onPublish) {
|
|
218
|
-
onPublish(
|
|
242
|
+
onPublish(activeStory, message.text);
|
|
219
243
|
}
|
|
220
244
|
},
|
|
221
|
-
[onSubmit, onPublish,
|
|
245
|
+
[onSubmit, onPublish, activeStory]
|
|
222
246
|
);
|
|
223
247
|
|
|
224
248
|
const handlePrev = React.useCallback(() => {
|
|
225
|
-
if (!
|
|
226
|
-
const prevIdx =
|
|
227
|
-
|
|
228
|
-
}, [
|
|
249
|
+
if (!items.length) return;
|
|
250
|
+
const prevIdx = currentStoryIndex > 0 ? currentStoryIndex - 1 : items.length - 1;
|
|
251
|
+
goToStory(prevIdx);
|
|
252
|
+
}, [items.length, currentStoryIndex, goToStory]);
|
|
229
253
|
|
|
230
254
|
const handleNext = React.useCallback(() => {
|
|
231
|
-
if (!
|
|
232
|
-
const nextIdx =
|
|
233
|
-
|
|
234
|
-
}, [
|
|
255
|
+
if (!items.length) return;
|
|
256
|
+
const nextIdx = currentStoryIndex < items.length - 1 ? currentStoryIndex + 1 : 0;
|
|
257
|
+
goToStory(nextIdx);
|
|
258
|
+
}, [items.length, currentStoryIndex, goToStory]);
|
|
235
259
|
|
|
236
|
-
const
|
|
237
|
-
if (!
|
|
260
|
+
const handleStoryEnded = React.useCallback(() => {
|
|
261
|
+
if (!items.length || !isPlaying || isTransitioningRef.current) return;
|
|
238
262
|
isTransitioningRef.current = true;
|
|
239
263
|
handleNext();
|
|
240
|
-
}, [
|
|
264
|
+
}, [items.length, isPlaying, handleNext]);
|
|
241
265
|
|
|
242
266
|
const handleTimeUpdate = React.useCallback(
|
|
243
267
|
(e: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
244
268
|
const video = e.currentTarget;
|
|
245
|
-
if (!
|
|
246
|
-
// Trigger next
|
|
269
|
+
if (!items.length || !isPlaying || isTransitioningRef.current) return;
|
|
270
|
+
// Trigger next segment 60ms before end so decoder starts seamlessly with zero gap
|
|
247
271
|
if (video.duration > 0.1 && video.currentTime >= video.duration - 0.06) {
|
|
248
272
|
isTransitioningRef.current = true;
|
|
249
273
|
handleNext();
|
|
250
274
|
}
|
|
251
275
|
},
|
|
252
|
-
[
|
|
276
|
+
[items.length, isPlaying, handleNext]
|
|
253
277
|
);
|
|
254
278
|
|
|
255
279
|
const handleDownload = React.useCallback(async () => {
|
|
@@ -257,7 +281,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
257
281
|
onDownload();
|
|
258
282
|
return;
|
|
259
283
|
}
|
|
260
|
-
const targetUrl = downloadUrl || videoUrl ||
|
|
284
|
+
const targetUrl = downloadUrl || videoUrl || getClipUrl(activeStory);
|
|
261
285
|
if (!targetUrl) return;
|
|
262
286
|
|
|
263
287
|
try {
|
|
@@ -282,7 +306,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
282
306
|
link.click();
|
|
283
307
|
document.body.removeChild(link);
|
|
284
308
|
}
|
|
285
|
-
}, [onDownload, downloadUrl, videoUrl,
|
|
309
|
+
}, [onDownload, downloadUrl, videoUrl, activeStory]);
|
|
286
310
|
|
|
287
311
|
// Playback & mute synchronization across dual slots
|
|
288
312
|
React.useEffect(() => {
|
|
@@ -315,12 +339,12 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
315
339
|
}
|
|
316
340
|
}, [activeSlot, isPlaying, isMuted]);
|
|
317
341
|
|
|
318
|
-
// Ensure idle video element actively loads and buffers upcoming
|
|
342
|
+
// Ensure idle video element actively loads and buffers upcoming segment
|
|
319
343
|
React.useEffect(() => {
|
|
320
|
-
if (
|
|
344
|
+
if (items.length <= 1) return;
|
|
321
345
|
const idleVideo = activeSlot === 0 ? videoRef1.current : videoRef0.current;
|
|
322
346
|
const idleIndex = activeSlot === 0 ? slot1Index : slot0Index;
|
|
323
|
-
const idleUrl =
|
|
347
|
+
const idleUrl = getClipUrl(items[idleIndex]);
|
|
324
348
|
|
|
325
349
|
if (idleVideo && idleUrl) {
|
|
326
350
|
if (idleVideo.src !== idleUrl) {
|
|
@@ -329,18 +353,18 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
329
353
|
idleVideo.preload = "auto";
|
|
330
354
|
idleVideo.load();
|
|
331
355
|
}
|
|
332
|
-
}, [activeSlot, slot0Index, slot1Index,
|
|
356
|
+
}, [activeSlot, slot0Index, slot1Index, items]);
|
|
333
357
|
|
|
334
358
|
// Prefetch upcoming clips into browser HTTP cache
|
|
335
359
|
React.useEffect(() => {
|
|
336
|
-
if (!
|
|
360
|
+
if (!items || items.length <= 2) return;
|
|
337
361
|
const toPrefetch = [
|
|
338
|
-
(
|
|
339
|
-
(
|
|
362
|
+
(currentStoryIndex + 2) % items.length,
|
|
363
|
+
(currentStoryIndex + 3) % items.length,
|
|
340
364
|
];
|
|
341
365
|
|
|
342
366
|
toPrefetch.forEach((idx) => {
|
|
343
|
-
const url =
|
|
367
|
+
const url = getClipUrl(items[idx]);
|
|
344
368
|
if (url) {
|
|
345
369
|
const link = document.createElement("link");
|
|
346
370
|
link.rel = "prefetch";
|
|
@@ -351,17 +375,17 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
351
375
|
}, 15000);
|
|
352
376
|
}
|
|
353
377
|
});
|
|
354
|
-
}, [
|
|
378
|
+
}, [currentStoryIndex, items]);
|
|
355
379
|
|
|
356
380
|
React.useEffect(() => {
|
|
357
|
-
if (!
|
|
358
|
-
if (
|
|
359
|
-
|
|
381
|
+
if (!items || items.length === 0) return;
|
|
382
|
+
if (currentStoryIndex >= items.length) {
|
|
383
|
+
setCurrentStoryIndex(0);
|
|
360
384
|
setSlot0Index(0);
|
|
361
|
-
setSlot1Index(
|
|
385
|
+
setSlot1Index(items.length > 1 ? 1 : 0);
|
|
362
386
|
setActiveSlot(0);
|
|
363
387
|
}
|
|
364
|
-
}, [
|
|
388
|
+
}, [items, currentStoryIndex]);
|
|
365
389
|
|
|
366
390
|
// Keyboard navigation & playback controls
|
|
367
391
|
React.useEffect(() => {
|
|
@@ -396,17 +420,17 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
396
420
|
|
|
397
421
|
window.addEventListener("keydown", handleKeyDown);
|
|
398
422
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
399
|
-
}, [handlePrev, handleNext, handleBack, togglePlay]);
|
|
423
|
+
}, [handlePrev, handleNext, handleBack, togglePlay, toggleMute]);
|
|
400
424
|
|
|
401
|
-
if (!
|
|
425
|
+
if (!items || items.length === 0) {
|
|
402
426
|
return (
|
|
403
427
|
<div
|
|
404
428
|
ref={ref}
|
|
405
429
|
className={cn("flex flex-col items-center justify-center w-full h-full bg-black text-white p-6", className)}
|
|
406
|
-
data-testid="
|
|
430
|
+
data-testid="story-player"
|
|
407
431
|
{...props}
|
|
408
432
|
>
|
|
409
|
-
<p className="text-white/70 text-sm">No
|
|
433
|
+
<p className="text-white/70 text-sm">No stories available</p>
|
|
410
434
|
{handleBack && (
|
|
411
435
|
<Button
|
|
412
436
|
variant="ghost"
|
|
@@ -421,6 +445,12 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
421
445
|
);
|
|
422
446
|
}
|
|
423
447
|
|
|
448
|
+
const currentLabel =
|
|
449
|
+
activeStory?.sceneNumber ??
|
|
450
|
+
activeStory?.storyNumber ??
|
|
451
|
+
activeStory?.segmentNumber ??
|
|
452
|
+
currentStoryIndex + 1;
|
|
453
|
+
|
|
424
454
|
return (
|
|
425
455
|
<div
|
|
426
456
|
ref={ref}
|
|
@@ -428,17 +458,17 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
428
458
|
"flex flex-col w-full h-full min-h-full bg-black text-white relative select-none overflow-hidden",
|
|
429
459
|
className
|
|
430
460
|
)}
|
|
431
|
-
data-testid="
|
|
461
|
+
data-testid="story-player"
|
|
432
462
|
{...props}
|
|
433
463
|
>
|
|
434
464
|
{/* Top Segmented Progress Bar */}
|
|
435
465
|
<div className="flex items-center gap-[3px] px-2 pt-3 pb-1.5 z-20 shrink-0">
|
|
436
|
-
{
|
|
437
|
-
const isCompleted = idx <
|
|
438
|
-
const isCurrent = idx ===
|
|
466
|
+
{items.map((item, idx) => {
|
|
467
|
+
const isCompleted = idx < currentStoryIndex;
|
|
468
|
+
const isCurrent = idx === currentStoryIndex;
|
|
439
469
|
return (
|
|
440
470
|
<div
|
|
441
|
-
key={
|
|
471
|
+
key={item.id || `story-segment-${idx}`}
|
|
442
472
|
className="flex-1 h-[2.5px] rounded-[2px] bg-white/35 overflow-hidden"
|
|
443
473
|
>
|
|
444
474
|
<div
|
|
@@ -474,7 +504,9 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
474
504
|
{title}
|
|
475
505
|
</span>
|
|
476
506
|
<span className="text-xs text-white/75 truncate">
|
|
477
|
-
|
|
507
|
+
{typeof currentLabel === "number"
|
|
508
|
+
? `Segment ${currentLabel} of ${items.length}`
|
|
509
|
+
: `${currentLabel} of ${items.length}`}
|
|
478
510
|
</span>
|
|
479
511
|
</div>
|
|
480
512
|
</div>
|
|
@@ -485,7 +517,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
485
517
|
variant="ghost"
|
|
486
518
|
size="icon"
|
|
487
519
|
data-testid="btn-toggle-play"
|
|
488
|
-
aria-label={isPlaying ? "Pause
|
|
520
|
+
aria-label={isPlaying ? "Pause story" : "Play story"}
|
|
489
521
|
onClick={togglePlay}
|
|
490
522
|
className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer"
|
|
491
523
|
>
|
|
@@ -523,13 +555,13 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
523
555
|
{/* Slot 0 Video */}
|
|
524
556
|
<video
|
|
525
557
|
ref={videoRef0}
|
|
526
|
-
src={
|
|
558
|
+
src={getClipUrl(items[slot0Index])}
|
|
527
559
|
preload="auto"
|
|
528
560
|
playsInline
|
|
529
561
|
muted={isMuted}
|
|
530
|
-
onEnded={activeSlot === 0 ?
|
|
562
|
+
onEnded={activeSlot === 0 ? handleStoryEnded : undefined}
|
|
531
563
|
onTimeUpdate={activeSlot === 0 ? handleTimeUpdate : undefined}
|
|
532
|
-
data-testid="
|
|
564
|
+
data-testid="story-video-0"
|
|
533
565
|
className={cn(
|
|
534
566
|
"w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
|
|
535
567
|
activeSlot === 0 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
|
|
@@ -537,16 +569,16 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
537
569
|
/>
|
|
538
570
|
|
|
539
571
|
{/* Slot 1 Video (preloads next scene in background for gapless playback) */}
|
|
540
|
-
{
|
|
572
|
+
{items.length > 1 && (
|
|
541
573
|
<video
|
|
542
574
|
ref={videoRef1}
|
|
543
|
-
src={
|
|
575
|
+
src={getClipUrl(items[slot1Index])}
|
|
544
576
|
preload="auto"
|
|
545
577
|
playsInline
|
|
546
578
|
muted={isMuted}
|
|
547
|
-
onEnded={activeSlot === 1 ?
|
|
579
|
+
onEnded={activeSlot === 1 ? handleStoryEnded : undefined}
|
|
548
580
|
onTimeUpdate={activeSlot === 1 ? handleTimeUpdate : undefined}
|
|
549
|
-
data-testid="
|
|
581
|
+
data-testid="story-video-1"
|
|
550
582
|
className={cn(
|
|
551
583
|
"w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
|
|
552
584
|
activeSlot === 1 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
|
|
@@ -554,19 +586,19 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
554
586
|
/>
|
|
555
587
|
)}
|
|
556
588
|
|
|
557
|
-
{/* Invisible Interactive Tap Zones for Stepping
|
|
589
|
+
{/* Invisible Interactive Tap Zones for Stepping Stories */}
|
|
558
590
|
<div className="absolute inset-x-0 top-[60px] bottom-[70px] flex z-20">
|
|
559
591
|
<Button
|
|
560
592
|
variant="ghost"
|
|
561
|
-
data-testid="btn-prev-
|
|
562
|
-
aria-label="Previous
|
|
593
|
+
data-testid="btn-prev-story"
|
|
594
|
+
aria-label="Previous story"
|
|
563
595
|
onClick={handlePrev}
|
|
564
596
|
className="flex-1 h-full cursor-pointer bg-transparent hover:bg-transparent p-0 rounded-none border-none focus-visible:ring-0 focus-visible:outline-none"
|
|
565
597
|
/>
|
|
566
598
|
<Button
|
|
567
599
|
variant="ghost"
|
|
568
|
-
data-testid="btn-next-
|
|
569
|
-
aria-label="Next
|
|
600
|
+
data-testid="btn-next-story"
|
|
601
|
+
aria-label="Next story"
|
|
570
602
|
onClick={handleNext}
|
|
571
603
|
className="flex-1 h-full cursor-pointer bg-transparent hover:bg-transparent p-0 rounded-none border-none focus-visible:ring-0 focus-visible:outline-none"
|
|
572
604
|
/>
|
|
@@ -604,4 +636,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
|
|
|
604
636
|
}
|
|
605
637
|
);
|
|
606
638
|
|
|
607
|
-
|
|
639
|
+
StoryPlayer.displayName = "StoryPlayer";
|
|
640
|
+
|
|
641
|
+
// Backwards-compatible aliases
|
|
642
|
+
export const ScenePlayer = StoryPlayer;
|
|
@@ -286,7 +286,7 @@ export type { WorkspaceEmptyStateProps } from './WorkspaceEmptyState'
|
|
|
286
286
|
export { StoriesBar } from './StoriesBar'
|
|
287
287
|
export type { StoriesBarProps, StoryItem } from './StoriesBar'
|
|
288
288
|
|
|
289
|
-
//
|
|
290
|
-
export { ScenePlayer } from './
|
|
291
|
-
export type { ScenePlayerProps, SceneItem } from './
|
|
289
|
+
// StoryPlayer Composite
|
|
290
|
+
export { StoryPlayer, ScenePlayer } from './StoryPlayer'
|
|
291
|
+
export type { StoryPlayerProps, ScenePlayerProps, StorySegment, SceneItem } from './StoryPlayer'
|
|
292
292
|
|
package/components/index.ts
CHANGED
|
@@ -34,11 +34,14 @@ export {
|
|
|
34
34
|
TaskQueue,
|
|
35
35
|
SpeechInput,
|
|
36
36
|
StoriesBar,
|
|
37
|
+
StoryPlayer,
|
|
37
38
|
ScenePlayer,
|
|
38
39
|
} from './composites';
|
|
39
40
|
export type {
|
|
40
41
|
StoriesBarProps,
|
|
41
42
|
StoryItem,
|
|
43
|
+
StoryPlayerProps,
|
|
44
|
+
StorySegment,
|
|
42
45
|
ScenePlayerProps,
|
|
43
46
|
SceneItem,
|
|
44
47
|
SpeechInputProps,
|