ai-design-system 0.1.106 → 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.
@@ -113,6 +113,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
113
113
 
114
114
  const videoRef0 = React.useRef<HTMLVideoElement | null>(null);
115
115
  const videoRef1 = React.useRef<HTMLVideoElement | null>(null);
116
+ const isTransitioningRef = React.useRef<boolean>(false);
116
117
 
117
118
  const [isPlayingState, setIsPlayingState] = React.useState<boolean>(isPlayingProp ?? true);
118
119
  const isPlaying = isPlayingProp !== undefined ? isPlayingProp : isPlayingState;
@@ -132,6 +133,15 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
132
133
 
133
134
  const [isMuted, setIsMuted] = React.useState<boolean>(initialMuted);
134
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
+ }, []);
144
+
135
145
  const activeScene = scenes[currentSceneIndex] || scenes[0];
136
146
 
137
147
  const goToScene = React.useCallback(
@@ -140,24 +150,51 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
140
150
  const clampedIdx = (targetIdx + scenes.length) % scenes.length;
141
151
  onSceneChange?.(clampedIdx, scenes[clampedIdx]);
142
152
  setCurrentSceneIndex(clampedIdx);
153
+ isTransitioningRef.current = false;
143
154
 
144
155
  if (scenes.length <= 1) {
145
156
  setSlot0Index(clampedIdx);
146
157
  const v0 = videoRef0.current;
147
158
  if (v0) {
148
159
  v0.currentTime = 0;
149
- if (isPlaying) v0.play().catch(() => {});
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
+ }
150
169
  }
151
170
  return;
152
171
  }
153
172
 
154
173
  const nextActiveSlot: 0 | 1 = activeSlot === 0 ? 1 : 0;
155
- const targetVideo = nextActiveSlot === 0 ? videoRef0.current : videoRef1.current;
156
- if (targetVideo) {
157
- targetVideo.currentTime = 0;
158
- }
174
+ const nextVideo = nextActiveSlot === 0 ? videoRef0.current : videoRef1.current;
175
+ const currentVideo = activeSlot === 0 ? videoRef0.current : videoRef1.current;
159
176
  const sceneAfterTarget = (clampedIdx + 1) % scenes.length;
160
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
161
198
  if (nextActiveSlot === 0) {
162
199
  setSlot0Index(clampedIdx);
163
200
  setSlot1Index(sceneAfterTarget);
@@ -168,7 +205,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
168
205
 
169
206
  setActiveSlot(nextActiveSlot);
170
207
  },
171
- [scenes, activeSlot, isPlaying, onSceneChange]
208
+ [scenes, activeSlot, isPlaying, isMuted, onSceneChange]
172
209
  );
173
210
 
174
211
  const handlePromptSubmit = React.useCallback(
@@ -197,10 +234,24 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
197
234
  }, [scenes.length, currentSceneIndex, goToScene]);
198
235
 
199
236
  const handleSceneEnded = React.useCallback(() => {
200
- if (!scenes.length || !isPlaying) return;
237
+ if (!scenes.length || !isPlaying || isTransitioningRef.current) return;
238
+ isTransitioningRef.current = true;
201
239
  handleNext();
202
240
  }, [scenes.length, isPlaying, handleNext]);
