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 { 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";
interface TimelineTrackHeaderProps {
trackNumber: number;
trackLabel: 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;
theme: TimelineTheme;
onToggleClipExpanded: () => void;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
}
function VisibilityButton({
hidden,
trackNumber,
visible,
onToggle,
}: {
hidden: boolean;
trackNumber: number;
visible: boolean;
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
}) {
if (!visible) return ;
const label = hidden ? `Show track ${trackNumber}` : `Hide track ${trackNumber}`;
return (
event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
void onToggle?.(trackNumber, !hidden);
}}
>
{hidden ? (
) : (
)}
);
}
// 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,
trackLabel,
clipCount,
showTrackLabel,
isTrackHidden,
isAudioTrack,
onToggleTrackHidden,
}: Pick<
TimelineTrackHeaderProps,
| "trackNumber"
| "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 (
{
event.stopPropagation();
seekTo(navigation.prevKeyframe);
}}
>
‹
{children}
{
event.stopPropagation();
seekTo(navigation.nextKeyframe);
}}
>
›
);
}
function PropertyGroupHeaderRow({
lane,
laneIndex,
isLastLane,
expandedElement,
currentTime,
clipPercentage,
gutterBackground,
columnWidth,
onTogglePropertyGroupKeyframe,
onSeek,
}: {
lane: TimelinePropertyLane;
laneIndex: number;
isLastLane: boolean;
expandedElement: TimelineElement;
currentTime: number;
clipPercentage: number;
gutterBackground: string;
columnWidth: number;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
}) {
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
currentTime,
clipPercentage,
);
return (
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
{label}
{
// Same as the disclosure caret and the eye: a control in the label
// column owns its click, it does not also hit the track row behind it.
event.stopPropagation();
if (toggleTarget) {
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
}
}}
>
{navigation.currentKeyframe ? "◆" : "◇"}
{valueReadout(lane.group, values)}
);
}
export function TimelineTrackHeader({
trackNumber,
trackLabel,
contentOrigin,
keyframeClip,
clipCount,
isExpanded,
animations,
currentTime,
isTrackHidden,
isAudioTrack,
theme,
onToggleClipExpanded,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onSeek,
}: TimelineTrackHeaderProps) {
const clipPercentage = keyframeClip
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
: 0;
const lanes = keyframeClip
? getTimelinePropertyLanes(animations, keyframeClip.start, 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;
const lanesId = `timeline-lanes-track-${trackNumber}`;
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. */}
{/* Always mounted so the caret's aria-controls resolves in both states. */}
{isExpanded &&
lanes.map((lane, laneIndex) => (
))}
>
)}
);
}