diff --git a/packages/studio/src/components/nle/NLELayout.test.ts b/packages/studio/src/components/nle/NLELayout.test.ts new file mode 100644 index 0000000000..c0aa410bbb --- /dev/null +++ b/packages/studio/src/components/nle/NLELayout.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { shouldDisableTimelineWhileCompositionLoading } from "./NLELayout"; + +describe("timeline loading disable state", () => { + it("disables the timeline while the composition loading overlay is visible", () => { + expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true); + }); + + it("reenables the timeline after composition loading finishes", () => { + expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false); + }); +}); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 0e6522f999..4e576aa5e5 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -69,6 +69,10 @@ const MIN_TIMELINE_H = 100; const DEFAULT_TIMELINE_H = 220; const MIN_PREVIEW_H = 120; +export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean { + return compositionLoading; +} + export const NLELayout = memo(function NLELayout({ projectId, portrait, @@ -214,6 +218,8 @@ export const NLELayout = memo(function NLELayout({ // Resizable timeline height const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H); + const [compositionLoading, setCompositionLoading] = useState(true); + const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading); const isTimelineVisible = timelineVisible ?? true; const isDragging = useRef(false); const containerRef = useRef(null); @@ -327,23 +333,31 @@ export const NLELayout = memo(function NLELayout({ }, [activeCompositionPath, projectId, updateCompositionStack]); // Resize divider handlers - const handleDividerPointerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault(); - isDragging.current = true; - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, []); + const handleDividerPointerDown = useCallback( + (e: React.PointerEvent) => { + if (timelineDisabled) return; + e.preventDefault(); + isDragging.current = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [timelineDisabled], + ); - const handleDividerPointerMove = useCallback((e: React.PointerEvent) => { - if (!isDragging.current || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const mouseY = e.clientY - rect.top; - const containerH = rect.height; - const newTimelineH = Math.max( - MIN_TIMELINE_H, - Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY), - ); - setTimelineH(newTimelineH); - }, []); + const handleDividerPointerMove = useCallback( + (e: React.PointerEvent) => { + if (timelineDisabled) return; + if (!isDragging.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const mouseY = e.clientY - rect.top; + const containerH = rect.height; + const newTimelineH = Math.max( + MIN_TIMELINE_H, + Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY), + ); + setTimelineH(newTimelineH); + }, + [timelineDisabled], + ); const handleDividerPointerUp = useCallback(() => { isDragging.current = false; @@ -374,6 +388,7 @@ export const NLELayout = memo(function NLELayout({ projectId={projectId} iframeRef={iframeRef} onIframeLoad={onIframeLoad} + onCompositionLoadingChange={setCompositionLoading} portrait={portrait} directUrl={directUrl} refreshKey={refreshKey} @@ -388,7 +403,7 @@ export const NLELayout = memo(function NLELayout({ onNavigate={handleNavigateComposition} /> )} - + @@ -406,13 +421,18 @@ export const NLELayout = memo(function NLELayout({ {/* Timeline section — fixed height, resizable */} -
+
{/* Timeline tracks */}
{ if ((e.target as HTMLElement).closest("[data-clip]")) return; + if (timelineDisabled) return; if (compositionStack.length > 1) { updateCompositionStack((prev) => prev.slice(0, -1)); } @@ -435,9 +455,20 @@ export const NLELayout = memo(function NLELayout({ layerChildCounts={timelineLayerChildCounts} thumbnailedElementIds={thumbnailedTimelineElementIds} onToggleElementThumbnail={onToggleTimelineElementThumbnail} + disabled={timelineDisabled} />
{timelineFooter &&
{timelineFooter}
} + {timelineDisabled && ( + ) : onToggleTimeline ? ( diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index ebe3dc2828..e8789ba9ac 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -5,6 +5,7 @@ interface NLEPreviewProps { projectId: string; iframeRef: Ref; onIframeLoad: () => void; + onCompositionLoadingChange?: (loading: boolean) => void; portrait?: boolean; directUrl?: string; refreshKey?: number; @@ -36,6 +37,7 @@ export const NLEPreview = memo(function NLEPreview({ projectId, iframeRef, onIframeLoad, + onCompositionLoadingChange, portrait, directUrl, refreshKey, @@ -88,6 +90,7 @@ export const NLEPreview = memo(function NLEPreview({ projectId={directUrl ? undefined : projectId} directUrl={directUrl} onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad} + onCompositionLoadingChange={onCompositionLoadingChange} portrait={portrait} style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined} /> diff --git a/packages/studio/src/player/components/Player.test.ts b/packages/studio/src/player/components/Player.test.ts new file mode 100644 index 0000000000..d4879ec1fd --- /dev/null +++ b/packages/studio/src/player/components/Player.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { shouldShowCompositionLoadingOverlay } from "./Player"; + +describe("composition loading overlay", () => { + it("shows while the composition is loading", () => { + expect(shouldShowCompositionLoadingOverlay(true)).toBe(true); + }); + + it("hides after the composition is ready", () => { + expect(shouldShowCompositionLoadingOverlay(false)).toBe(false); + }); +}); diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 70099c63eb..920d442294 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -10,6 +10,7 @@ interface PlayerProps { projectId?: string; directUrl?: string; onLoad: () => void; + onCompositionLoadingChange?: (loading: boolean) => void; portrait?: boolean; style?: React.CSSProperties; } @@ -31,6 +32,10 @@ function getShaderTransitionLoading(event: Event): boolean | null { return state.loading === true && state.ready !== true; } +export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean { + return compositionLoading; +} + function enableInteractiveIframe(player: HyperframesPlayerElement): void { const root = player.shadowRoot; if (!root) return; @@ -84,7 +89,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool * timeline probing, and DOM inspection. */ export const Player = forwardRef( - ({ projectId, directUrl, onLoad, portrait, style }, ref) => { + ({ projectId, directUrl, onLoad, onCompositionLoadingChange, portrait, style }, ref) => { const containerRef = useRef(null); const loadCountRef = useRef(0); const assetPollRef = useRef | null>(null); @@ -93,6 +98,7 @@ export const Player = forwardRef( const [assetOverlayVisible, setAssetOverlayVisible] = useState(false); const [assetOverlayFading, setAssetOverlayFading] = useState(false); const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false); + const [compositionLoading, setCompositionLoading] = useState(true); useMountEffect(() => { const container = containerRef.current; @@ -138,10 +144,20 @@ export const Player = forwardRef( }; player.addEventListener("shadertransitionstate", handleShaderTransitionState); + const handleReady = () => { + setCompositionLoading(false); + }; + const handleError = () => { + setCompositionLoading(false); + }; + player.addEventListener("ready", handleReady); + player.addEventListener("error", handleError); + // Forward the iframe's native load event to the studio's onIframeLoad. const handleLoad = () => { loadCountRef.current++; setShaderTransitionLoading(false); + setCompositionLoading(true); // Reveal animation on reload (hot-reload, composition switch) if (loadCountRef.current > 1) { container.classList.remove("preview-revealing"); @@ -192,6 +208,8 @@ export const Player = forwardRef( iframe.removeEventListener("load", handleLoad); player.removeEventListener("click", preventToggle, { capture: true }); player.removeEventListener("shadertransitionstate", handleShaderTransitionState); + player.removeEventListener("ready", handleReady); + player.removeEventListener("error", handleError); if (assetPollRef.current) clearInterval(assetPollRef.current); assetPollRef.current = null; container.removeChild(player); @@ -237,7 +255,13 @@ export const Player = forwardRef( }; }, [assetsLoading]); - const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading; + const showCompositionOverlay = shouldShowCompositionLoadingOverlay(compositionLoading); + const showAssetOverlay = + assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay; + + useEffect(() => { + onCompositionLoadingChange?.(showCompositionOverlay); + }, [onCompositionLoadingChange, showCompositionOverlay]); return (
( style={style} >
+ {showCompositionOverlay && ( +
event.preventDefault()} + onMouseDown={(event) => event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} + > + +
+ )} {showAssetOverlay && (
void; onSeek: (time: number) => void; + disabled?: boolean; } export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, + disabled = false, }: PlayerControlsProps) { // Subscribe to only the fields we render — each selector prevents cascading re-renders const isPlaying = usePlayerStore((s) => s.isPlaying); @@ -57,6 +59,7 @@ export const PlayerControls = memo(function PlayerControls({ const durationRef = useRef(duration); durationRef.current = duration; + const controlsDisabled = disabled || !timelineReady; useMountEffect(() => { const updateProgress = (t: number) => { currentTimeRef.current = t; @@ -115,6 +118,7 @@ export const PlayerControls = memo(function PlayerControls({ const seekFromClientX = useCallback( (clientX: number) => { + if (disabled) return; const bar = seekBarRef.current; if (!bar || duration <= 0) return; const rect = bar.getBoundingClientRect(); @@ -125,7 +129,7 @@ export const PlayerControls = memo(function PlayerControls({ if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; onSeek(percent * duration); }, - [duration, onSeek], + [disabled, duration, onSeek], ); const handlePointerDown = useCallback( @@ -204,7 +208,7 @@ export const PlayerControls = memo(function PlayerControls({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (!timelineReady || duration <= 0) return; + if (disabled || !timelineReady || duration <= 0) return; const step = e.shiftKey ? 10 : 1; if (e.key === "ArrowLeft") { e.preventDefault(); @@ -214,14 +218,15 @@ export const PlayerControls = memo(function PlayerControls({ onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step))); } }, - [timelineReady, duration, onSeek], + [disabled, timelineReady, duration, onSeek], ); const commitJumpFrame = useCallback(() => { + if (disabled) return; const frame = Number.parseInt(jumpFrame, 10); if (!Number.isFinite(frame) || duration <= 0) return; onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame)))); - }, [duration, jumpFrame, onSeek]); + }, [disabled, duration, jumpFrame, onSeek]); const handleJumpSubmit = useCallback( (e: React.FormEvent) => { @@ -243,6 +248,7 @@ export const PlayerControls = memo(function PlayerControls({ return (
@@ -293,12 +299,15 @@ export const PlayerControls = memo(function PlayerControls({ (sliderRef as React.MutableRefObject).current = el; }} role="slider" - tabIndex={0} + tabIndex={disabled ? -1 : 0} aria-label="Seek" + aria-disabled={disabled || undefined} aria-valuemin={0} aria-valuemax={Math.round(duration)} aria-valuenow={0} - className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group" + className={`min-w-[96px] flex-1 h-6 flex items-center group ${ + disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" + }`} // `touch-action: none` tells the browser we're handling every // pointer gesture on this element ourselves. Without it, iOS // Safari consumes horizontal swipes for its own swipe-back-to- @@ -334,6 +343,7 @@ export const PlayerControls = memo(function PlayerControls({