ai-design-system 0.1.105 → 0.1.107

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.
@@ -94,6 +94,27 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
94
94
  return 0;
95
95
  });
96
96
 
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.
99
+ const [activeSlot, setActiveSlot] = React.useState<0 | 1>(0);
100
+ const [slot0Index, setSlot0Index] = React.useState<number>(() => {
101
+ if (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
102
+ return initialSceneIndex;
103
+ }
104
+ return 0;
105
+ });
106
+ 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;
110
+ }
111
+ return 0;
112
+ });
113
+
114
+ const videoRef0 = React.useRef<HTMLVideoElement | null>(null);
115
+ const videoRef1 = React.useRef<HTMLVideoElement | null>(null);
116
+ const isTransitioningRef = React.useRef<boolean>(false);
117
+
97
118
  const [isPlayingState, setIsPlayingState] = React.useState<boolean>(isPlayingProp ?? true);
98
119
  const isPlaying = isPlayingProp !== undefined ? isPlayingProp : isPlayingState;
99
120
 
@@ -111,10 +132,82 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
111
132
  }, [setIsPlaying]);
112
133
 
113
134
  const [isMuted, setIsMuted] = React.useState<boolean>(initialMuted);
114
- const videoRef = React.useRef<HTMLVideoElement | null>(null);
135
+
136
+ const toggleMute = React.useCallback(() => {
137
+ setIsMuted((prev) => {
138
+ const next = !prev;
139
+ if (videoRef0.current) videoRef0.current.muted = next;
140
+ if (videoRef1.current) videoRef1.current.muted = next;
141
+ return next;
142
+ });
143
+ }, []);
115
144
 
116
145
  const activeScene = scenes[currentSceneIndex] || scenes[0];
117
146
 
147
+ const goToScene = React.useCallback(
148
+ (targetIdx: number) => {
149
+ if (!scenes.length) return;
150
+ const clampedIdx = (targetIdx + scenes.length) % scenes.length;
151
+ onSceneChange?.(clampedIdx, scenes[clampedIdx]);
152
+ setCurrentSceneIndex(clampedIdx);
153
+ isTransitioningRef.current = false;
154
+
155
+ if (scenes.length <= 1) {
156
+ setSlot0Index(clampedIdx);
157
+ const v0 = videoRef0.current;
158
+ if (v0) {
159
+ v0.currentTime = 0;
160
+ v0.muted = isMuted;
161
+ if (isPlaying) {
162
+ v0.play().catch((err) => {
163
+ if (err?.name === "NotAllowedError") {
164
+ v0.muted = true;
165
+ v0.play().catch(() => {});
166
+ }
167
+ });
168
+ }
169
+ }
170
+ return;
171
+ }
172
+
173
+ const nextActiveSlot: 0 | 1 = activeSlot === 0 ? 1 : 0;
174
+ const nextVideo = nextActiveSlot === 0 ? videoRef0.current : videoRef1.current;
175
+ const currentVideo = activeSlot === 0 ? videoRef0.current : videoRef1.current;
176
+ const sceneAfterTarget = (clampedIdx + 1) % scenes.length;
177
+
178
+ // 1. Immediately start next video synchronously (0ms transition gap)
179
+ if (nextVideo) {
180
+ nextVideo.currentTime = 0;
181
+ nextVideo.muted = isMuted;
182
+ if (isPlaying) {
183
+ nextVideo.play().catch((err) => {
184
+ if (err?.name === "NotAllowedError") {
185
+ nextVideo.muted = true;
186
+ nextVideo.play().catch(() => {});
187
+ }
188
+ });
189
+ }
190
+ }
191
+
192
+ // 2. Pause previous video
193
+ if (currentVideo) {
194
+ currentVideo.pause();
195
+ }
196
+
197
+ // 3. Swap slots and prepare idle slot to preload upcoming scene
198
+ if (nextActiveSlot === 0) {
199
+ setSlot0Index(clampedIdx);
200
+ setSlot1Index(sceneAfterTarget);
201
+ } else {
202
+ setSlot1Index(clampedIdx);
203
+ setSlot0Index(sceneAfterTarget);
204
+ }
205
+
206
+ setActiveSlot(nextActiveSlot);
207
+ },
208
+ [scenes, activeSlot, isPlaying, isMuted, onSceneChange]
209
+ );
210
+
118
211
  const handlePromptSubmit = React.useCallback(
119
212
  (message: PromptInputMessage, event: FormEvent<HTMLFormElement>) => {
120
213
  if (onSubmit) {
@@ -130,30 +223,34 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
130
223
 
131
224
  const handlePrev = React.useCallback(() => {
132
225
  if (!scenes.length) return;
133
- setCurrentSceneIndex((prev) => {
134
- const nextIdx = prev > 0 ? prev - 1 : scenes.length - 1;
135
- onSceneChange?.(nextIdx, scenes[nextIdx]);
136
- return nextIdx;
137
- });
138
- }, [scenes, onSceneChange]);
226
+ const prevIdx = currentSceneIndex > 0 ? currentSceneIndex - 1 : scenes.length - 1;
227
+ goToScene(prevIdx);
228
+ }, [scenes.length, currentSceneIndex, goToScene]);
139
229
 
140
230
  const handleNext = React.useCallback(() => {
141
231
  if (!scenes.length) return;
142
- setCurrentSceneIndex((prev) => {
143
- const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
144
- onSceneChange?.(nextIdx, scenes[nextIdx]);
145
- return nextIdx;
146
- });
147
- }, [scenes, onSceneChange]);
232
+ const nextIdx = currentSceneIndex < scenes.length - 1 ? currentSceneIndex + 1 : 0;
233
+ goToScene(nextIdx);
234
+ }, [scenes.length, currentSceneIndex, goToScene]);
148
235
 