203
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
+ );
254
+
204
255
  const handleDownload = React.useCallback(async () => {
205
256
  if (onDownload) {
206
257
  onDownload();
@@ -240,7 +291,6 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
240
291
 
241
292
  if (idleVideo) {
242
293
  idleVideo.pause();
243
- idleVideo.muted = true;
244
294
  }
245
295
 
246
296
  if (!activeVideo) return;
@@ -253,10 +303,9 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
253
303
  }
254
304
  const playPromise = activeVideo.play();
255
305
  if (playPromise !== undefined) {
256
- playPromise.catch(() => {
257
- if (!isMuted) {
306
+ playPromise.catch((err) => {
307
+ if (err?.name === "NotAllowedError") {
258
308
  activeVideo.muted = true;
259
- setIsMuted(true);
260
309
  activeVideo.play().catch(() => {});
261
310
  }
262
311
  });
@@ -264,7 +313,45 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
264
313
  } else {
265
314
  activeVideo.pause();
266
315
  }
267
- }, [activeSlot, isPlaying, isMuted, slot0Index, slot1Index]);
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]);
268
355
 
269
356
  React.useEffect(() => {
270
357
  if (!scenes || scenes.length === 0) return;
@@ -303,7 +390,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
303
390
  handleBack?.();
304
391
  } else if (e.key === "m" || e.key === "M") {
305
392
  e.preventDefault();
306
- setIsMuted((prev) => !prev);
393
+ toggleMute();
307
394
  }
308
395
  };
309
396
 
@@ -411,7 +498,7 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
411
498
  size="icon"
412
499
  data-testid="btn-toggle-mute"
413
500
  aria-label={isMuted ? "Unmute audio" : "Mute audio"}
414
- onClick={() => setIsMuted((prev) => !prev)}
501
+ onClick={toggleMute}
415
502
  className="h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer"
416
503
  >
417
504
  <Icon name={isMuted ? "volume-x" : "volume-2"} className="w-4 h-4" />
@@ -439,11 +526,12 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
439
526
  src={scenes[slot0Index]?.scene_clip_url}
440
527
  preload="auto"
441
528
  playsInline
442
- muted={activeSlot === 0 ? isMuted : true}
529
+ muted={isMuted}
443
530
  onEnded={activeSlot === 0 ? handleSceneEnded : undefined}
531
+ onTimeUpdate={activeSlot === 0 ? handleTimeUpdate : undefined}
444
532
  data-testid="scene-video-0"
445
533
  className={cn(
446
- "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-150",
534
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
447
535
  activeSlot === 0 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
448
536
  )}
449
537
  />
@@ -455,11 +543,12 @@ export const ScenePlayer = React.forwardRef<HTMLDivElement, ScenePlayerProps>(
455
543
  src={scenes[slot1Index]?.scene_clip_url}
456
544
  preload="auto"
457
545
  playsInline
458
- muted={activeSlot === 1 ? isMuted : true}
546
+ muted={isMuted}
459
547
  onEnded={activeSlot === 1 ? handleSceneEnded : undefined}
548
+ onTimeUpdate={activeSlot === 1 ? handleTimeUpdate : undefined}
460
549
  data-testid="scene-video-1"
461
550
  className={cn(
462
- "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-150",
551
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
463
552
  activeSlot === 1 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
464
553
  )}
465
554
  />
package/dist/index.cjs CHANGED
@@ -14716,6 +14716,7 @@ var ScenePlayer = React3__namespace.forwardRef(
14716
14716
  });
14717
14717
  const videoRef0 = React3__namespace.useRef(null);
14718
14718
  const videoRef1 = React3__namespace.useRef(null);
14719
+ const isTransitioningRef = React3__namespace.useRef(false);
14719
14720
  const [isPlayingState, setIsPlayingState] = React3__namespace.useState(isPlayingProp != null ? isPlayingProp : true);
14720
14721
  const isPlaying = isPlayingProp !== void 0 ? isPlayingProp : isPlayingState;
14721
14722
  const setIsPlaying = React3__namespace.useCallback(
@@ -14730,6 +14731,14 @@ var ScenePlayer = React3__namespace.forwardRef(
14730
14731
  setIsPlaying((prev) => !prev);
14731
14732
  }, [setIsPlaying]);
14732
14733
  const [isMuted, setIsMuted] = React3__namespace.useState(initialMuted);
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
+ }, []);
14733
14742
  const activeScene = scenes[currentSceneIndex] || scenes[0];
14734
14743
  const goToScene = React3__namespace.useCallback(
14735
14744
  (targetIdx) => {
@@ -14737,22 +14746,45 @@ var ScenePlayer = React3__namespace.forwardRef(
14737
14746
  const clampedIdx = (targetIdx + scenes.length) % scenes.length;
14738
14747
  onSceneChange == null ? void 0 : onSceneChange(clampedIdx, scenes[clampedIdx]);
14739
14748
  setCurrentSceneIndex(clampedIdx);
14749
+ isTransitioningRef.current = false;
14740
14750
  if (scenes.length <= 1) {
14741
14751
  setSlot0Index(clampedIdx);
14742
14752
  const v0 = videoRef0.current;
14743
14753
  if (v0) {
14744
14754
  v0.currentTime = 0;
14745
- if (isPlaying) v0.play().catch(() => {
14746
- });
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
+ }
14747
14765
  }
14748
14766
  return;
14749
14767
  }
14750
14768
  const nextActiveSlot = activeSlot === 0 ? 1 : 0;
14751
- const targetVideo = nextActiveSlot === 0 ? videoRef0.current : videoRef1.current;
14752
- if (targetVideo) {
14753
- targetVideo.currentTime = 0;
14754
- }
14769
+ const nextVideo = nextActiveSlot === 0 ? videoRef0.current : videoRef1.current;
14770
+ const currentVideo = activeSlot === 0 ? videoRef0.current : videoRef1.current;
14755
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
+ }
14756
14788
  if (nextActiveSlot === 0) {
14757
14789
  setSlot0Index(clampedIdx);
14758
14790
  setSlot1Index(sceneAfterTarget);
@@ -14762,7 +14794,7 @@ var ScenePlayer = React3__namespace.forwardRef(
14762
14794
  }
14763
14795
  setActiveSlot(nextActiveSlot);
14764
14796
  },
14765
- [scenes, activeSlot, isPlaying, onSceneChange]
14797
+ [scenes, activeSlot, isPlaying, isMuted, onSceneChange]
14766
14798
  );
