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
26 changes: 26 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export function StudioApp() {
const [rightWidth, setRightWidth] = useState(400);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(true);
const [timelineVisible, setTimelineVisible] = useState(false);
const panelDragRef = useRef<{
side: "left" | "right";
startX: number;
Expand Down Expand Up @@ -575,6 +576,29 @@ export function StudioApp() {
<path d="M9 3v18" />
</svg>
</button>
<button
onClick={() => setTimelineVisible((v) => !v)}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
timelineVisible
? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
}`}
title={timelineVisible ? "Hide timeline" : "Show timeline"}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<rect x="3" y="13" width="18" height="8" rx="1" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="5" x2="21" y2="5" />
</svg>
</button>
<button
onClick={() => setRightCollapsed((v) => !v)}
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
Expand Down Expand Up @@ -673,6 +697,8 @@ export function StudioApp() {
onIframeRef={(iframe) => {
previewIframeRef.current = iframe;
}}
timelineVisible={timelineVisible}
onToggleTimeline={() => setTimelineVisible((v) => !v)}
/>
</div>

Expand Down
83 changes: 30 additions & 53 deletions packages/studio/src/components/editor/FileTree.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,48 @@
import { memo, useState, useCallback } from "react";
import { Film, Music, Image, ChevronDown, ChevronRight } from "../../icons/SystemIcons";
import {
FileHtml,
FileCss,
FileJs,
FileJsx,
FileTs,
FileTsx,
FileTxt,
FileCode,
File,
FilmStrip,
MusicNote,
Image as PhImage,
} from "@phosphor-icons/react";
import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";

interface FileTreeProps {
files: string[];
activeFile: string | null;
onSelectFile: (path: string) => void;
}

/** VS Code–style language badge: colored rounded rect with a 2–3 letter label. */
function Badge({ label, bg, text = "#fff" }: { label: string; bg: string; text?: string }) {
return (
<span
className="flex-shrink-0 inline-flex items-center justify-center rounded"
style={{
width: 16,
height: 16,
background: bg,
color: text,
fontSize: 7,
fontWeight: 700,
fontFamily: "monospace",
letterSpacing: "-0.02em",
lineHeight: 1,
}}
>
{label}
</span>
);
}
const SZ = 14;

/** Render a file-type icon for a given file path. */
function FileIcon({ path }: { path: string }) {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
// Language badges
if (ext === "html") return <Badge label="HTML" bg="#E44D26" />;
if (ext === "js" || ext === "mjs" || ext === "cjs")
return <Badge label="JS" bg="#F0DB4F" text="#323330" />;
if (ext === "ts" || ext === "mts") return <Badge label="TS" bg="#3178C6" />;
if (ext === "css") return <Badge label="CSS" bg="#264DE4" />;
if (ext === "json") return <Badge label="{}" bg="#1E7F34" />;
if (ext === "md" || ext === "mdx") return <Badge label="MD" bg="#555" />;
if (ext === "svg") return <Badge label="SVG" bg="#FF9900" />;
const d = { size: SZ, weight: "duotone" as const, className: "flex-shrink-0" };
if (ext === "html") return <FileHtml {...d} color="#E44D26" />;
if (ext === "css") return <FileCss {...d} color="#264DE4" />;
if (ext === "js" || ext === "mjs" || ext === "cjs") return <FileJs {...d} color="#F0DB4F" />;
if (ext === "jsx") return <FileJsx {...d} color="#61DAFB" />;
if (ext === "ts" || ext === "mts") return <FileTs {...d} color="#3178C6" />;
if (ext === "tsx") return <FileTsx {...d} color="#3178C6" />;
if (ext === "txt" || ext === "md" || ext === "mdx") return <FileTxt {...d} color="#9CA3AF" />;
if (ext === "json" || ext === "svg") return <FileCode {...d} color="#22C55E" />;
if (ext === "wav" || ext === "mp3" || ext === "ogg" || ext === "m4a")
return <Music size={13} style={{ color: "#3CE6AC" }} className="flex-shrink-0" />;
return <MusicNote size={SZ} color="#3CE6AC" className="flex-shrink-0" />;
if (ext === "mp4" || ext === "webm" || ext === "mov")
return <Film size={13} style={{ color: "#A855F7" }} className="flex-shrink-0" />;
return <FilmStrip size={SZ} color="#A855F7" className="flex-shrink-0" />;
if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "webp" || ext === "gif")
return <Image size={13} style={{ color: "#22C55E" }} className="flex-shrink-0" />;
return <PhImage size={SZ} color="#22C55E" className="flex-shrink-0" />;
if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf")
return <Badge label="Aa" bg="#525252" />;
if (ext === "txt") return <Badge label="TXT" bg="#4B5563" />;
// Generic document
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="#6B7280"
strokeWidth="1.5"
strokeLinecap="round"
className="flex-shrink-0"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
return <File size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />;
return <File size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />;
}

interface TreeNode {
Expand Down
102 changes: 59 additions & 43 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ interface NLELayoutProps {
) => ReactNode;
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
onCompIdToSrcChange?: (map: Map<string, string>) => void;
/** Whether the timeline panel is visible (default: true) */
timelineVisible?: boolean;
/** Callback to toggle timeline visibility */
onToggleTimeline?: () => void;
}

const MIN_TIMELINE_H = 100;
Expand All @@ -47,6 +51,8 @@ export const NLELayout = memo(function NLELayout({
onCompositionChange,
renderClipContent,
onCompIdToSrcChange,
timelineVisible,
onToggleTimeline,
}: NLELayoutProps) {
const {
iframeRef,
Expand Down Expand Up @@ -311,60 +317,70 @@ export const NLELayout = memo(function NLELayout({
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{/* Preview — takes remaining space above timeline */}
<div className="flex-1 min-h-0 relative">
<NLEPreview
projectId={projectId}
iframeRef={iframeRef}
onIframeLoad={onIframeLoad}
portrait={portrait}
directUrl={directUrl}
refreshKey={refreshKey}
/>
{previewOverlay}
</div>

{/* Resize divider */}
<div
className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-row-resize transition-colors active:bg-blue-400 z-10"
style={{ touchAction: "none" }}
onPointerDown={handleDividerPointerDown}
onPointerMove={handleDividerPointerMove}
onPointerUp={handleDividerPointerUp}
/>

{/* Timeline section — fixed height, resizable */}
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
{/* Breadcrumb + Player controls */}
{/* Preview + player controls — takes remaining space above timeline */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 relative">
<NLEPreview
projectId={projectId}
iframeRef={iframeRef}
onIframeLoad={onIframeLoad}
portrait={portrait}
directUrl={directUrl}
refreshKey={refreshKey}
/>
{previewOverlay}
</div>
{/* Player controls always visible, regardless of timeline state */}
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
{compositionStack.length > 1 && (
<CompositionBreadcrumb
stack={compositionStack}
onNavigate={handleNavigateComposition}
/>
)}
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
</div>

{/* Timeline tracks */}
<div
className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (compositionStack.length > 1) {
updateCompositionStack((prev) => prev.slice(0, -1));
}
}}
>
{timelineToolbar}
<Timeline
<PlayerControls
onTogglePlay={togglePlay}
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
timelineVisible={timelineVisible ?? true}
onToggleTimeline={onToggleTimeline}
/>
{timelineFooter}
</div>
</div>

{(timelineVisible ?? true) && (
<>
{/* Resize divider */}
<div
className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-row-resize transition-colors active:bg-blue-400 z-10"
style={{ touchAction: "none" }}
onPointerDown={handleDividerPointerDown}
onPointerMove={handleDividerPointerMove}
onPointerUp={handleDividerPointerUp}
/>

{/* Timeline section — fixed height, resizable */}
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
{/* Timeline tracks */}
<div
className="flex-1 min-h-0 overflow-y-auto bg-neutral-950"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (compositionStack.length > 1) {
updateCompositionStack((prev) => prev.slice(0, -1));
}
}}
>
{timelineToolbar}
<Timeline
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
/>
{timelineFooter}
</div>
</div>
</>
)}
</div>
);
});
31 changes: 31 additions & 0 deletions packages/studio/src/player/components/PlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
interface PlayerControlsProps {
onTogglePlay: () => void;
onSeek: (time: number) => void;
timelineVisible?: boolean;
onToggleTimeline?: () => void;
}

export const PlayerControls = memo(function PlayerControls({
onTogglePlay,
onSeek,
timelineVisible,
onToggleTimeline,
}: PlayerControlsProps) {
// Subscribe to only the fields we render — each selector prevents cascading re-renders
const isPlaying = usePlayerStore((s) => s.isPlaying);
Expand Down Expand Up @@ -224,6 +228,33 @@ export const PlayerControls = memo(function PlayerControls({
</div>
)}
</div>

{/* Timeline toggle */}
{onToggleTimeline !== undefined && (
<button
onClick={onToggleTimeline}
className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
timelineVisible
? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
: "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
}`}
title={timelineVisible ? "Hide timeline" : "Show timeline"}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<rect x="3" y="13" width="18" height="8" rx="1" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="5" x2="21" y2="5" />
</svg>
</button>
)}
</div>
);
});
Loading