149
236
  const handleSceneEnded = React.useCallback(() => {
150
- if (!scenes.length || !isPlaying) return;
151
- setCurrentSceneIndex((prev) => {
152
- const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
153
- onSceneChange?.(nextIdx, scenes[nextIdx]);
154
- return nextIdx;
155
- });
156
- }, [scenes, onSceneChange, isPlaying]);
237
+ if (!scenes.length || !isPlaying || isTransitioningRef.current) return;
238
+ isTransitioningRef.current = true;
239
+ handleNext();
240
+ }, [scenes.length, isPlaying, handleNext]);
241
+
242
+ const handleTimeUpdate = React.useCallback(
243
+ (e: React.SyntheticEvent<HTMLVideoElement>) => {
244
+ 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
247
+ if (video.duration > 0.1 && video.currentTime >= video.duration - 0.06) {
248
+ isTransitioningRef.current = true;
249
+ handleNext();
250
+ }
251
+ },
252
+ [scenes.length, isPlaying, handleNext]
253
+ );
157
254
 
158
255
  const handleDownload = React.useCallback(async () => {
159
256
  if (onDownload) {
@@ -187,28 +284,84 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
187
284
  }
188
285
  }, [onDownload, downloadUrl, videoUrl, activeScene?.scene_clip_url]);
189
286
 
