From bf47416e144bbd45c41375f357b229215aa11ccb Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Thu, 23 Jul 2026 22:00:35 -0700 Subject: [PATCH] refactor(studio): simplify preview workspace layout --- .../src/components/StudioLeftSidebar.tsx | 5 +- .../src/components/StudioRightPanel.tsx | 6 +- .../studio/src/components/nle/NLEContext.tsx | 2 +- .../src/contexts/PanelLayoutContext.tsx | 9 +- .../studio/src/hooks/usePanelLayout.test.ts | 69 +++- packages/studio/src/hooks/usePanelLayout.ts | 106 +++++-- .../player/components/PlayerControls.test.ts | 20 -- .../src/player/components/PlayerControls.tsx | 297 ++++-------------- .../src/player/components/ShortcutsPanel.tsx | 6 +- .../src/player/components/SpeedMenu.tsx | 3 +- .../src/player/components/useSeekBarDrag.ts | 169 ---------- .../src/utils/studioUiPreferences.test.ts | 5 + .../studio/src/utils/studioUiPreferences.ts | 8 + 13 files changed, 233 insertions(+), 472 deletions(-) delete mode 100644 packages/studio/src/player/components/PlayerControls.test.ts delete mode 100644 packages/studio/src/player/components/useSeekBarDrag.ts diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index 9db71a2d23..40842ecc58 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -38,7 +38,7 @@ export function StudioLeftSidebar({ const { leftCollapsed, leftWidth, - setLeftWidth, + adjustPanelWidth, toggleLeftSidebar, handlePanelResizeStart, handlePanelResizeMove, @@ -173,8 +173,7 @@ export function StudioLeftSidebar({ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; e.preventDefault(); const delta = e.key === "ArrowLeft" ? -16 : 16; - const maxLeft = Math.floor(window.innerWidth * 0.5); - setLeftWidth(Math.max(160, Math.min(maxLeft, leftWidth + delta))); + adjustPanelWidth("left", delta); }} > {/* Expanded hit zone: 8px wide, centered on the 3px seam */} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 178407f679..fc96e3605c 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -96,7 +96,7 @@ export function StudioRightPanel({ }: StudioRightPanelProps) { const { rightWidth, - setRightWidth, + adjustPanelWidth, rightPanelTab, setRightPanelTab, rightInspectorPanes, @@ -464,7 +464,7 @@ export function StudioRightPanel({ e.preventDefault(); // Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it. const delta = e.key === "ArrowLeft" ? 16 : -16; - setRightWidth(Math.max(160, Math.min(600, rightWidth + delta))); + adjustPanelWidth("right", delta); }} > {/* Expanded hit zone: 8px wide, centered on the 3px seam */} @@ -473,7 +473,7 @@ export function StudioRightPanel({
{captionEditMode ? ( diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 968fcafb75..5bdece6459 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -19,7 +19,7 @@ import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; // Timeline gets a generous default height so the preview isn't oversized and the // tracks have room to breathe (CapCut-style). Users can still drag the divider. -const DEFAULT_TIMELINE_H = 340; +const DEFAULT_TIMELINE_H = 360; export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean { return compositionLoading; diff --git a/packages/studio/src/contexts/PanelLayoutContext.tsx b/packages/studio/src/contexts/PanelLayoutContext.tsx index 67ed23da4f..b696da0a8b 100644 --- a/packages/studio/src/contexts/PanelLayoutContext.tsx +++ b/packages/studio/src/contexts/PanelLayoutContext.tsx @@ -14,9 +14,8 @@ export function usePanelLayoutContext(): PanelLayoutValue { export function PanelLayoutProvider({ value: { leftWidth, - setLeftWidth, rightWidth, - setRightWidth, + adjustPanelWidth, leftCollapsed, setLeftCollapsed, rightCollapsed, @@ -39,9 +38,8 @@ export function PanelLayoutProvider({ const stable = useMemo( () => ({ leftWidth, - setLeftWidth, rightWidth, - setRightWidth, + adjustPanelWidth, leftCollapsed, setLeftCollapsed, rightCollapsed, @@ -58,9 +56,8 @@ export function PanelLayoutProvider({ }), [ leftWidth, - setLeftWidth, rightWidth, - setRightWidth, + adjustPanelWidth, leftCollapsed, setLeftCollapsed, rightCollapsed, diff --git a/packages/studio/src/hooks/usePanelLayout.test.ts b/packages/studio/src/hooks/usePanelLayout.test.ts index b1ddf2fa17..0a3d18a7c3 100644 --- a/packages/studio/src/hooks/usePanelLayout.test.ts +++ b/packages/studio/src/hooks/usePanelLayout.test.ts @@ -2,11 +2,30 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readStudioUiPreferences } from "../utils/studioUiPreferences"; import { usePanelLayout } from "./usePanelLayout"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +beforeEach(() => { + const entries = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + get length() { + return entries.size; + }, + clear: () => entries.clear(), + getItem: (key: string) => entries.get(key) ?? null, + key: (index: number) => Array.from(entries.keys())[index] ?? null, + removeItem: (key: string) => entries.delete(key), + setItem: (key: string, value: string) => entries.set(key, value), + } satisfies Storage, + }); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1496 }); +}); + afterEach(() => { document.body.innerHTML = ""; vi.doUnmock("../components/editor/manualEditingAvailability"); @@ -42,6 +61,54 @@ function renderPanelLayout() { } describe("usePanelLayout — right inspector panes", () => { + it("opens Design with the intended viewport-scaled panel widths", () => { + const harness = renderPanelLayout(); + + expect(harness.getState()).toMatchObject({ + leftWidth: 384, + rightWidth: 424, + rightCollapsed: false, + rightPanelTab: "design", + }); + + harness.unmount(); + }); + + it("persists the latest pointer width even before React rerenders", () => { + const harness = renderPanelLayout(); + const state = harness.getState(); + const target = { setPointerCapture: vi.fn() }; + + act(() => { + state.handlePanelResizeStart("left", { + preventDefault: vi.fn(), + target, + pointerId: 1, + clientX: 100, + } as unknown as React.PointerEvent); + state.handlePanelResizeMove({ clientX: 140 } as React.PointerEvent); + state.handlePanelResizeEnd(); + }); + + expect(harness.getState().leftWidth).toBe(424); + expect(readStudioUiPreferences().leftWidth).toBe(424); + harness.unmount(); + }); + + it("accumulates and persists rapid keyboard resize steps", () => { + const harness = renderPanelLayout(); + const state = harness.getState(); + + act(() => { + state.adjustPanelWidth("right", 16); + state.adjustPanelWidth("right", 16); + }); + + expect(harness.getState().rightWidth).toBe(456); + expect(readStudioUiPreferences().rightWidth).toBe(456); + harness.unmount(); + }); + it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => { const harness = renderPanelLayout(); expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true }); diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts index df94b244e2..cc358b6e65 100644 --- a/packages/studio/src/hooks/usePanelLayout.ts +++ b/packages/studio/src/hooks/usePanelLayout.ts @@ -13,30 +13,76 @@ export interface InitialPanelLayoutState { rightPanelTab?: RightPanelTab | null; } +type PanelSide = "left" | "right"; + function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspectorPanes { if (tab === "layers") return { layers: true, design: false }; return { layers: false, design: true }; } +function getInitialPanelWidths(): { left: number; right: number } { + const viewportWidth = typeof window === "undefined" ? 1496 : window.innerWidth; + const preferences = readStudioUiPreferences(); + const leftDefault = Math.max(240, Math.min(384, Math.round(viewportWidth * 0.257))); + const rightDefault = Math.max(320, Math.min(424, Math.round(viewportWidth * 0.284))); + return { + left: Math.max( + 160, + Math.min(Math.floor(viewportWidth * 0.5), preferences.leftWidth ?? leftDefault), + ), + right: Math.max(160, Math.min(600, preferences.rightWidth ?? rightDefault)), + }; +} + +function clampPanelWidth(side: PanelSide, width: number): number { + const max = side === "left" ? Math.floor(window.innerWidth * 0.5) : 600; + return Math.max(160, Math.min(max, width)); +} + export function usePanelLayout(initialState?: InitialPanelLayoutState) { - const [leftWidth, setLeftWidth] = useState(240); - const [rightWidth, setRightWidth] = useState(400); + const [initialPanelWidths] = useState(getInitialPanelWidths); + const [leftWidth, setLeftWidth] = useState(initialPanelWidths.left); + const [rightWidth, setRightWidth] = useState(initialPanelWidths.right); + const panelWidthsRef = useRef(initialPanelWidths); const [leftCollapsed, setLeftCollapsed] = useState( () => readStudioUiPreferences().leftCollapsed ?? false, ); - const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? true); + const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? false); const [rightPanelTab, setRightPanelTab] = useState( - initialState?.rightPanelTab ?? "renders", + initialState?.rightPanelTab ?? "design", ); const [rightInspectorPanes, setRightInspectorPanes] = useState(() => getInitialRightInspectorPanes(initialState?.rightPanelTab), ); const panelDragRef = useRef<{ - side: "left" | "right"; + side: PanelSide; startX: number; startW: number; } | null>(null); + const updatePanelWidth = useCallback((side: PanelSide, width: number) => { + const next = clampPanelWidth(side, width); + panelWidthsRef.current[side] = next; + if (side === "left") setLeftWidth(next); + else setRightWidth(next); + return next; + }, []); + + const commitPanelWidth = useCallback( + (side: PanelSide, width: number) => { + const next = updatePanelWidth(side, Math.round(width)); + writeStudioUiPreferences(side === "left" ? { leftWidth: next } : { rightWidth: next }); + }, + [updatePanelWidth], + ); + + const adjustPanelWidth = useCallback( + (side: PanelSide, delta: number) => { + commitPanelWidth(side, panelWidthsRef.current[side] + delta); + }, + [commitPanelWidth], + ); + const toggleLeftSidebar = useCallback(() => { setLeftCollapsed((collapsed) => { writeStudioUiPreferences({ leftCollapsed: !collapsed }); @@ -45,38 +91,31 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { }); }, []); - const handlePanelResizeStart = useCallback( - (side: "left" | "right", e: React.PointerEvent) => { - e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - panelDragRef.current = { - side, - startX: e.clientX, - startW: side === "left" ? leftWidth : rightWidth, - }; + const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => { + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + panelDragRef.current = { + side, + startX: e.clientX, + startW: panelWidthsRef.current[side], + }; + }, []); + + const handlePanelResizeMove = useCallback( + (e: React.PointerEvent) => { + const drag = panelDragRef.current; + if (!drag) return; + const delta = e.clientX - drag.startX; + updatePanelWidth(drag.side, drag.startW + (drag.side === "left" ? delta : -delta)); }, - [leftWidth, rightWidth], + [updatePanelWidth], ); - const handlePanelResizeMove = useCallback((e: React.PointerEvent) => { - const drag = panelDragRef.current; - if (!drag) return; - const delta = e.clientX - drag.startX; - const maxLeft = Math.floor(window.innerWidth * 0.5); - const newW = Math.max( - 160, - Math.min( - drag.side === "left" ? maxLeft : 600, - drag.startW + (drag.side === "left" ? delta : -delta), - ), - ); - if (drag.side === "left") setLeftWidth(newW); - else setRightWidth(newW); - }, []); - const handlePanelResizeEnd = useCallback(() => { + const side = panelDragRef.current?.side; + if (side) commitPanelWidth(side, panelWidthsRef.current[side]); panelDragRef.current = null; - }, []); + }, [commitPanelWidth]); const trackedSetRightPanelTab = useCallback( (tab: RightPanelTab) => { @@ -120,9 +159,8 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { return { leftWidth, - setLeftWidth, rightWidth, - setRightWidth, + adjustPanelWidth, leftCollapsed, setLeftCollapsed, rightCollapsed, diff --git a/packages/studio/src/player/components/PlayerControls.test.ts b/packages/studio/src/player/components/PlayerControls.test.ts deleted file mode 100644 index 84f23ac599..0000000000 --- a/packages/studio/src/player/components/PlayerControls.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveSeekPercent } from "./PlayerControls"; - -describe("resolveSeekPercent", () => { - it("returns 0 when the track width is invalid", () => { - expect(resolveSeekPercent(100, 0, 0)).toBe(0); - }); - - it("snaps to the start within the edge threshold", () => { - expect(resolveSeekPercent(105, 100, 200)).toBe(0); - }); - - it("snaps to the end within the edge threshold", () => { - expect(resolveSeekPercent(298, 100, 200)).toBe(1); - }); - - it("preserves the true percent away from the edges", () => { - expect(resolveSeekPercent(150, 100, 200)).toBe(0.25); - }); -}); diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 0ebe6ec2d2..1ac690617b 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -1,15 +1,13 @@ -import { useRef, useCallback, useEffect, memo } from "react"; +import { useRef, useEffect, memo } from "react"; import gsap from "gsap"; import { MorphSVGPlugin } from "gsap/MorphSVGPlugin"; -import { formatFrameTime, formatTime, stepFrameTime } from "../lib/time"; -import { usePlayerStore } from "../store/playerStore"; +import { formatFrameTime, formatTime } from "../lib/time"; +import { liveTime, usePlayerStore } from "../store/playerStore"; import { trackStudioEvent } from "../../utils/studioTelemetry"; import { Tooltip } from "../../components/ui"; +import { useMountEffect } from "../../hooks/useMountEffect"; import { ShortcutsPanel } from "./ShortcutsPanel"; import { SpeedMenu } from "./SpeedMenu"; -import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag"; - -export { resolveSeekPercent }; /* ── Icon sub-components ─────────────────────────────────────────── */ @@ -78,10 +76,8 @@ const MuteButton = memo(function MuteButton({ disabled={controlsDisabled} aria-label={label} aria-pressed={audioMuted} - className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${ - audioMuted - ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30" - : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800" + className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${ + audioMuted ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200" }`} > @@ -214,118 +206,6 @@ const FullscreenButton = memo(function FullscreenButton({ ); }); -/* ── Seek bar sub-component ──────────────────────────────────────── */ - -function SeekBarMarker({ position, duration }: { position: number; duration: number }) { - if (duration <= 0) return null; - return ( -
- ); -} - -function WorkAreaOverlay({ - inPoint, - outPoint, - duration, -}: { - inPoint: number | null; - outPoint: number | null; - duration: number; -}) { - if ((inPoint === null && outPoint === null) || duration <= 0) return null; - return ( - <> -
- {inPoint !== null && } - {outPoint !== null && } - - ); -} - -const SeekBar = memo(function SeekBar({ - disabled, - duration, - inPoint, - outPoint, - progressFillRef, - progressThumbRef, - seekBarRef, - sliderRef, - onPointerDown, - onKeyDown, -}: { - disabled: boolean; - duration: number; - inPoint: number | null; - outPoint: number | null; - progressFillRef: React.RefObject; - progressThumbRef: React.RefObject; - seekBarRef: React.RefObject; - sliderRef: React.RefObject; - onPointerDown: (e: React.PointerEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; -}) { - return ( -
{ - (seekBarRef as React.MutableRefObject).current = el; - (sliderRef as React.MutableRefObject).current = el; - }} - role="slider" - 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 group outline-none focus-visible:ring-1 focus-visible:ring-white/30 focus-visible:rounded ${ - disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" - }`} - style={{ touchAction: "none" }} - onPointerDown={onPointerDown} - onKeyDown={onKeyDown} - > -
- -
-
-
-
- ); -}); - /* ── Main component ──────────────────────────────────────────────── */ interface PlayerControlsProps { @@ -359,12 +239,7 @@ export const PlayerControls = memo(function PlayerControls({ const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode); const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode; - const progressFillRef = useRef(null); - const progressThumbRef = useRef(null); const timeDisplayRef = useRef(null); - const seekBarRef = useRef(null); - const sliderRef = useRef(null); - const isDraggingRef = useRef(false); const currentTimeRef = useRef(0); const timeDisplayModeRef = useRef(timeDisplayMode); timeDisplayModeRef.current = timeDisplayMode; @@ -380,64 +255,29 @@ export const PlayerControls = memo(function PlayerControls({ timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t); }, [duration, timeDisplayMode]); - const { handlePointerDown } = useSeekBarDrag( - { - seekBarRef, - progressFillRef, - progressThumbRef, - sliderRef, - timeDisplayRef, - isDraggingRef, - durationRef, - currentTimeRef, - timeDisplayModeRef, - }, - onSeek, - disabled, - duration, - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (disabled || !timelineReady || duration <= 0) return; - const step = e.shiftKey ? 10 : 1; - if (e.key === "ArrowLeft") { - e.preventDefault(); - onSeek(stepFrameTime(currentTimeRef.current, -step)); - } else if (e.key === "ArrowRight") { - e.preventDefault(); - onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step))); - } - }, - [disabled, timelineReady, duration, onSeek], - ); + useMountEffect(() => { + const updateTime = (time: number) => { + currentTimeRef.current = time; + if (!timeDisplayRef.current) return; + const currentDuration = durationRef.current; + timeDisplayRef.current.textContent = + timeDisplayModeRef.current === "frame" + ? formatFrameTime(time, currentDuration) + : formatTime(time); + }; + const unsubscribe = liveTime.subscribe(updateTime); + updateTime(usePlayerStore.getState().currentTime); + return unsubscribe; + }); return (
- - - - @@ -445,59 +285,58 @@ export const PlayerControls = memo(function PlayerControls({ type="button" onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")} disabled={disabled} - className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80" - style={{ color: "#A1A1AA", cursor: "pointer" }} + className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none" > {formatTime(0)} {timeDisplayMode === "time" ? ( <> - / - {formatTime(duration)} + / + {formatTime(duration)} ) : null} - - - - - - - - - {onToggleFullscreen && ( - - )} + + + - +
+ + + + {onToggleFullscreen && ( + + )} + +
); }); diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx index 3ef8736013..50506ad46a 100644 --- a/packages/studio/src/player/components/ShortcutsPanel.tsx +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -153,10 +153,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({ diff --git a/packages/studio/src/player/components/useSeekBarDrag.ts b/packages/studio/src/player/components/useSeekBarDrag.ts deleted file mode 100644 index 5fd0f054c6..0000000000 --- a/packages/studio/src/player/components/useSeekBarDrag.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback } from "react"; -import { useMountEffect } from "../../hooks/useMountEffect"; -import { formatFrameTime, formatTime } from "../lib/time"; -import { usePlayerStore, liveTime } from "../store/playerStore"; - -const SEEK_EDGE_SNAP_PX = 8; - -export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number { - if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0; - const rawPercent = (clientX - rectLeft) / rectWidth; - const clamped = Math.max(0, Math.min(1, rawPercent)); - const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth); - if (clamped <= snapThreshold) return 0; - if (clamped >= 1 - snapThreshold) return 1; - return clamped; -} - -interface SeekBarRefs { - seekBarRef: React.RefObject; - progressFillRef: React.RefObject; - progressThumbRef: React.RefObject; - sliderRef: React.RefObject; - timeDisplayRef: React.RefObject; - isDraggingRef: React.MutableRefObject; - durationRef: React.MutableRefObject; - currentTimeRef: React.MutableRefObject; - timeDisplayModeRef: React.MutableRefObject<"time" | "frame">; -} - -function updateProgressUI( - fillRef: React.RefObject, - thumbRef: React.RefObject, - pct: number, -): void { - if (fillRef.current) fillRef.current.style.width = `${pct}%`; - if (thumbRef.current) thumbRef.current.style.left = `${pct}%`; -} - -export function useSeekBarDrag( - refs: SeekBarRefs, - onSeek: (time: number) => void, - disabled: boolean, - duration: number, -) { - const seekFromClientX = useCallback( - (clientX: number) => { - if (disabled) return; - const bar = refs.seekBarRef.current; - if (!bar || duration <= 0) return; - const rect = bar.getBoundingClientRect(); - const percent = resolveSeekPercent(clientX, rect.left, rect.width); - updateProgressUI(refs.progressFillRef, refs.progressThumbRef, percent * 100); - onSeek(percent * duration); - }, - [disabled, duration, onSeek, refs], - ); - - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.currentTarget.focus(); - refs.isDraggingRef.current = true; - - const target = e.currentTarget; - const pointerId = e.pointerId; - try { - target.setPointerCapture(pointerId); - } catch { - /* fallback to window listeners */ - } - - seekFromClientX(e.clientX); - - let seekRafId = 0; - let pendingClientX = e.clientX; - const onMove = (ev: PointerEvent) => { - if (ev.pointerId !== pointerId || !refs.isDraggingRef.current) return; - pendingClientX = ev.clientX; - const bar = refs.seekBarRef.current; - const dur = refs.durationRef.current; - if (bar && dur > 0) { - const rect = bar.getBoundingClientRect(); - const pct = resolveSeekPercent(ev.clientX, rect.left, rect.width) * 100; - updateProgressUI(refs.progressFillRef, refs.progressThumbRef, pct); - } - if (!seekRafId) { - seekRafId = requestAnimationFrame(() => { - seekRafId = 0; - if (refs.isDraggingRef.current) seekFromClientX(pendingClientX); - }); - } - }; - const cleanup = () => { - refs.isDraggingRef.current = false; - if (seekRafId) { - cancelAnimationFrame(seekRafId); - seekRafId = 0; - } - seekFromClientX(pendingClientX); - try { - target.releasePointerCapture(pointerId); - } catch { - /* already released */ - } - target.removeEventListener("pointermove", onMove); - target.removeEventListener("pointerup", onUp); - target.removeEventListener("pointercancel", onUp); - window.removeEventListener("pointerup", onUp); - window.removeEventListener("pointercancel", onUp); - document.removeEventListener("visibilitychange", onVisibilityChange); - window.removeEventListener("blur", cleanup); - target.blur(); - }; - const onUp = (ev: PointerEvent) => { - if (ev.pointerId !== pointerId) return; - cleanup(); - }; - const onVisibilityChange = () => { - if (document.visibilityState === "hidden") cleanup(); - }; - - target.addEventListener("pointermove", onMove); - target.addEventListener("pointerup", onUp); - target.addEventListener("pointercancel", onUp); - window.addEventListener("pointerup", onUp); - window.addEventListener("pointercancel", onUp); - document.addEventListener("visibilitychange", onVisibilityChange); - window.addEventListener("blur", cleanup); - }, - [seekFromClientX, refs], - ); - - useMountEffect(() => { - const updateProgress = (t: number) => { - refs.currentTimeRef.current = t; - const dur = refs.durationRef.current; - const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0; - updateProgressUI(refs.progressFillRef, refs.progressThumbRef, pct); - if (refs.timeDisplayRef.current) { - refs.timeDisplayRef.current.textContent = - refs.timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t); - } - if (refs.sliderRef.current) - refs.sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t))); - }; - const unsub = liveTime.subscribe(updateProgress); - updateProgress(usePlayerStore.getState().currentTime); - - const interval = setInterval(() => { - const t = usePlayerStore.getState().currentTime; - const dur = usePlayerStore.getState().duration; - if (dur > 0 && t > 0) { - updateProgressUI( - refs.progressFillRef, - refs.progressThumbRef, - Math.min(100, (t / dur) * 100), - ); - } - }, 500); - - return () => { - unsub(); - clearInterval(interval); - }; - }); - - return { handlePointerDown }; -} diff --git a/packages/studio/src/utils/studioUiPreferences.test.ts b/packages/studio/src/utils/studioUiPreferences.test.ts index 5e335cf5d3..982a10bc61 100644 --- a/packages/studio/src/utils/studioUiPreferences.test.ts +++ b/packages/studio/src/utils/studioUiPreferences.test.ts @@ -20,12 +20,15 @@ describe("studio UI preferences", () => { const storage = createStorage(); writeStudioUiPreferences({ timelineVisible: false }, storage); + writeStudioUiPreferences({ leftWidth: 384, rightWidth: 424 }, storage); writeStudioUiPreferences({ playbackRate: 1.5 }, storage); writeStudioUiPreferences({ audioMuted: true }, storage); writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage); expect(readStudioUiPreferences(storage)).toEqual({ timelineVisible: false, + leftWidth: 384, + rightWidth: 424, playbackRate: 1.5, audioMuted: true, previewZoom: { zoomPercent: 160, panX: -20, panY: 12 }, @@ -38,6 +41,8 @@ describe("studio UI preferences", () => { "hf-studio-ui-preferences", JSON.stringify({ leftCollapsed: "yes", + leftWidth: "wide", + rightWidth: Number.NaN, timelineVisible: true, playbackRate: Number.NaN, audioMuted: "false", diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts index 6548a58eba..c2bfe7a537 100644 --- a/packages/studio/src/utils/studioUiPreferences.ts +++ b/packages/studio/src/utils/studioUiPreferences.ts @@ -6,6 +6,8 @@ export interface StoredPreviewZoomState { export interface StudioUiPreferences { leftCollapsed?: boolean; + leftWidth?: number; + rightWidth?: number; timelineVisible?: boolean; timelineHeight?: number; playbackRate?: number; @@ -58,6 +60,12 @@ function readStorage(storage: Storage | null): StudioUiPreferences { if (typeof parsed.leftCollapsed === "boolean") { preferences.leftCollapsed = parsed.leftCollapsed; } + if (typeof parsed.leftWidth === "number" && Number.isFinite(parsed.leftWidth)) { + preferences.leftWidth = parsed.leftWidth; + } + if (typeof parsed.rightWidth === "number" && Number.isFinite(parsed.rightWidth)) { + preferences.rightWidth = parsed.rightWidth; + } if (typeof parsed.timelineVisible === "boolean") { preferences.timelineVisible = parsed.timelineVisible; }