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