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.
@@ -1,2 +1,7 @@
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
+ SceneItem,
7
+ } 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,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 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 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
- avatarInitials?: string;
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?: (currentScene: SceneItem, text?: string) => void;
57
+ onPublish?: (currentStory: StorySegment, text?: string) => void;
51
58
  publishPlaceholder?: string;
52
- onSceneChange?: (sceneIndex: number, scene: SceneItem) => void;
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 ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
70
+ export const StoryPlayer = React.forwardRef<HTMLDivElement, StoryPlayerProps>(
56
71
  (
57
72
  {
58
- scenes = [],
59
- title = "Devin for iOS",
60
- avatarInitials = "D",
61
- avatarColor = "bg-indigo-600",
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 [currentSceneIndex, setCurrentSceneIndex] = React.useState<number>(() => {
91
- if (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
92
- return initialSceneIndex;
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 scenes:
98
- // One slot actively plays the current scene, while the other slot preloads the next scene in the background.
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 (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
102
- return initialSceneIndex;
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 (scenes.length > 1) {
108
- const init = initialSceneIndex >= 0 && initialSceneIndex < scenes.length ? initialSceneIndex : 0;
109
- return (init + 1) % scenes.length;
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 activeScene = scenes[currentSceneIndex] || scenes[0];
167
+ const activeStory = items[currentStoryIndex] || items[0];
146
168
 
147
- const goToScene = React.useCallback(
169
+ const goToStory = React.useCallback(
148
170
  (targetIdx: number) => {
149
- if (!scenes.length) return;
150
- const clampedIdx = (targetIdx + scenes.length) % scenes.length;
151
- onSceneChange?.(clampedIdx, scenes[clampedIdx]);
152
- setCurrentSceneIndex(clampedIdx);
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 (scenes.length <= 1) {
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 sceneAfterTarget = (clampedIdx + 1) % scenes.length;
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 scene
220
+ // 3. Swap slots and prepare idle slot to preload upcoming segment
198
221
  if (nextActiveSlot === 0) {
199
222
  setSlot0Index(clampedIdx);
200
- setSlot1Index(sceneAfterTarget);
223
+ setSlot1Index(segmentAfterTarget);
201
224
  } else {
202
225
  setSlot1Index(clampedIdx);
203
- setSlot0Index(sceneAfterTarget);
226
+ setSlot0Index(segmentAfterTarget);
204
227
  }
205
228
 
206
229
  setActiveSlot(nextActiveSlot);
207
230
  },
208
- [scenes, activeSlot, isPlaying, isMuted, onSceneChange]
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(activeScene, message.text);
241
+ onPublish(activeStory, message.text);
219
242
  }
220
243
  },
221
- [onSubmit, onPublish, activeScene]
244
+ [onSubmit, onPublish, activeStory]
222
245
  );
223
246
 
224
247
  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]);
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 (!scenes.length) return;
232
- const nextIdx = currentSceneIndex < scenes.length - 1 ? currentSceneIndex + 1 : 0;
233
- goToScene(nextIdx);
234
- }, [scenes.length, currentSceneIndex, goToScene]);
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 handleSceneEnded = React.useCallback(() => {
237
- if (!scenes.length || !isPlaying || isTransitioningRef.current) return;
259
+ const handleStoryEnded = React.useCallback(() => {
260
+ if (!items.length || !isPlaying || isTransitioningRef.current) return;
238
261
  isTransitioningRef.current = true;
239
262
  handleNext();
240
- }, [scenes.length, isPlaying, handleNext]);
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 (!scenes.length || !isPlaying || isTransitioningRef.current) return;
246
- // Trigger next scene 60ms before end so decoder starts seamlessly with zero gap
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
- [scenes.length, isPlaying, handleNext]
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 || activeScene?.scene_clip_url;
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, activeScene?.scene_clip_url]);
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 scene
341
+ // Ensure idle video element actively loads and buffers upcoming segment
319
342
  React.useEffect(() => {
320
- if (scenes.length <= 1) return;
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 = scenes[idleIndex]?.scene_clip_url;
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, scenes]);
355
+ }, [activeSlot, slot0Index, slot1Index, items]);
333
356
 
334
357
  // Prefetch upcoming clips into browser HTTP cache
335
358
  React.useEffect(() => {
336
- if (!scenes || scenes.length <= 2) return;
359
+ if (!items || items.length <= 2) return;
337
360
  const toPrefetch = [
338
- (currentSceneIndex + 2) % scenes.length,
339
- (currentSceneIndex + 3) % scenes.length,
361
+ (currentStoryIndex + 2) % items.length,
362
+ (currentStoryIndex + 3) % items.length,
340
363
  ];
341
364
 
342
365
  toPrefetch.forEach((idx) => {
343
- const url = scenes[idx]?.scene_clip_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
- }, [currentSceneIndex, scenes]);
377
+ }, [currentStoryIndex, items]);
355
378
 
356
379
  React.useEffect(() => {
357
- if (!scenes || scenes.length === 0) return;
358
- if (currentSceneIndex >= scenes.length) {
359
- setCurrentSceneIndex(0);
380
+ if (!items || items.length === 0) return;
381
+ if (currentStoryIndex >= items.length) {
382
+ setCurrentStoryIndex(0);
360
383
  setSlot0Index(0);
361
- setSlot1Index(scenes.length > 1 ? 1 : 0);
384
+ setSlot1Index(items.length > 1 ? 1 : 0);
362
385
  setActiveSlot(0);
363
386
  }
364
- }, [scenes, currentSceneIndex]);
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 (!scenes || scenes.length === 0) {
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="screen-scenes"
429
+ data-testid="story-player"
407
430
  {...props}
408
431
  >
409
- <p className="text-white/70 text-sm">No scenes available</p>
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="screen-scenes"
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
- {scenes.map((scene, idx) => {
437
- const isCompleted = idx < currentSceneIndex;
438
- const isCurrent = idx === currentSceneIndex;
465
+ {items.map((item, idx) => {
466
+ const isCompleted = idx < currentStoryIndex;
467
+ const isCurrent = idx === currentStoryIndex;
439
468
  return (
440
469
  <div
441
- key={scene.id || `scene-${idx}`}
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
- Scene {activeScene?.sceneNumber ?? currentSceneIndex + 1} of {scenes.length}
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 scene" : "Play scene"}
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={scenes[slot0Index]?.scene_clip_url}
557
+ src={getClipUrl(items[slot0Index])}
527
558
  preload="auto"
528
559
  playsInline
529
560
  muted={isMuted}
530
- onEnded={activeSlot === 0 ? handleSceneEnded : undefined}
561
+ onEnded={activeSlot === 0 ? handleStoryEnded : undefined}
531
562
  onTimeUpdate={activeSlot === 0 ? handleTimeUpdate : undefined}
532
- data-testid="scene-video-0"
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
- {scenes.length > 1 && (
571
+ {items.length > 1 && (
541
572
  <video
542
573
  ref={videoRef1}
543
- src={scenes[slot1Index]?.scene_clip_url}
574
+ src={getClipUrl(items[slot1Index])}
544
575
  preload="auto"
545
576
  playsInline
546
577
  muted={isMuted}
547
- onEnded={activeSlot === 1 ? handleSceneEnded : undefined}
578
+ onEnded={activeSlot === 1 ? handleStoryEnded : undefined}
548
579
  onTimeUpdate={activeSlot === 1 ? handleTimeUpdate : undefined}
549
- data-testid="scene-video-1"
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 Scenes */}
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-scene"
562
- aria-label="Previous scene"
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-scene"
569
- aria-label="Next scene"
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
- ScenePlayer.displayName = "ScenePlayer";
638
+ StoryPlayer.displayName = "StoryPlayer";
639
+
640
+ // Backwards-compatible aliases
641
+ export const ScenePlayer = StoryPlayer;
@@ -0,0 +1,7 @@
1
+ export { StoryPlayer, ScenePlayer } from "./StoryPlayer";
2
+ export type {
3
+ StoryPlayerProps,
4
+ ScenePlayerProps,
5
+ StorySegment,
6
+ SceneItem,
7
+ } 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,