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
10 changes: 5 additions & 5 deletions packages/studio/src/player/components/PlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import { Tooltip } from "../../components/ui";
import { ShortcutsPanel } from "./ShortcutsPanel";
import { SpeedMenu } from "./SpeedMenu";
import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
import { useState } from "react";

export { resolveSeekPercent };
type TimeDisplayMode = "time" | "frame";

/* ── Icon sub-components ─────────────────────────────────────────── */

Expand Down Expand Up @@ -369,7 +367,8 @@ export const PlayerControls = memo(function PlayerControls({
const outPoint = usePlayerStore((s) => s.outPoint);
const setInPoint = usePlayerStore.getState().setInPoint;
const setOutPoint = usePlayerStore.getState().setOutPoint;
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;

const progressFillRef = useRef<HTMLDivElement>(null);
const progressThumbRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -428,10 +427,11 @@ export const PlayerControls = memo(function PlayerControls({

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"
aria-disabled={disabled || undefined}
style={{
borderTop: "1px solid rgba(255,255,255,0.04)",
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
}}
>
Expand All @@ -456,7 +456,7 @@ export const PlayerControls = memo(function PlayerControls({
>
<button
type="button"
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
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" }}
Expand Down
5 changes: 4 additions & 1 deletion packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { memo, type CSSProperties, type ReactNode } from "react";
import type { TimelineElement } from "../store/playerStore";
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
import type { TimelineEditCapabilities } from "./timelineEditing";
import { isAudioTimelineElement } from "../../utils/timelineInspector";

interface TimelineClipProps {
el: TimelineElement;
Expand Down Expand Up @@ -62,6 +63,7 @@ export const TimelineClip = memo(function TimelineClip({
isHovered ? "is-hovered" : "",
isDragging ? "is-dragging" : "",
showDefaultText ? "" : "is-micro",
isAudioTimelineElement(el) ? "is-audio" : "",
]
.filter((className) => className.length > 0)
.join(" ");
Expand All @@ -72,7 +74,8 @@ export const TimelineClip = memo(function TimelineClip({
bottom: clipY,
borderRadius: theme.clipRadius,
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
cursor: capabilities.canMove ? "grab" : "default",
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
cursor: "default",
transform: isDragging ? "translateY(-1px)" : undefined,
};

Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/player/components/TimelineEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export function TimelineEmptyState({
style={{ position: "absolute", left: GUTTER + s * 14 }}
>
<span className="text-[9px] text-neutral-600 font-mono tabular-nums leading-none mb-0.5">
{`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
{`${Math.floor(s / 60)
.toString()
.padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`}
</span>
<div className="w-px h-[5px] bg-neutral-700/40" />
</div>
Expand Down
102 changes: 54 additions & 48 deletions packages/studio/src/player/components/TimelineRuler.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { secondsToFrame } from "../lib/time";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";

interface TimelineRulerProps {
Expand All @@ -26,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
theme,
beatAnalysis,
}: TimelineRulerProps) {
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const beatTimes = beatAnalysis?.beatTimes ?? [];
const beatStrengths = beatAnalysis?.beatStrengths ?? [];

Expand All @@ -38,27 +41,13 @@ export const TimelineRuler = memo(function TimelineRuler({

return (
<>
{/* Grid lines (major ticks + beat lines) — behind the tracks (background).
Opaque track rows hide them; only the beat dots show on tracks. */}
{/* Background SVG — beat lines only; major-tick gridlines removed so only
the ruler's own small ticks mark intervals (no full-height lines). */}
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
height={totalH}
>
{major.map((t) => {
const x = t * pps;
return (
<line
key={`g-${t}`}
x1={x}
y1={RULER_H}
x2={x}
y2={totalH}
stroke={theme.tickMinor}
strokeWidth="1"
/>
);
})}
{showBeats &&
beatTimes.map((t, i) => {
const x = t * pps;
Expand All @@ -79,41 +68,58 @@ export const TimelineRuler = memo(function TimelineRuler({
})}
</svg>

{/* Ruler. The bar fills the full panel width (canvas is min 100% wide);
calc(100% - GUTTER) equals trackContentWidth when zoomed in and extends
past the content when zoomed out. Ticks stay at composition coordinates. */}
{/* Ruler — sticky so the timestamps stay visible while the tracks scroll
vertically. Opaque background (plus the gutter corner block) so clips
scrolling underneath don't bleed through; z-index sits above the track
rows and drag overlays but below the playhead (z 100). */}
<div
className="relative overflow-hidden"
style={{
height: RULER_H,
marginLeft: GUTTER,
width: `calc(100% - ${GUTTER}px)`,
background: theme.gutterBackground,
borderBottom: `1px solid ${theme.rulerBorder}`,
}}
className="sticky top-0 flex"
style={{ height: RULER_H, width: GUTTER + trackContentWidth, zIndex: 70 }}
>
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
</div>
))}
<div
className="sticky left-0 z-[12] flex-shrink-0"
style={{
width: GUTTER,
// Ruler corner uses the panel surface — same as the ruler strip itself.
background: theme.shellBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
/>
<div
className="relative overflow-hidden"
style={{
height: RULER_H,
width: trackContentWidth,
// Ruler background = panel surface (#0A0A0B) — no bottom border,
// no tick lines (CapCut-style clean ruler, labels only).
background: theme.shellBackground,
}}
>
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
</div>
))}

{major.map((t) => (
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
<span
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
color: theme.tickText,
left: 5,
top: 5,
fontSize: 10,
}}
>
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
))}
{major.map((t) => (
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
<span
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
color: theme.tickText,
left: 5,
top: 5,
fontSize: 10,
}}
>
{timeDisplayMode === "frame"
? secondsToFrame(t)
: formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
))}
</div>
</div>
</>
);
Expand Down
7 changes: 6 additions & 1 deletion packages/studio/src/player/components/timelineTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,15 @@ const TRACK_STYLE: TimelineTrackStyle = {
};

export const defaultTimelineTheme: TimelineTheme = {
// Near-black card surfaces: the panels sit dark while the shell canvas
// between them is a step LIGHTER (#18181B), so the gaps read as visible
// seams around dark cards (CapCut-style).
shellBackground: "#0A0A0B",
shellBorder: "rgba(255,255,255,0.05)",
rulerBorder: "rgba(255,255,255,0.16)",
rowBackground: "#0B0C0F",
// All track lanes use a single uniform color — one step lighter than the panel
// surface (#0A0A0B) so lanes are visibly distinct from the ruler/shell.
rowBackground: "#101014",
rowBorder: "rgba(255,255,255,0.06)",
gutterBackground: "#0E0F12",
gutterBorder: "rgba(255,255,255,0.10)",
Expand Down
20 changes: 4 additions & 16 deletions packages/studio/src/player/components/useTimelinePlayhead.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef, useCallback, useEffect, useLayoutEffect } from "react";
import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
import { liveTime, type ZoomMode } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
import { getPinchTimelineZoomPercent } from "./timelineZoom";
import {
Expand Down Expand Up @@ -117,21 +117,9 @@ export function useTimelinePlayhead({
useMountEffect(() => {
const unsub = liveTime.subscribe((t) => {
if (!playheadRef.current || durationRef.current <= 0) return;
const playheadX = getTimelinePlayheadLeft(t, ppsRef.current);
playheadRef.current.style.left = `${playheadX}px`;
const scroll = scrollRef.current;
if (
scroll &&
!isDragging.current &&
usePlayerStore.getState().isPlaying &&
shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
) {
const edgeMargin = scroll.clientWidth * 0.12;
if (playheadX > scroll.scrollLeft + scroll.clientWidth - edgeMargin)
scroll.scrollLeft = playheadX - scroll.clientWidth * 0.15;
else if (playheadX < scroll.scrollLeft + GUTTER)
scroll.scrollLeft = Math.max(0, playheadX - GUTTER);
}
// Playback deliberately does NOT scroll the viewport to chase the playhead —
// the user's scroll position is theirs; the playhead may run off-screen.
playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current)}px`;
});
return unsub;
});
Expand Down
27 changes: 27 additions & 0 deletions packages/studio/src/styles/studio.css
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ body {
opacity: 1;
}

/* Audio clips read as a distinct kind — a persistent violet tint vs the teal-
accented visual clips — so the audio zone at the bottom is visually separate
at a glance (the timeline's kinds: visual on top, audio below). */
.timeline-clip.is-audio {
background-color: rgba(167, 139, 250, 0.16);
border-color: rgba(167, 139, 250, 0.4);
}

.timeline-clip.is-audio.is-hovered {
background-color: rgba(167, 139, 250, 0.24);
}

.timeline-clip.is-audio.is-dragging {
background-color: rgba(60, 52, 84, 0.96);
}

.timeline-clip.is-hovered {
background-color: rgba(255, 255, 255, 0.09);
}
Expand All @@ -158,9 +174,20 @@ body {
}

.timeline-clip.is-dragging {
/* Solid background so the picked-up clip reads clearly while dragging — audio
clips (waveform drawn on a separate layer) would otherwise look transparent. */
background-color: rgba(38, 42, 52, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

/* Keep the white selection outline while dragging (a plain box-shadow alone gets
overridden by .is-dragging's drop shadow, so re-state both together). */
.timeline-clip.is-selected.is-dragging {
box-shadow:
0 0 0 1.5px rgba(255, 255, 255, 0.85),
0 8px 24px rgba(0, 0, 0, 0.4);
}

.timeline-clip[data-active].is-selected.is-dragging {
box-shadow:
0 0 0 1.5px rgba(255, 255, 255, 0.85),
Expand Down
Loading