Skip to content
Open
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
13 changes: 13 additions & 0 deletions packages/studio/src/hooks/useMusicBeatAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { resolveBeatSourceTrack } from "../utils/timelineInspector";
import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
import { mergeUserBeats } from "../utils/beatEditing";
import { getTimelineElementIndexes } from "../player/lib/timelineElementIndexes";
import { remapBeatAnalysisToComposition } from "../utils/beatEditActions";
import {
audioRelPathForSrc,
beatFilePathForSrc,
Expand Down Expand Up @@ -90,6 +92,17 @@ async function loadBeatAnalysis(
}
}

/** The current beat analysis, remapped onto the composition's edited beat grid. */
export function useAdjustedBeatAnalysis() {
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
const beatEdits = usePlayerStore((s) => s.beatEdits);
return useMemo(
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
[beatAnalysis, musicElement, beatEdits],
);
}

export function useMusicBeatAnalysis(): void {
const elements = usePlayerStore((s) => s.elements);
const setBeatAnalysis = usePlayerStore((s) => s.setBeatAnalysis);
Expand Down
25 changes: 12 additions & 13 deletions packages/studio/src/player/components/LayerDisclosureRow.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { CaretRight } from "@phosphor-icons/react";
import { TRACK_H } from "./timelineLayout";
import { TrackClipCount } from "./TrackClipCount";

// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
// here, not on the clip bar, and re-expands a collapsed layer.
// Layer row (Figma order: disclosure ∿, diamond, name) — the disclosure lives
// here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
// caret) because a group's own row keeps the caret for its structural
// disclosure (member rows) — this button only ever means "show this row's
// lanes", so it needs its own distinct glyph.
export function LayerDisclosureRow({
name,
clipCount,
Expand Down Expand Up @@ -49,23 +51,20 @@ export function LayerDisclosureRow({
tabIndex={-1}
aria-expanded={isExpanded}
aria-controls={lanesId}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px;
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
title={`${isExpanded ? "Hide" : "Show"} lanes`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
// only the hit box grows.
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleClipExpanded();
}}
>
<CaretRight
size={11}
weight="bold"
aria-hidden="true"
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
/>
<span aria-hidden="true">∿</span>
</button>
{/* Decorative: the disclosure button above already names the row's keyframe
state, and aria-label on a plain span is not exposed reliably anyway. */}
Expand Down
14 changes: 7 additions & 7 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,11 +556,11 @@ describe("Timeline provider boundary", () => {
// Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure
// lives in the left column. clip-2 has no keyframes so it never shows one.
const collapseButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="Collapse clip-1 keyframes"]',
'button[aria-label="Hide clip-1 lanes"]',
);
expect(collapseButton).not.toBeNull();
expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull();
expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull();
expect(host.querySelector('button[aria-label="Show clip-2 lanes"]')).toBeNull();
expect(host.querySelector('button[aria-label="Hide clip-2 lanes"]')).toBeNull();

const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
const row = clip?.parentElement?.parentElement;
Expand All @@ -571,7 +571,7 @@ describe("Timeline provider boundary", () => {
expectTrackExpansion(row, [], TRACK_H);

const expandButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="Expand clip-1 keyframes"]',
'button[aria-label="Show clip-1 lanes"]',
);
expect(expandButton).not.toBeNull();
act(() => expandButton?.click());
Expand Down Expand Up @@ -603,8 +603,8 @@ describe("Timeline provider boundary", () => {
const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
?.parentElement;
// A row of several clips is named for the track, so the caret is too.
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]');
expect(caret()?.getAttribute("aria-label")).toBe("Expand Track 1 keyframes");
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]');
expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes");

act(() => caret()?.click());
// One shared volume row, and BOTH clips hold it open.
Expand Down Expand Up @@ -647,7 +647,7 @@ describe("Timeline provider boundary", () => {
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]')?.click());
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());

const before = [...host.querySelectorAll(".hf-automation-lane")];
expect(before).toHaveLength(2);
Expand Down
17 changes: 7 additions & 10 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useRef, useMemo, useCallback, useState, memo } from "react";
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
import { defaultTimelineTheme } from "./timelineTheme";
Expand Down Expand Up @@ -39,7 +38,6 @@ import {
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
Expand Down Expand Up @@ -109,13 +107,7 @@ export const Timeline = memo(function Timeline({
useMusicBeatAnalysis();
const rawElements = usePlayerStore((s) => s.elements);
const expandedElements = useExpandedTimelineElements();
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
const beatEdits = usePlayerStore((s) => s.beatEdits);
const adjustedBeatAnalysis = useMemo(
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
[beatAnalysis, musicElement, beatEdits],
);
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
const duration = usePlayerStore((s) => s.duration);
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const timelineReady = usePlayerStore((s) => s.timelineReady);
Expand Down Expand Up @@ -158,6 +150,8 @@ export const Timeline = memo(function Timeline({
laneCounts,
rowGeometry,
rowGeometryRef,
groups,
trackGroupOf,
} = useTimelineTrackLayout(
expandedElements,
gsapAnimations,
Expand Down Expand Up @@ -289,6 +283,8 @@ export const Timeline = memo(function Timeline({
laneCounts,
selectedElementId,
selectedElementIds,
groups,
trackGroupOf,
gsapAnimations,
elements: expandedElements,
pixelsPerSecond: pps,
Expand Down Expand Up @@ -509,6 +505,7 @@ export const Timeline = memo(function Timeline({
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
groups={groups}
laneCounts={laneCounts}
selectedElementId={selectedElementId}
selectedElementIds={selectedElementIds}
Expand Down
101 changes: 101 additions & 0 deletions packages/studio/src/player/components/TimelineGroupHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";

interface TimelineGroupHeaderProps {
label: string;
memberCount: number;
/** Caret: shows/hides the member rows beneath this group (structural). */
isExpanded: boolean;
onToggleExpanded: () => void;
/** `∿`: shows/hides the group's own automation-lane rows. */
laneCount: number;
isLaneOpen: boolean;
onToggleLanes: () => void;
columnWidth: number;
theme: TimelineTheme;
}

/**
* A group's own row header: caret (member disclosure) + `▤` + label + `∿ n`
* (lane disclosure). Mute/solo (B5) and the FX entry point (C1) land here as
* siblings once those steps exist — nothing to reserve for them yet.
*/
export function TimelineGroupHeader({
label,
memberCount,
isExpanded,
onToggleExpanded,
laneCount,
isLaneOpen,
onToggleLanes,
columnWidth,
theme,
}: TimelineGroupHeaderProps) {
return (
<div
role="rowheader"
aria-colindex={1}
className="sticky left-0 z-[12] flex shrink-0 items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
style={{
width: columnWidth,
height: TRACK_H,
color: "#ffffff",
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
<button
type="button"
tabIndex={-1}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
title={`${isExpanded ? "Hide" : "Show"} tracks`}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-white" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleExpanded();
}}
>
<span aria-hidden="true" style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}>
</span>
</button>
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
</span>
<span className="min-w-0 flex-1 truncate font-medium" title={label}>
{label}
</span>
<span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true"
title={`${memberCount} tracks`}
>
{memberCount}
</span>
<button
type="button"
tabIndex={-1}
aria-expanded={isLaneOpen}
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleLanes();
}}
>
<span aria-hidden="true">∿</span>
{laneCount > 0 && (
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
)}
</button>
</div>
);
}
77 changes: 77 additions & 0 deletions packages/studio/src/player/components/TimelineGroupRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { TimelineElement } from "../store/playerStore";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { TimelineGroupHeader } from "./TimelineGroupHeader";
import { groupAutomationLanes } from "./automationLaneData";
import { LABEL_COL_W } from "./timelineLayout";

interface TimelineGroupRowProps {
index: number;
rowKey: number;
group: TimelineTrackGroupInfo;
logicalRow: TimelineLogicalRow;
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
top: number;
height: number;
virtualized: boolean;
contentOrigin: number;
theme: TimelineTheme;
rovingTargetId?: string | null;
expandedGroupIds: ReadonlySet<string>;
expandedLaneOwnerIds: ReadonlySet<string>;
toggleGroupExpanded: (id: string) => void;
toggleLaneOwnerExpanded: (id: string) => void;
}

/** A group's own row: the accessible shell (shared with track rows) plus the group header. */
export function TimelineGroupRow({
index,
rowKey,
group,
logicalRow,
tracks,
top,
height,
virtualized,
contentOrigin,
theme,
rovingTargetId = null,
expandedGroupIds,
expandedLaneOwnerIds,
toggleGroupExpanded,
toggleLaneOwnerExpanded,
}: TimelineGroupRowProps) {
const memberElements = group.memberTracks.flatMap(
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
);
return (
<TimelineTrackRow
index={index}
rowKey={rowKey}
logicalRow={logicalRow}
propertyRows={[]}
lanesId=""
headerLanesId=""
top={top}
height={height}
virtualized={virtualized}
background={theme.rowBackground}
borderColor={theme.rowBorder}
rovingTargetId={rovingTargetId}
>
<TimelineGroupHeader
label={group.label}
memberCount={group.memberTracks.length}
isExpanded={expandedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
laneCount={groupAutomationLanes(memberElements).length}
isLaneOpen={expandedLaneOwnerIds.has(group.id)}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
</TimelineTrackRow>
);
}
5 changes: 5 additions & 0 deletions packages/studio/src/player/components/TimelineLanes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ function renderLanes(options: RenderLanesOptions = {}): {
selectedElementId: null,
selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []),
expandedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
gsapAnimations,
})}
clipIndex={createTimelineClipIndex(tracks)}
Expand All @@ -127,6 +131,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
trackOrder={displayTrackOrder}
tracks={tracks}
trackStyles={new Map()}
groups={[]}
laneCounts={laneCounts}
selectedElementId={null}
selectedElementIds={next.selectedElementIds ?? new Set()}
Expand Down
Loading
Loading