import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
HF_AUDIO_FX_ATTR,
serializeAudioFxChain,
type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import { classifyAudioName } from "@hyperframes/core/audio-carve";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
import { isCanaryEnabled } from "../../telemetry/canary";
import { TimelineFxButton } from "./TimelineFxButton";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { groupAutomationLanes } from "./automationLaneData";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import {
resolveLaneHeaderState,
type KeyframeNavigationState,
type TimelinePropertyLane,
} from "./trackHeaderLaneState";
import { valueReadout } from "./trackHeaderLaneValues";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
/** Accent rail + inset marking a row as a group MEMBER, matching the level-2
* nesting its `aria-level` already reports. */
const GROUP_MEMBER_RAIL = "#3CE6AC59";
const GROUP_MEMBER_INDENT = 14;
interface TimelineTrackHeaderProps {
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
* never shown or announced. */
trackNumber: number;
/** The track's 1-based position in the rendered order: the only number safe
* to put in a label. Announcing `trackNumber` read out "track
* 0.16666666666666666". Null when the key has no row, which drops the number
* from the label rather than inventing one (see trackDisplayNumber). */
trackDisplayNumber: number | null;
trackLabel: string;
/** Ids of the canvas-side lane regions the disclosure caret expands, space
* separated as `aria-controls` takes them: the active clip's keyframe lanes
* and the track's automation lanes are separate elements, one owned by a
* clip and one by the row. Minted by TimelineLanes, the one place that sees
* every subtree. */
lanesId: string;
contentOrigin: number;
/** The track's active keyframe clip (selected, else primary) — the one whose
* disclosure + property rows this header shows, whether expanded or not. */
keyframeClip: TimelineElement | null;
/** Every clip on this track. Automation rows are the track's, unioned over
* these, so they stop changing with the selection. */
trackElements: readonly TimelineElement[];
/** Clips on this track, so the header can say how many the row holds. */
clipCount: number;
isExpanded: boolean;
animations: readonly GsapAnimation[];
currentTime: number;
isTrackHidden: boolean;
isAudioTrack: boolean;
/** This track is a member of an audio group — indents the row under its header. */
isGroupMember?: boolean;
rovingTargetId?: string | null;
theme: TimelineTheme;
onToggleClipExpanded: () => void;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
/** Drop one envelope. Absent while the lanes are read-only, which is what
* hides the control rather than offering a button that cannot act. */
onRemoveAutomationLane?: (target: string) => void;
onSeek?: (time: number) => void;
}
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
function PropertyGroupNavigation({
navigation,
label,
expandedElement,
onSeek,
children,
}: {
navigation: KeyframeNavigationState;
label: string;
expandedElement: TimelineElement;
onSeek?: (time: number) => void;
children: React.ReactNode;
}) {
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
// target is met with a centered transparent ::before overlay instead of a
// bigger box; focus-visible matches every other control in this header.
const CHEVRON_BUTTON_CLASS =
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
const seekTo = (keyframe: { percentage: number } | null) => {
if (keyframe) {
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
}
};
return (
{children}
);
}
function PropertyGroupHeaderRow({
lanesId,
lane,
laneIndex,
isLastLane,
expandedElement,
currentTime,
clipPercentage,
gutterBackground,
columnWidth,
onTogglePropertyGroupKeyframe,
onSeek,
rovingTargetId = null,
}: {
lanesId: string;
lane: TimelinePropertyLane;
laneIndex: number;
isLastLane: boolean;
expandedElement: TimelineElement;
currentTime: number;
clipPercentage: number;
gutterBackground: string;
columnWidth: number;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
rovingTargetId: string | null;
}) {
const elementId = expandedElement.key ?? expandedElement.id;
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
currentTime,
clipPercentage,
);
return (
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
{label}
{valueReadout(lane.group, values)}
);
}
/**
* One envelope's row in the label column.
*
* Named here rather than inside the lane, on the same tree connector the
* keyframe rows use: an automation lane is a child of its clip exactly as a
* property group is, and drawing its name over the envelope put the label on top
* of the curve it describes and scrolled it away from its own row.
*/
function AutomationLaneHeaderRow({
target,
label,
name,
param,
top,
isLastLane,
gutterBackground,
columnWidth,
onRemove,
}: {
/** The lane the ACTIVE clip draws in this row, or null when it draws none —
* the row belongs to the property, and a clip may be absent from it. */
target: string | null;
/** The whole thing on one line: the row's identity, its tooltip, and the
* remove button's name. */
label: string;
/** What the effect is — "Peaking EQ 1.6 kHz". */
name: string;
/** Which knob the envelope drives. Empty when there is no second line to draw. */
param: string;
top: number;
isLastLane: boolean;
gutterBackground: string;
columnWidth: number;
onRemove?: (target: string) => void;
}) {
return (
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
tick at the name's own height. */}
{/* Two lines: what the effect is, then which knob the envelope drives. On
one line a band's own name was the first thing truncated in a column this
narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
bands apart. */}
{name}
{param ? (
{param}
) : null}
{/* Beside the name it labels, because that is the only place an envelope is
named at all: a carve writes its own lanes, and the FX panel's automate
toggle can only reach a parameter it still lists — so without this an
envelope could be created and never removed.
Only for the clip the header is showing, since that is the only one a
write can reach: a shared row's other envelopes belong to clips that
are not selected, and a button that silently removed one of them (or
none) would be worse than no button. */}
{onRemove && target !== null && (
)}
);
}
// fallow-ignore-next-line complexity
export function TimelineTrackHeader({
trackNumber,
trackDisplayNumber,
trackLabel,
lanesId,
contentOrigin,
keyframeClip,
trackElements,
clipCount,
isExpanded,
animations,
currentTime,
isTrackHidden,
isAudioTrack,
isGroupMember = false,
theme,
onToggleClipExpanded,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onRemoveAutomationLane,
onSeek,
rovingTargetId = null,
}: TimelineTrackHeaderProps) {
const clipPercentage = keyframeClip
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
: 0;
const lanes = keyframeClip
? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
// host-absolute while its tweens are local to its own file.
getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
: [];
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
const showTrackLabel = contentOrigin >= LABEL_COL_W;
// One row per automated property across the whole track, in the order the
// canvas draws them — a name beside the wrong envelope is worse than an awkward
// order. `target` is the ACTIVE clip's lane in that row, which is the only one
// the remove button can write to; null when the row belongs to its siblings.
const activeKey = keyframeClip ? (keyframeClip.key ?? keyframeClip.id) : null;
const automationRows = groupAutomationLanes(trackElements).map((group) => ({
key: group.key,
label: group.key,
name: group.name,
param: group.param,
target:
group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane
.target ?? null,
}));
// Automation counts as something to disclose: gating the caret on tweens alone
// left an audio clip's envelopes unreachable, since the track could not expand.
const disclosable = lanes.length > 0 || automationRows.length > 0;
const isKeyframeLayer = !!keyframeClip && disclosable;
// Solo is per-clip/per-group, never per track (design doc §2.2) — this header
// acts on the track's first clip as a pragmatic stand-in for "this track",
// the same simplification the mute button doesn't need to make (it patches
// every clip on the track at once).
// A bare DOM id, not the store key: the set lands in the runtime, which
// compares it against `el.id` (see `runtimeAudioId`). A track whose first
// clip has no DOM id simply has no solo button.
const soloTargetId = trackElements[0] ? runtimeAudioId(trackElements[0]) : null;
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
// C1: the FX entry point. A single audio clip has one chain to point at; a
// track holding several ungrouped ones has no single chain — the design
// doc refuses to build "N clips = N chains", so that case gets a pointer
// at grouping (B6's normative rule) instead of a popover.
const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional();
const singleAudioClip =
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
};
const openClipFxRack = (clip: TimelineElement) => {
void domEditActions?.handleTimelineElementSelect(clip);
};
// DOM ids, matching the carve picker's other caller — membership is read back
// by `resolveAudioGroups`, which only ever sees the document. A clip with no
// DOM id cannot be a member (resolveAudioGroups skips it), so a track holding
// one cannot be grouped WHOLE — and grouping the rest would quietly leave
// those clips outside the bus, past every fader, mute and effect, while the
// UI showed the track as grouped. The button is withheld instead of acting on
// a subset, which is also why the carve path's loud guard cannot catch this:
// the unresolvable ids were filtered out before the call.
const groupableClipIds = trackElements.map(runtimeAudioId);
const canGroupWholeTrack =
groupableClipIds.length >= 2 && groupableClipIds.every((id) => id !== null);
const groupUngroupedClips = () => {
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
if (!doc || !onGroupClips) return;
if (!canGroupWholeTrack) return;
void onGroupClips(groupableClipIds as string[], mintGroupId(doc));
};
return (
{!keyframeClip || !disclosable ? (
<>
el.audioGroupHidden)}
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
onToggleTrackHidden={onToggleTrackHidden}
/>
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
writeClipFxChain(singleAudioClip, next, false)}
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
// Muted, an audition is silent — so the hover lifts the mute on
// the running graph and puts it back on the way out, the same
// borrow-and-return it already does with the playhead.
auditionSpans={[singleAudioClip]}
isMuted={isTrackHidden}
onSetMutedLive={(muted) =>
onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null)
}
onOpenRack={() => openClipFxRack(singleAudioClip)}
/>
)}
{/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES
a group, so it needs `audio-groups` too — without it a user outside
that canary could create a group and then have no UI to manage it. */}
{isAudioTrack &&
clipCount > 1 &&
!isTrackGrouped &&
canGroupWholeTrack &&
isCanaryEnabled("audio-fx-rack") &&
isCanaryEnabled("audio-groups") && (
)}
>
) : (
<>
1
? `Track${trackDisplaySuffix(trackDisplayNumber)}`
: (keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id)
}
clipCount={clipCount}
isExpanded={isExpanded}
gutterBackground={theme.gutterBackground}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
lanesId={lanesId}
onToggleClipExpanded={onToggleClipExpanded}
>
{/* The eye belongs to the LAYER, so it lives on the always-mounted
layer row exactly like a plain track's. Hanging it off a lane row
(hover-gated, and only while expanded) left a keyframed track with
no way to be hidden at all by keyboard, and put the control on a
row it does not act on. */}
{/* The caret expands TWO disjoint subtrees: these label-column rows,
which carry the per-lane keyframe controls, and the diamond lanes
on the canvas. `lanesId` names the canvas lanes (rendered by
TimelineLanes), because that is what a sighted user watches appear
and what following the reference has to land on. These rows are not
empty and are not the target; they are absolutely positioned inside
the sticky column, which is what made a wrapper HERE compute to
0x0 and hold no diamonds. */}
{isExpanded &&
lanes.map((lane, laneIndex) => (
))}
{/* Below the keyframe rows and stepping by its own height, which is how
TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
two have to agree or a name labels the wrong curve. */}
{isExpanded &&
automationRows.map((row, index) => (
))}
>
)}