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.
@@ -1,2 +1,8 @@
1
- export { ScenePlayer } from "./ScenePlayer";
2
- export type { ScenePlayerProps, SceneItem } from "./ScenePlayer";
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 { ScenePlayer, type SceneItem } from "./ScenePlayer";
2
+ import { StoryPlayer, type StorySegment } from "./StoryPlayer";
3
3
  import { fn } from "@storybook/test";
4
4
 
5
5
  /**
6
- * ScenePlayer Composite Stories
6
+ * StoryPlayer Composite Stories
7
7
  *
8
- * Full-screen segmented video player for scene-by-scene review with progress tracks,
9
- * mute toggle, step zones, and publish action.
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/ScenePlayer",
13
- component: ScenePlayer,
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 ScenePlayer>;
26
+ } satisfies Meta<typeof StoryPlayer>;
26
27
 
27
28
  export default meta;
28
29
  type Story = StoryObj<typeof meta>;
29
30
 
30
- const mockScenes: SceneItem[] = [
31
+ const mockStories: StorySegment[] = [
31
32
  {
32
33
  id: "001",
33
- sceneNumber: 1,
34
- totalScenes: 3,
34
+ segmentNumber: 1,
35
+ totalSegments: 3,
35
36
  start_sec: 0,
36
37
  duration_sec: 1.6,
37
- templateId: "grid2_compare_visual",
38
- caption: "iOS apps used to mean one",
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
- sceneNumber: 2,
45
- totalScenes: 3,
44
+ segmentNumber: 2,
45
+ totalSegments: 3,
46
46
  start_sec: 1.6,
47
47
  duration_sec: 1.5,
48
- templateId: "full_visual_loop",
49
- caption: "thing you needed a Mac. And nobody",
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
- sceneNumber: 3,
56
- totalScenes: 3,
54
+ segmentNumber: 3,
55
+ totalSegments: 3,
57
56
  start_sec: 3.1,
58
57
  duration_sec: 1.5,
59
- templateId: "full_talking_head",
60
- caption: "told the AI, every AI",
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
- scenes: mockScenes,
72
- title: "Devin for iOS",
73
- avatarInitials: "D",
66
+ stories: mockStories,
67
+ title: "Building iOS App on Devin",
68
+ isPlaying: true,
74
69
  },
75
70
  };
76
71
 
77
- /**
78
- * Empty scenes fallback
79
- */
80
- export const EmptyState: Story = {
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
- scenes: [],
83
- title: "No Scenes",
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 SceneItem {
12
- id: string;
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
- scene_clip_url: string;
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 interface ScenePlayerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit"> {
25
- scenes: SceneItem[];
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
- avatarInitials?: string;
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?: (currentScene: SceneItem, text?: string) => void;
58
+ onPublish?: (currentStory: StorySegment, text?: string) => void;
51
59
  publishPlaceholder?: string;
52
- onSceneChange?: (sceneIndex: number, scene: SceneItem) => void;
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 ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
71
+ export const StoryPlayer = React.forwardRef<HTMLDivElement, StoryPlayerProps>(
56
72
  (
57
73
  {
58
- scenes = [],
59
- title = "Devin for iOS",
60
- avatarInitials = "D",
61
- avatarColor = "bg-indigo-600",
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 [currentSceneIndex, setCurrentSceneIndex] = React.useState<number>(() => {
91
- if (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
92
- return initialSceneIndex;
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 scenes:
98
- // One slot actively plays the current scene, while the other slot preloads the next scene in the background.
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 (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
102
- return initialSceneIndex;
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 (scenes.length > 1) {
108
- const init = initialSceneIndex >= 0 && initialSceneIndex < scenes.length ? initialSceneIndex : 0;
109
- return (init + 1) % scenes.length;
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 activeScene = scenes[currentSceneIndex] || scenes[0];
168
+ const activeStory = items[currentStoryIndex] || items[0];
146
169
 
147
- const goToScene = React.useCallback(
170
+ const goToStory = React.useCallback(
148
171
  (targetIdx: number) => {
149
- if (!scenes.length) return;
150
- const clampedIdx = (targetIdx + scenes.length) % scenes.length;
151
- onSceneChange?.(clampedIdx, scenes[clampedIdx]);
152
- setCurrentSceneIndex(clampedIdx);
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 (scenes.length <= 1) {
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 sceneAfterTarget = (clampedIdx + 1) % scenes.length;
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 scene
221
+ // 3. Swap slots and prepare idle slot to preload upcoming segment
198
222
  if (nextActiveSlot === 0) {
199
223
  setSlot0Index(clampedIdx);
200
- setSlot1Index(sceneAfterTarget);
224
+ setSlot1Index(segmentAfterTarget);
201
225
  } else {
202
226
  setSlot1Index(clampedIdx);
203
- setSlot0Index(sceneAfterTarget);
227
+ setSlot0Index(segmentAfterTarget);
204
228
  }
205
229
 
206
230
  setActiveSlot(nextActiveSlot);
207
231
  },
208
- [scenes, activeSlot, isPlaying, isMuted, onSceneChange]
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(activeScene, message.text);
242
+ onPublish(activeStory, message.text);
219
243
  }
220
244
  },
221
- [onSubmit, onPublish, activeScene]
245
+ [onSubmit, onPublish, activeStory]
222
246
  );
223
247
 
224
248
  const handlePrev = React.useCallback(() => {
225
- if (!scenes.length) return;
226
- const prevIdx = currentSceneIndex > 0 ? currentSceneIndex - 1 : scenes.length - 1;
227
- goToScene(prevIdx);
228
- }, [scenes.length, currentSceneIndex, goToScene]);
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 (!scenes.length) return;
232
- const nextIdx = currentSceneIndex < scenes.length - 1 ? currentSceneIndex + 1 : 0;
233
- goToScene(nextIdx);
234
- }, [scenes.length, currentSceneIndex, goToScene]);
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 handleSceneEnded = React.useCallback(() => {
237
- if (!scenes.length || !isPlaying || isTransitioningRef.current) return;
260
+ const handleStoryEnded = React.useCallback(() => {
261
+ if (!items.length || !isPlaying || isTransitioningRef.current) return;
238
262
  isTransitioningRef.current = true;
239
263
  handleNext();
240
- }, [scenes.length, isPlaying, handleNext]);
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 (!scenes.length || !isPlaying || isTransitioningRef.current) return;
246
- // Trigger next scene 60ms before end so decoder starts seamlessly with zero gap
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
- [scenes.length, isPlaying, handleNext]
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 || activeScene?.scene_clip_url;
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, activeScene?.scene_clip_url]);
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 scene
342
+ // Ensure idle video element actively loads and buffers upcoming segment
319
343
  React.useEffect(() => {
320
- if (scenes.length <= 1) return;
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 = scenes[idleIndex]?.scene_clip_url;
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, scenes]);
356
+ }, [activeSlot, slot0Index, slot1Index, items]);
333
357
 
334
358
  // Prefetch upcoming clips into browser HTTP cache
335
359
  React.useEffect(() => {
336
- if (!scenes || scenes.length <= 2) return;
360
+ if (!items || items.length <= 2) return;
337
361
  const toPrefetch = [
338
- (currentSceneIndex + 2) % scenes.length,
339
- (currentSceneIndex + 3) % scenes.length,
362
+ (currentStoryIndex + 2) % items.length,
363
+ (currentStoryIndex + 3) % items.length,
340
364
  ];
341
365
 
342
366
  toPrefetch.forEach((idx) => {
343
- const url = scenes[idx]?.scene_clip_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
- }, [currentSceneIndex, scenes]);
378
+ }, [currentStoryIndex, items]);
355
379
 
356
380
  React.useEffect(() => {
357
- if (!scenes || scenes.length === 0) return;
358
- if (currentSceneIndex >= scenes.length) {
359
- setCurrentSceneIndex(0);
381
+ if (!items || items.length === 0) return;
382
+ if (currentStoryIndex >= items.length) {
383
+ setCurrentStoryIndex(0);
360
384
  setSlot0Index(0);
361
- setSlot1Index(scenes.length > 1 ? 1 : 0);
385
+ setSlot1Index(items.length > 1 ? 1 : 0);
362
386
  setActiveSlot(0);
363
387
  }
364
- }, [scenes, currentSceneIndex]);
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 (!scenes || scenes.length === 0) {
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="screen-scenes"
430
+ data-testid="story-player"
407
431
  {...props}
408
432
  >
409
- <p className="text-white/70 text-sm">No scenes available</p>
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="screen-scenes"
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
- {scenes.map((scene, idx) => {
437
- const isCompleted = idx < currentSceneIndex;
438
- const isCurrent = idx === currentSceneIndex;
466
+ {items.map((item, idx) => {
467
+ const isCompleted = idx < currentStoryIndex;
468
+ const isCurrent = idx === currentStoryIndex;
439
469
  return (
440
470
  <div
441
- key={scene.id || `scene-${idx}`}
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
- Scene {activeScene?.sceneNumber ?? currentSceneIndex + 1} of {scenes.length}
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 scene" : "Play scene"}
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={scenes[slot0Index]?.scene_clip_url}
558
+ src={getClipUrl(items[slot0Index])}
527
559
  preload="auto"
528
560
  playsInline
529
561
  muted={isMuted}
530
- onEnded={activeSlot === 0 ? handleSceneEnded : undefined}
562
+ onEnded={activeSlot === 0 ? handleStoryEnded : undefined}
531
563
  onTimeUpdate={activeSlot === 0 ? handleTimeUpdate : undefined}
532
- data-testid="scene-video-0"
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
- {scenes.length > 1 && (
572
+ {items.length > 1 && (
541
573
  <video
542
574
  ref={videoRef1}
543
- src={scenes[slot1Index]?.scene_clip_url}
575
+ src={getClipUrl(items[slot1Index])}
544
576
  preload="auto"
545
577
  playsInline
546
578
  muted={isMuted}
547
- onEnded={activeSlot === 1 ? handleSceneEnded : undefined}
579
+ onEnded={activeSlot === 1 ? handleStoryEnded : undefined}
548
580
  onTimeUpdate={activeSlot === 1 ? handleTimeUpdate : undefined}
549
- data-testid="scene-video-1"
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 Scenes */}
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-scene"
562
- aria-label="Previous scene"
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-scene"
569
- aria-label="Next scene"
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
- ScenePlayer.displayName = "ScenePlayer";
639
+ StoryPlayer.displayName = "StoryPlayer";
640
+
641
+ // Backwards-compatible aliases
642
+ export const ScenePlayer = StoryPlayer;
@@ -0,0 +1,8 @@
1
+ export { StoryPlayer, ScenePlayer } from "./StoryPlayer";
2
+ export type {
3
+ StoryPlayerProps,
4
+ ScenePlayerProps,
5
+ StorySegment,
6
+ StoryItem,
7
+ SceneItem,
8
+ } from "./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
- // ScenePlayer Composite
290
- export { ScenePlayer } from './ScenePlayer'
291
- export type { ScenePlayerProps, SceneItem } from './ScenePlayer'
289
+ // StoryPlayer Composite
290
+ export { StoryPlayer, ScenePlayer } from './StoryPlayer'
291
+ export type { StoryPlayerProps, ScenePlayerProps, StorySegment, SceneItem } from './StoryPlayer'
292
292
 
@@ -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,