Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/studio/src/components/nle/NLELayout.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
67 changes: 49 additions & 18 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -374,6 +388,7 @@ export const NLELayout = memo(function NLELayout({
projectId={projectId}
iframeRef={iframeRef}
onIframeLoad={onIframeLoad}
onCompositionLoadingChange={setCompositionLoading}
portrait={portrait}
directUrl={directUrl}
refreshKey={refreshKey}
Expand All @@ -388,7 +403,7 @@ export const NLELayout = memo(function NLELayout({
onNavigate={handleNavigateComposition}
/>
)}
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
</div>
</div>

Expand All @@ -406,13 +421,18 @@ export const NLELayout = memo(function NLELayout({
</div>

{/* Timeline section — fixed height, resizable */}
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
<div
className="relative flex flex-col flex-shrink-0"
style={{ height: timelineH }}
aria-disabled={timelineDisabled || undefined}
>
{/* Timeline tracks */}
<div
// flex-col: toolbar takes natural height, Timeline fills remainder.
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (timelineDisabled) return;
if (compositionStack.length > 1) {
updateCompositionStack((prev) => prev.slice(0, -1));
}
Expand All @@ -435,9 +455,20 @@ export const NLELayout = memo(function NLELayout({
layerChildCounts={timelineLayerChildCounts}
thumbnailedElementIds={thumbnailedTimelineElementIds}
onToggleElementThumbnail={onToggleTimelineElementThumbnail}
disabled={timelineDisabled}
/>
</div>
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
{timelineDisabled && (
<div
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
data-testid="timeline-loading-disabled-overlay"
aria-hidden="true"
onPointerDown={(event) => event.preventDefault()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
/>
)}
</div>
</>
) : onToggleTimeline ? (
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/components/nle/NLEPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface NLEPreviewProps {
projectId: string;
iframeRef: Ref<HTMLIFrameElement>;
onIframeLoad: () => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
directUrl?: string;
refreshKey?: number;
Expand Down Expand Up @@ -36,6 +37,7 @@ export const NLEPreview = memo(function NLEPreview({
projectId,
iframeRef,
onIframeLoad,
onCompositionLoadingChange,
portrait,
directUrl,
refreshKey,
Expand Down Expand Up @@ -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}
/>
Expand Down
12 changes: 12 additions & 0 deletions packages/studio/src/player/components/Player.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 43 additions & 2 deletions packages/studio/src/player/components/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface PlayerProps {
projectId?: string;
directUrl?: string;
onLoad: () => void;
onCompositionLoadingChange?: (loading: boolean) => void;
portrait?: boolean;
style?: React.CSSProperties;
}
Expand All @@ -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;
Expand Down Expand Up @@ -84,7 +89,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
* timeline probing, and DOM inspection.
*/
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
({ projectId, directUrl, onLoad, portrait, style }, ref) => {
({ projectId, directUrl, onLoad, onCompositionLoadingChange, portrait, style }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const loadCountRef = useRef(0);
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
Expand All @@ -93,6 +98,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
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;
Expand Down Expand Up @@ -138,10 +144,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
};
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");
Expand Down Expand Up @@ -192,6 +208,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
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);
Expand Down Expand Up @@ -237,14 +255,37 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
};
}, [assetsLoading]);

const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
const showCompositionOverlay = shouldShowCompositionLoadingOverlay(compositionLoading);
const showAssetOverlay =
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;

useEffect(() => {
onCompositionLoadingChange?.(showCompositionOverlay);
}, [onCompositionLoadingChange, showCompositionOverlay]);

return (
<div
className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
style={style}
>
<div ref={containerRef} className="w-full h-full" />
{showCompositionOverlay && (
<div
className="absolute inset-0 bg-black flex items-center justify-center z-30 select-none"
data-hyperframes-ignore=""
data-testid="composition-loading-overlay"
draggable={false}
onDragStart={(event) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()}
onPointerDown={(event) => event.preventDefault()}
>
<HyperframesLoader
title="Loading composition"
detail="Preparing the Studio preview."
size={56}
/>
</div>
)}
{showAssetOverlay && (
<div
className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
Expand Down
Loading
Loading