import { Eye, EyeSlash } from "@phosphor-icons/react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { Music } from "../../icons/SystemIcons";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
import { TrackClipCount } from "./TrackClipCount";
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";
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;
/** Id of the canvas-side lanes element the disclosure caret expands. Minted by
* TimelineLanes, which is the one place that sees both subtrees. */
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;
/** 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;
rovingTargetId?: string | null;
theme: TimelineTheme;
onToggleClipExpanded: () => void;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
}
function VisibilityButton({
hidden,
trackNumber,
trackDisplayNumber,
visible,
onToggle,
}: {
hidden: boolean;
trackNumber: number;
trackDisplayNumber: number | null;
visible: boolean;
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
}) {
if (!visible) return ;
// Display number in the text, real key in the callback. The two must not be
// conflated in either direction.
const suffix = trackDisplaySuffix(trackDisplayNumber);
const label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
return (
);
}
// The header a track gets when it has no keyframe clip to disclose: label, clip
// count, eye. Not deprecated — it is the live path for every track without lanes.
function PlainTrackHeader({
trackNumber,
trackDisplayNumber,
trackLabel,
clipCount,
showTrackLabel,
isTrackHidden,
isAudioTrack,
onToggleTrackHidden,
}: Pick<
TimelineTrackHeaderProps,
| "trackNumber"
| "trackDisplayNumber"
| "trackLabel"
| "clipCount"
| "isTrackHidden"
| "isAudioTrack"
| "onToggleTrackHidden"
> & { showTrackLabel: boolean }) {
return (
<>
{isAudioTrack && (
)}
{showTrackLabel && (
{trackLabel}
)}
{showTrackLabel && }
>
);
}
// 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)}
);
}
export function TimelineTrackHeader({
trackNumber,
trackDisplayNumber,
trackLabel,
lanesId,
contentOrigin,
keyframeClip,
clipCount,
isExpanded,
animations,
currentTime,
isTrackHidden,
isAudioTrack,
theme,
onToggleClipExpanded,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
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;
const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
return (
{!keyframeClip || lanes.length === 0 ? (
) : (
<>
{/* 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) => (
))}
>
)}