14767
14799
  const handlePromptSubmit = React3__namespace.useCallback(
14768
14800
  (message, event) => {
@@ -14787,9 +14819,21 @@ var ScenePlayer = React3__namespace.forwardRef(
14787
14819
  goToScene(nextIdx);
14788
14820
  }, [scenes.length, currentSceneIndex, goToScene]);
14789
14821
  const handleSceneEnded = React3__namespace.useCallback(() => {
14790
- if (!scenes.length || !isPlaying) return;
14822
+ if (!scenes.length || !isPlaying || isTransitioningRef.current) return;
14823
+ isTransitioningRef.current = true;
14791
14824
  handleNext();
14792
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
+ );
14793
14837
  const handleDownload = React3__namespace.useCallback(async () => {
14794
14838
  var _a3, _b3;
14795
14839
  if (onDownload) {
@@ -14826,7 +14870,6 @@ var ScenePlayer = React3__namespace.forwardRef(
14826
14870
  const idleVideo = activeSlot === 0 ? videoRef1.current : videoRef0.current;
14827
14871
  if (idleVideo) {
14828
14872
  idleVideo.pause();
14829
- idleVideo.muted = true;
14830
14873
  }
14831
14874
  if (!activeVideo) return;
14832
14875
  activeVideo.muted = isMuted;
@@ -14836,10 +14879,9 @@ var ScenePlayer = React3__namespace.forwardRef(
14836
14879
  }
14837
14880
  const playPromise = activeVideo.play();
14838
14881
  if (playPromise !== void 0) {
14839
- playPromise.catch(() => {
14840
- if (!isMuted) {
14882
+ playPromise.catch((err) => {
14883
+ if ((err == null ? void 0 : err.name) === "NotAllowedError") {
14841
14884
  activeVideo.muted = true;
14842
- setIsMuted(true);
14843
14885
  activeVideo.play().catch(() => {
14844
14886
  });
14845
14887
  }
@@ -14848,7 +14890,41 @@ var ScenePlayer = React3__namespace.forwardRef(
14848
14890
  } else {
14849
14891
  activeVideo.pause();
14850
14892
  }
14851
- }, [activeSlot, isPlaying, isMuted, slot0Index, slot1Index]);
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]);
14852
14928
  React3__namespace.useEffect(() => {
14853
14929
  if (!scenes || scenes.length === 0) return;
14854
14930
  if (currentSceneIndex >= scenes.length) {
@@ -14878,7 +14954,7 @@ var ScenePlayer = React3__namespace.forwardRef(
14878
14954
  handleBack == null ? void 0 : handleBack();
14879
14955
  } else if (e.key === "m" || e.key === "M") {
14880
14956
  e.preventDefault();
14881
- setIsMuted((prev) => !prev);
14957
+ toggleMute();
14882
14958
  }
14883
14959
  };
14884
14960
  window.addEventListener("keydown", handleKeyDown);
@@ -14983,7 +15059,7 @@ var ScenePlayer = React3__namespace.forwardRef(
14983
15059
  size: "icon",
14984
15060
  "data-testid": "btn-toggle-mute",
14985
15061
  "aria-label": isMuted ? "Unmute audio" : "Mute audio",
14986
- onClick: () => setIsMuted((prev) => !prev),
15062
+ onClick: toggleMute,
14987
15063
  className: "h-8 w-8 rounded-full bg-black/40 hover:bg-white/20 text-white p-0 cursor-pointer",
14988
15064
  children: /* @__PURE__ */ jsxRuntime.jsx(Icon, { name: isMuted ? "volume-x" : "volume-2", className: "w-4 h-4" })
14989
15065
  }
@@ -15010,11 +15086,12 @@ var ScenePlayer = React3__namespace.forwardRef(
15010
15086
  src: (_b2 = scenes[slot0Index]) == null ? void 0 : _b2.scene_clip_url,
15011
15087
  preload: "auto",
15012
15088
  playsInline: true,
15013
- muted: activeSlot === 0 ? isMuted : true,
15089
+ muted: isMuted,
15014
15090
  onEnded: activeSlot === 0 ? handleSceneEnded : void 0,
15091
+ onTimeUpdate: activeSlot === 0 ? handleTimeUpdate : void 0,
15015
15092
  "data-testid": "scene-video-0",
15016
15093
  className: cn(
15017
- "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-150",
15094
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
15018
15095
  activeSlot === 0 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
15019
15096
  )
15020
15097
  }
@@ -15026,11 +15103,12 @@ var ScenePlayer = React3__namespace.forwardRef(
15026
15103
  src: (_c = scenes[slot1Index]) == null ? void 0 : _c.scene_clip_url,
15027
15104
  preload: "auto",
15028
15105
  playsInline: true,
15029
- muted: activeSlot === 1 ? isMuted : true,
15106
+ muted: isMuted,
15030
15107
  onEnded: activeSlot === 1 ? handleSceneEnded : void 0,
15108
+ onTimeUpdate: activeSlot === 1 ? handleTimeUpdate : void 0,
15031
15109
  "data-testid": "scene-video-1",
15032
15110
  className: cn(
15033
- "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-150",
15111
+ "w-full h-full object-contain bg-black absolute inset-0 transition-opacity duration-75",
15034
15112
  activeSlot === 1 ? "opacity-100 z-10" : "opacity-0 pointer-events-none z-0"
15035
15113
  )
15036
15114
  }