190
- // Playback & mute synchronization
287
+ // Playback & mute synchronization across dual slots
191
288
  React.useEffect(() => {
192
- const video = videoRef.current;
193
- if (!video) return;
289
+ const activeVideo = activeSlot === 0 ? videoRef0.current : videoRef1.current;
290
+ const idleVideo = activeSlot === 0 ? videoRef1.current : videoRef0.current;
194
291
 
195
- video.muted = isMuted;
292
+ if (idleVideo) {
293
+ idleVideo.pause();
294
+ }
295
+
296
+ if (!activeVideo) return;
297
+
298
+ activeVideo.muted = isMuted;
196
299
 
197
300
  if (isPlaying) {
198
- const playPromise = video.play();
301
+ if (activeVideo.ended) {
302
+ activeVideo.currentTime = 0;
303
+ }
304
+ const playPromise = activeVideo.play();
199
305
  if (playPromise !== undefined) {
200
- playPromise.catch(() => {
201
- if (!isMuted) {
202
- video.muted = true;
203
- setIsMuted(true);
204
- video.play().catch(() => {});
306
+ playPromise.catch((err) => {
307
+ if (err?.name === "NotAllowedError") {
308
+ activeVideo.muted = true;
309
+ activeVideo.play().catch(() => {});
205
310
  }
206
311
  });
207
312
  }
208
313
  } else {
209
- video.pause();
314
+ activeVideo.pause();
210
315
  }
211
- }, [activeScene?.scene_clip_url, isMuted, isPlaying]);
316
+ }, [activeSlot, isPlaying, isMuted]);
317
+
318
+ // Ensure idle video element actively loads and buffers upcoming scene
319
+ React.useEffect(() => {
320
+ if (scenes.length <= 1) return;
321
+ const idleVideo = activeSlot === 0 ? videoRef1.current : videoRef0.current;
322
+ const idleIndex = activeSlot === 0 ? slot1Index : slot0Index;
323
+ const idleUrl = scenes[idleIndex]?.scene_clip_url;
324
+
325
+ if (idleVideo && idleUrl) {
326
+ if (idleVideo.src !== idleUrl) {
327
+ idleVideo.src = idleUrl;
328
+ }
329
+ idleVideo.preload = "auto";
330
+ idleVideo.load();
331
+ }
332
+ }, [activeSlot, slot0Index, slot1Index, scenes]);
333
+
334
+ // Prefetch upcoming clips into browser HTTP cache
335
+ React.useEffect(() => {
336
+ if (!scenes || scenes.length <= 2) return;
337
+ const toPrefetch = [
338
+ (currentSceneIndex + 2) % scenes.length,
339
+ (currentSceneIndex + 3) % scenes.length,
340
+ ];
341
+
342
+ toPrefetch.forEach((idx) => {
343
+ const url = scenes[idx]?.scene_clip_url;
344
+ if (url) {
345
+ const link = document.createElement("link");
346
+ link.rel = "prefetch";
347
+ link.href = url;
348
+ document.head.appendChild(link);
349
+ setTimeout(() => {
350
+ if (link.parentNode) link.parentNode.removeChild(link);
351
+ }, 15000);
352
+ }
353
+ });
354
+ }, [currentSceneIndex, scenes]);
355
+
356
+ React.useEffect(() => {
357
+ if (!scenes || scenes.length === 0) return;
358
+ if (currentSceneIndex >= scenes.length) {
359
+ setCurrentSceneIndex(0);
360
+ setSlot0Index(0);
361
+ setSlot1Index(scenes.length > 1 ? 1 : 0);
362
+ setActiveSlot(0);
363
+ }
364
+ }, [scenes, currentSceneIndex]);
212
365
 
213
366
  // Keyboard navigation & playback controls
214
367
  React.useEffect(() => {
@@ -237,7 +390,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
237
390
  handleBack?.();
238
391
  } else if (e.key === "m" || e.key === "M") {
239
392
  e.preventDefault();
240
- setIsMuted((prev) => !prev);
393
+ toggleMute();
241
394
  }
242
395
  };
243
396
 
@@ -345,7 +498,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
345
498
  size="icon"
346
499
  data-testid="btn-toggle-mute"
347
500
  aria-label={isMuted ? "Unmute audio" : "Mute audio"}
348
- onClick={() => setIsMuted((prev) => !prev)}
501
+ onClick={toggleMute}
349
502
  className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer"
350
503
  >
351
504
  <Icon name={isMuted ? "volume-x" : "volume-2"} className="w-4 h-4" />
@@ -367,20 +520,42 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
367
520
 
368
521
  {/* Main Scene Video Player Stage */}
369
522
  <div className="flex-1 relative flex items-center justify-center bg-black overflow-hidden min-h-0">
523
+ {/* Slot 0 Video */}
370
524
  <video
371
- ref={videoRef}
372
- key={activeScene?.scene_clip_url}
373
- src={activeScene?.scene_clip_url}
374
- autoPlay
525
+ ref={videoRef0}
526
+ src={scenes[slot0Index]?.scene_clip_url}
527
+ preload="auto"
375
528
  playsInline
376
- loop={false}
377
529
  muted={isMuted}
378
- onEnded={handleSceneEnded}
379
- className="w-full h-full object-contain bg-black"
530
+ onEnded={activeSlot === 0 ? handleSceneEnded : undefined}
531
+ onTimeUpdate={activeSlot === 0 ? handleTimeUpdate : undefined}
532
+ data-testid="scene-video-0"
533
+ className={cn(
534
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
535
+ activeSlot === 0 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
536
+ )}
380
537
  />
381
538
 
539
+ {/* Slot 1 Video (preloads next scene in background for gapless playback) */}
540
+ {scenes.length > 1 && (
541
+ <video
542
+ ref={videoRef1}
543
+ src={scenes[slot1Index]?.scene_clip_url}
544
+ preload="auto"
545
+ playsInline
546
+ muted={isMuted}
547
+ onEnded={activeSlot === 1 ? handleSceneEnded : undefined}
548
+ onTimeUpdate={activeSlot === 1 ? handleTimeUpdate : undefined}
549
+ data-testid="scene-video-1"
550
+ className={cn(
551
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
552
+ activeSlot === 1 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
553
+ )}
554
+ />
555
+ )}
556
+
382
557
  {/* Invisible Interactive Tap Zones for Stepping Scenes */}
383
- <div className="absolute inset-x-0 top-[60px] bottom-[70px] flex z-10">
558
+ <div className="absolute inset-x-0 top-[60px] bottom-[70px] flex z-20">
384
559
  <Button
385
560
  variant="ghost"
386
561
  data-testid="btn-prev-scene"
package/dist/index.cjs CHANGED
@@ -14692,7 +14692,7 @@ var ScenePlayer = React3__namespace.forwardRef(
14692
14692
  "onSceneChange",
14693
14693
  "className"
14694
14694
  ]);
