mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +00:00
Merge pull request #2756 from heygen-com/feat/studio-preview-layout
refactor(studio): simplify preview workspace layout
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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({
|
||||
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
||||
</div>
|
||||
<div
|
||||
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900"
|
||||
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-950"
|
||||
style={{ width: rightWidth }}
|
||||
>
|
||||
{captionEditMode ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PanelLayoutValue>(
|
||||
() => ({
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
rightWidth,
|
||||
setRightWidth,
|
||||
adjustPanelWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
@@ -58,9 +56,8 @@ export function PanelLayoutProvider({
|
||||
}),
|
||||
[
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
rightWidth,
|
||||
setRightWidth,
|
||||
adjustPanelWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
|
||||
@@ -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<string, string>();
|
||||
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 });
|
||||
|
||||
@@ -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<RightPanelTab>(
|
||||
initialState?.rightPanelTab ?? "renders",
|
||||
initialState?.rightPanelTab ?? "design",
|
||||
);
|
||||
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
@@ -131,10 +127,8 @@ const LoopButton = memo(function LoopButton({
|
||||
setLoopEnabled(!loopEnabled);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
loopEnabled
|
||||
? "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 items-center justify-center rounded-md transition-colors disabled:opacity-30 ${
|
||||
loopEnabled ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
||||
aria-pressed={loopEnabled}
|
||||
@@ -175,10 +169,8 @@ const FullscreenButton = memo(function FullscreenButton({
|
||||
trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
|
||||
onToggleFullscreen();
|
||||
}}
|
||||
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors ${
|
||||
isFullscreen
|
||||
? "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 ${
|
||||
isFullscreen ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
|
||||
}`}
|
||||
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
@@ -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 (
|
||||
<div
|
||||
className="absolute z-[3] pointer-events-none"
|
||||
style={{
|
||||
left: `${Math.min(100, (position / duration) * 100)}%`,
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "2px",
|
||||
height: "10px",
|
||||
background: "#3CE6AC",
|
||||
borderRadius: "1px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkAreaOverlay({
|
||||
inPoint,
|
||||
outPoint,
|
||||
duration,
|
||||
}: {
|
||||
inPoint: number | null;
|
||||
outPoint: number | null;
|
||||
duration: number;
|
||||
}) {
|
||||
if ((inPoint === null && outPoint === null) || duration <= 0) return null;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 pointer-events-none"
|
||||
style={{
|
||||
left: `${inPoint !== null ? Math.min(100, (inPoint / duration) * 100) : 0}%`,
|
||||
right: `${outPoint !== null ? 100 - Math.min(100, (outPoint / duration) * 100) : 0}%`,
|
||||
background: "rgba(60,230,172,0.15)",
|
||||
}}
|
||||
/>
|
||||
{inPoint !== null && <SeekBarMarker position={inPoint} duration={duration} />}
|
||||
{outPoint !== null && <SeekBarMarker position={outPoint} duration={duration} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement | null>;
|
||||
progressThumbRef: React.RefObject<HTMLDivElement | null>;
|
||||
seekBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
sliderRef: React.RefObject<HTMLDivElement | null>;
|
||||
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
(seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).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}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-full relative"
|
||||
style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
|
||||
>
|
||||
<WorkAreaOverlay inPoint={inPoint} outPoint={outPoint} duration={duration} />
|
||||
<div
|
||||
ref={progressFillRef}
|
||||
className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
|
||||
style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
|
||||
/>
|
||||
<div
|
||||
ref={progressThumbRef}
|
||||
className="absolute top-1/2 z-[4] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
|
||||
style={{
|
||||
background: "var(--hf-accent, #3CE6AC)",
|
||||
boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/* ── 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<HTMLDivElement>(null);
|
||||
const progressThumbRef = useRef<HTMLDivElement>(null);
|
||||
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||
const seekBarRef = useRef<HTMLDivElement>(null);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const currentTimeRef = useRef(0);
|
||||
const timeDisplayModeRef = useRef(timeDisplayMode);
|
||||
timeDisplayModeRef.current = timeDisplayMode;
|
||||
@@ -380,48 +255,48 @@ 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 (
|
||||
<div
|
||||
// No own background/border: the transport blends into the preview
|
||||
// panel's surface — buttons carry their own chrome.
|
||||
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
||||
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
|
||||
aria-disabled={disabled || undefined}
|
||||
style={{
|
||||
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
||||
disabled={disabled}
|
||||
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"
|
||||
>
|
||||
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
||||
{timeDisplayMode === "time" ? (
|
||||
<>
|
||||
<span className="mx-0.5 text-neutral-700">/</span>
|
||||
<span className="text-neutral-600">{formatTime(duration)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={isPlaying ? "Pause" : "Play"}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -431,73 +306,37 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
onTogglePlay();
|
||||
}}
|
||||
disabled={controlsDisabled}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
<PlayPauseMorphIcon playing={isPlaying} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
||||
<div className="flex min-w-0 items-center justify-self-end">
|
||||
<MuteButton
|
||||
audioMuted={audioMuted}
|
||||
controlsDisabled={controlsDisabled}
|
||||
setAudioMuted={setAudioMuted}
|
||||
/>
|
||||
<SpeedMenu
|
||||
playbackRate={playbackRate}
|
||||
setPlaybackRate={setPlaybackRate}
|
||||
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" }}
|
||||
>
|
||||
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
||||
{timeDisplayMode === "time" ? (
|
||||
<>
|
||||
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
|
||||
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<SeekBar
|
||||
disabled={disabled}
|
||||
duration={duration}
|
||||
inPoint={inPoint}
|
||||
outPoint={outPoint}
|
||||
progressFillRef={progressFillRef}
|
||||
progressThumbRef={progressThumbRef}
|
||||
seekBarRef={seekBarRef}
|
||||
sliderRef={sliderRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
audioMuted={audioMuted}
|
||||
controlsDisabled={controlsDisabled}
|
||||
setAudioMuted={setAudioMuted}
|
||||
/>
|
||||
|
||||
<SpeedMenu
|
||||
playbackRate={playbackRate}
|
||||
setPlaybackRate={setPlaybackRate}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
|
||||
|
||||
{onToggleFullscreen && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
|
||||
)}
|
||||
|
||||
<ShortcutsPanel
|
||||
disabled={disabled}
|
||||
duration={duration}
|
||||
inPoint={inPoint}
|
||||
outPoint={outPoint}
|
||||
setInPoint={setInPoint}
|
||||
setOutPoint={setOutPoint}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
/>
|
||||
<LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
|
||||
{onToggleFullscreen && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
|
||||
)}
|
||||
<ShortcutsPanel
|
||||
disabled={disabled}
|
||||
duration={duration}
|
||||
inPoint={inPoint}
|
||||
outPoint={outPoint}
|
||||
setInPoint={setInPoint}
|
||||
setOutPoint={setOutPoint}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -153,10 +153,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShortcuts((v) => !v)}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
|
||||
showShortcuts
|
||||
? "border-neutral-600 text-neutral-200 bg-neutral-800"
|
||||
: "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
showShortcuts ? "text-neutral-200" : "text-neutral-600 hover:text-neutral-300"
|
||||
}`}
|
||||
aria-label="Shortcuts and tools"
|
||||
aria-expanded={showShortcuts}
|
||||
|
||||
@@ -41,8 +41,7 @@ export const SpeedMenu = memo(function SpeedMenu({
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
disabled={disabled}
|
||||
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
||||
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
||||
className="h-7 w-8 rounded-md font-mono text-[10px] tabular-nums text-neutral-500 transition-colors hover:text-neutral-200 disabled:opacity-30"
|
||||
>
|
||||
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
||||
</button>
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
progressFillRef: React.RefObject<HTMLDivElement | null>;
|
||||
progressThumbRef: React.RefObject<HTMLDivElement | null>;
|
||||
sliderRef: React.RefObject<HTMLDivElement | null>;
|
||||
timeDisplayRef: React.RefObject<HTMLSpanElement | null>;
|
||||
isDraggingRef: React.MutableRefObject<boolean>;
|
||||
durationRef: React.MutableRefObject<number>;
|
||||
currentTimeRef: React.MutableRefObject<number>;
|
||||
timeDisplayModeRef: React.MutableRefObject<"time" | "frame">;
|
||||
}
|
||||
|
||||
function updateProgressUI(
|
||||
fillRef: React.RefObject<HTMLDivElement | null>,
|
||||
thumbRef: React.RefObject<HTMLDivElement | null>,
|
||||
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<HTMLDivElement>) => {
|
||||
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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user