14695
- var _a2;
14695
+ var _a2, _b2, _c;
14696
14696
  const handleBack = onBack || onClose;
14697
14697
  const [currentSceneIndex, setCurrentSceneIndex] = React3__namespace.useState(() => {
14698
14698
  if (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
@@ -14700,6 +14700,23 @@ var ScenePlayer = React3__namespace.forwardRef(
14700
14700
  }
14701
14701
  return 0;
14702
14702
  });
14703
+ const [activeSlot, setActiveSlot] = React3__namespace.useState(0);
14704
+ const [slot0Index, setSlot0Index] = React3__namespace.useState(() => {
14705
+ if (initialSceneIndex >= 0 && initialSceneIndex < scenes.length) {
14706
+ return initialSceneIndex;
14707
+ }
14708
+ return 0;
14709
+ });
14710
+ const [slot1Index, setSlot1Index] = React3__namespace.useState(() => {
14711
+ if (scenes.length > 1) {
14712
+ const init = initialSceneIndex >= 0 && initialSceneIndex < scenes.length ? initialSceneIndex : 0;
14713
+ return (init + 1) % scenes.length;
14714
+ }
14715
+ return 0;
14716
+ });
14717
+ const videoRef0 = React3__namespace.useRef(null);
14718
+ const videoRef1 = React3__namespace.useRef(null);
14719
+ const isTransitioningRef = React3__namespace.useRef(false);
14703
14720
  const [isPlayingState, setIsPlayingState] = React3__namespace.useState(isPlayingProp != null ? isPlayingProp : true);
14704
14721
  const isPlaying = isPlayingProp !== void 0 ? isPlayingProp : isPlayingState;
14705
14722
  const setIsPlaying = React3__namespace.useCallback(
@@ -14714,8 +14731,71 @@ var ScenePlayer = React3__namespace.forwardRef(
14714
14731
  setIsPlaying((prev) => !prev);
14715
14732
  }, [setIsPlaying]);
14716
14733
  const [isMuted, setIsMuted] = React3__namespace.useState(initialMuted);
14717
- const videoRef = React3__namespace.useRef(null);
14734
+ const toggleMute = React3__namespace.useCallback(() => {
14735
+ setIsMuted((prev) => {
14736
+ const next = !prev;
14737
+ if (videoRef0.current) videoRef0.current.muted = next;
14738
+ if (videoRef1.current) videoRef1.current.muted = next;
14739
+ return next;
14740
+ });
14741
+ }, []);
14718
14742
  const activeScene = scenes[currentSceneIndex] || scenes[0];
14743
+ const goToScene = React3__namespace.useCallback(
14744
+ (targetIdx) => {
14745
+ if (!scenes.length) return;
14746
+ const clampedIdx = (targetIdx + scenes.length) % scenes.length;
14747
+ onSceneChange == null ? void 0 : onSceneChange(clampedIdx, scenes[clampedIdx]);
14748
+ setCurrentSceneIndex(clampedIdx);
14749
+ isTransitioningRef.current = false;
14750
+ if (scenes.length <= 1) {
14751
+ setSlot0Index(clampedIdx);
14752
+ const v0 = videoRef0.current;
14753
+ if (v0) {
14754
+ v0.currentTime = 0;
14755
+ v0.muted = isMuted;
14756
+ if (isPlaying) {
14757
+ v0.play().catch((err) => {
14758
+ if ((err == null ? void 0 : err.name) === "NotAllowedError") {
14759
+ v0.muted = true;
14760
+ v0.play().catch(() => {
14761
+ });
14762
+ }
14763
+ });
14764
+ }
14765
+ }
14766
+ return;
14767
+ }
14768
+ const nextActiveSlot = activeSlot === 0 ? 1 : 0;
14769
+ const nextVideo = nextActiveSlot === 0 ? videoRef0.current : videoRef1.current;
14770
+ const currentVideo = activeSlot === 0 ? videoRef0.current : videoRef1.current;
14771
+ const sceneAfterTarget = (clampedIdx + 1) % scenes.length;
14772
+ if (nextVideo) {
14773
+ nextVideo.currentTime = 0;
14774
+ nextVideo.muted = isMuted;
14775
+ if (isPlaying) {
14776
+ nextVideo.play().catch((err) => {
14777
+ if ((err == null ? void 0 : err.name) === "NotAllowedError") {
14778
+ nextVideo.muted = true;
14779
+ nextVideo.play().catch(() => {
14780
+ });
14781
+ }
14782
+ });
14783
+ }
14784
+ }
14785
+ if (currentVideo) {
14786
+ currentVideo.pause();
14787
+ }
14788
+ if (nextActiveSlot === 0) {
14789
+ setSlot0Index(clampedIdx);
14790
+ setSlot1Index(sceneAfterTarget);
14791
+ } else {
14792
+ setSlot1Index(clampedIdx);
14793
+ setSlot0Index(sceneAfterTarget);
14794
+ }
14795
+ setActiveSlot(nextActiveSlot);
14796
+ },
14797
+ [scenes, activeSlot, isPlaying, isMuted, onSceneChange]
14798
+ );
14719
14799
  const handlePromptSubmit = React3__namespace.useCallback(
14720
14800
  (message, event) => {
14721
14801
  if (onSubmit) {
@@ -14730,30 +14810,32 @@ var ScenePlayer = React3__namespace.forwardRef(
14730
14810
  );
14731
14811
  const handlePrev = React3__namespace.useCallback(() => {
14732
14812
  if (!scenes.length) return;
14733
- setCurrentSceneIndex((prev) => {
14734
- const nextIdx = prev > 0 ? prev - 1 : scenes.length - 1;
14735
- onSceneChange == null ? void 0 : onSceneChange(nextIdx, scenes[nextIdx]);
14736
- return nextIdx;
14737
- });
14738
- }, [scenes, onSceneChange]);
14813
+ const prevIdx = currentSceneIndex > 0 ? currentSceneIndex - 1 : scenes.length - 1;
14814
+ goToScene(prevIdx);
14815
+ }, [scenes.length, currentSceneIndex, goToScene]);
14739
14816
  const handleNext = React3__namespace.useCallback(() => {
14740
14817
  if (!scenes.length) return;
14741
- setCurrentSceneIndex((prev) => {
14742
- const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
14743
- onSceneChange == null ? void 0 : onSceneChange(nextIdx, scenes[nextIdx]);
14744
- return nextIdx;
14745
- });
14746
- }, [scenes, onSceneChange]);
14818
+ const nextIdx = currentSceneIndex < scenes.length - 1 ? currentSceneIndex + 1 : 0;
14819
+ goToScene(nextIdx);
14820
+ }, [scenes.length, currentSceneIndex, goToScene]);
14747
14821
  const handleSceneEnded = React3__namespace.useCallback(() => {
14748
- if (!scenes.length || !isPlaying) return;
14749
- setCurrentSceneIndex((prev) => {
14750
- const nextIdx = prev < scenes.length - 1 ? prev + 1 : 0;
14751
- onSceneChange == null ? void 0 : onSceneChange(nextIdx, scenes[nextIdx]);
14752
- return nextIdx;
14753
- });
14754
- }, [scenes, onSceneChange, isPlaying]);
14822
+ if (!scenes.length || !isPlaying || isTransitioningRef.current) return;
14823
+ isTransitioningRef.current = true;
14824
+ handleNext();
14825
+ }, [scenes.length, isPlaying, handleNext]);
14826
+ const handleTimeUpdate = React3__namespace.useCallback(
14827
+ (e) => {
14828
+ const video = e.currentTarget;
14829
+ if (!scenes.length || !isPlaying || isTransitioningRef.current) return;
14830
+ if (video.duration > 0.1 && video.currentTime >= video.duration - 0.06) {
14831
+ isTransitioningRef.current = true;
14832
+ handleNext();
14833
+ }
14834
+ },
14835
+ [scenes.length, isPlaying, handleNext]
14836
+ );
14755
14837
  const handleDownload = React3__namespace.useCallback(async () => {
14756
- var _a3, _b2;
14838
+ var _a3, _b3;
14757
14839
  if (onDownload) {
14758
14840
  onDownload();
14759
14841
  return;
@@ -14775,7 +14857,7 @@ var ScenePlayer = React3__namespace.forwardRef(
14775
14857
  } catch (e) {
14776
14858
  const link = document.createElement("a");
14777
14859
  link.href = targetUrl;
14778
- link.download = ((_b2 = targetUrl.split("/").pop()) == null ? void 0 : _b2.split("?")[0]) || "video.mp4";
14860
+ link.download = ((_b3 = targetUrl.split("/").pop()) == null ? void 0 : _b3.split("?")[0]) || "video.mp4";
14779
14861
  link.target = "_blank";
14780
14862
  link.rel = "noopener noreferrer";
14781
14863
  document.body.appendChild(link);
@@ -14784,25 +14866,74 @@ var ScenePlayer = React3__namespace.forwardRef(
14784
14866
  }
14785
14867
  }, [onDownload, downloadUrl, videoUrl, activeScene == null ? void 0 : activeScene.scene_clip_url]);
14786
14868
  React3__namespace.useEffect(() => {
14787
- const video = videoRef.current;
14788
- if (!video) return;
14789
- video.muted = isMuted;
14869
+ const activeVideo = activeSlot === 0 ? videoRef0.current : videoRef1.current;
14870
+ const idleVideo = activeSlot === 0 ? videoRef1.current : videoRef0.current;
14871
+ if (idleVideo) {
14872
+ idleVideo.pause();
14873
+ }
14874
+ if (!activeVideo) return;
14875
+ activeVideo.muted = isMuted;
14790
14876
  if (isPlaying) {
14791
- const playPromise = video.play();
14877
+ if (activeVideo.ended) {
14878
+ activeVideo.currentTime = 0;
14879
+ }
14880
+ const playPromise = activeVideo.play();
14792
14881
  if (playPromise !== void 0) {
14793
- playPromise.catch(() => {
14794
- if (!isMuted) {
14795
- video.muted = true;
14796
- setIsMuted(true);
14797
- video.play().catch(() => {
14882
+ playPromise.catch((err) => {
14883
+ if ((err == null ? void 0 : err.name) === "NotAllowedError") {
14884
+ activeVideo.muted = true;
14885
+ activeVideo.play().catch(() => {
14798
14886
  });
14799
14887
  }
14800
14888
  });
14801
14889
  }
14802
14890
  } else {
14803
- video.pause();
14891
+ activeVideo.pause();
14892
+ }
14893
+ }, [activeSlot, isPlaying, isMuted]);
14894
+ React3__namespace.useEffect(() => {
14895
+ var _a3;
14896
+ if (scenes.length <= 1) return;
14897
+ const idleVideo = activeSlot === 0 ? videoRef1.current : videoRef0.current;
14898
+ const idleIndex = activeSlot === 0 ? slot1Index : slot0Index;
14899
+ const idleUrl = (_a3 = scenes[idleIndex]) == null ? void 0 : _a3.scene_clip_url;
14900
+ if (idleVideo && idleUrl) {
14901
+ if (idleVideo.src !== idleUrl) {
14902
+ idleVideo.src = idleUrl;
14903
+ }
14904
+ idleVideo.preload = "auto";
14905
+ idleVideo.load();
14906
+ }
14907
+ }, [activeSlot, slot0Index, slot1Index, scenes]);
14908
+ React3__namespace.useEffect(() => {
14909
+ if (!scenes || scenes.length <= 2) return;
14910
+ const toPrefetch = [
14911
+ (currentSceneIndex + 2) % scenes.length,
14912
+ (currentSceneIndex + 3) % scenes.length
14913
+ ];
14914
+ toPrefetch.forEach((idx) => {
14915
+ var _a3;
14916
+ const url = (_a3 = scenes[idx]) == null ? void 0 : _a3.scene_clip_url;
14917
+ if (url) {
14918
+ const link = document.createElement("link");
14919
+ link.rel = "prefetch";
14920
+ link.href = url;
14921
+ document.head.appendChild(link);
14922
+ setTimeout(() => {
14923
+ if (link.parentNode) link.parentNode.removeChild(link);
14924
+ }, 15e3);
14925
+ }
14926
+ });
14927
+ }, [currentSceneIndex, scenes]);
14928
+ React3__namespace.useEffect(() => {
14929
+ if (!scenes || scenes.length === 0) return;
14930
+ if (currentSceneIndex >= scenes.length) {
14931
+ setCurrentSceneIndex(0);
14932
+ setSlot0Index(0);
14933
+ setSlot1Index(scenes.length > 1 ? 1 : 0);
14934
+ setActiveSlot(0);
14804
14935
  }
14805
- }, [activeScene == null ? void 0 : activeScene.scene_clip_url, isMuted, isPlaying]);
14936
+ }, [scenes, currentSceneIndex]);
14806
14937
  React3__namespace.useEffect(() => {
14807
14938
  const handleKeyDown = (e) => {
14808
14939
  const target = e.target;
@@ -14823,7 +14954,7 @@ var ScenePlayer = React3__namespace.forwardRef(
14823
14954
  handleBack == null ? void 0 : handleBack();
14824
14955
  } else if (e.key === "m" || e.key === "M") {
14825
14956
  e.preventDefault();
14826
- setIsMuted((prev) => !prev);
14957
+ toggleMute();
14827
14958
  }
14828
14959
  };
14829
14960
  window.addEventListener("keydown", handleKeyDown);
@@ -14928,7 +15059,7 @@ var ScenePlayer = React3__namespace.forwardRef(
14928
15059
  size: "icon",
14929
15060
  "data-testid": "btn-toggle-mute",
14930
15061
  "aria-label": isMuted ? "Unmute audio" : "Mute audio",
14931
- onClick: () => setIsMuted((prev) => !prev),
15062
+ onClick: toggleMute,
14932
15063
  className: "h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer",
14933
15064
  children: /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: isMuted ? "volume-x" : "volume-2", className: "w-4 h-4" })
14934
15065
  }
@@ -14951,18 +15082,38 @@ var ScenePlayer = React3__namespace.forwardRef(
14951
15082
  /* @__PURE__ */ jsxRuntime.jsx(
14952
15083
  "video",
14953
15084
  {
14954
- ref: videoRef,
14955
- src: activeScene == null ? void 0 : activeScene.scene_clip_url,
14956
- autoPlay: true,
15085
+ ref: videoRef0,
15086
+ src: (_b2 = scenes[slot0Index]) == null ? void 0 : _b2.scene_clip_url,
15087
+ preload: "auto",
14957
15088
  playsInline: true,
14958
- loop: false,
14959
15089
  muted: isMuted,
14960
- onEnded: handleSceneEnded,
14961
- className: "w-full h-full object-contain bg-black"
14962
- },
14963
- activeScene == null ? void 0 : activeScene.scene_clip_url
15090
+ onEnded: activeSlot === 0 ? handleSceneEnded : void 0,
15091
+ onTimeUpdate: activeSlot === 0 ? handleTimeUpdate : void 0,
15092
+ "data-testid": "scene-video-0",
15093
+ className: cn(
15094
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
15095
+ activeSlot === 0 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
15096
+ )
15097
+ }
15098
+ ),
15099
+ scenes.length > 1 && /* @__PURE__ */ jsxRuntime.jsx(
15100
+ "video",
15101
+ {
15102
+ ref: videoRef1,
15103
+ src: (_c = scenes[slot1Index]) == null ? void 0 : _c.scene_clip_url,
15104
+ preload: "auto",
15105
+ playsInline: true,
15106
+ muted: isMuted,
15107
+ onEnded: activeSlot === 1 ? handleSceneEnded : void 0,
15108
+ onTimeUpdate: activeSlot === 1 ? handleTimeUpdate : void 0,
15109
+ "data-testid": "scene-video-1",
15110
+ className: cn(
15111
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
15112
+ activeSlot === 1 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
15113
+ )
15114
+ }
14964
15115
  ),
14965
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-x-0 top-[60px] bottom-[70px] flex z-10", children: [
15116
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "absolute inset-x-0 top-[60px] bottom-[70px] flex z-20", children: [
14966
15117
  /* @__PURE__ */ jsxRuntime.jsx(
14967
15118
  Button2,
14968
15119
  {