import { usePlayerStore } from "../store/playerStore"; import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice"; import { runtimeAudioId } from "../lib/timelineElementHelpers"; import { HF_AUDIO_FX_ATTR, serializeAudioFxChain, type HfAudioFxChain, } from "@hyperframes/core/audio-fx"; import type { TimelineTheme } from "./timelineTheme"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { TimelineGroupHeader } from "./TimelineGroupHeader"; import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip"; import { groupAutomationLanes } from "./automationLaneData"; import { groupAutomationElement } from "./groupAutomationElement"; import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels"; import { STRIP_H, TRACK_H } from "./timelineLayout"; import type { UseAutomationLanesResult } from "./useAutomationLanes"; import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext"; /** Accent rail on a group-owned lane — the same green the member rail uses, so * "this belongs to the group" reads the same in both places (groups doc §5). */ const GROUP_LANE_ACCENT = "#3CE6AC"; import { LABEL_COL_W } from "./timelineLayout"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; interface TimelineGroupRowProps { index: number; rowKey: number; group: TimelineTrackGroupInfo; logicalRow: TimelineLogicalRow; top: number; height: number; virtualized: boolean; contentOrigin: number; theme: TimelineTheme; rovingTargetId?: string | null; collapsedGroupIds: ReadonlySet; expandedLaneOwnerIds: ReadonlySet; toggleGroupExpanded: (id: string) => void; toggleLaneOwnerExpanded: (id: string) => void; lanes: UseAutomationLanesResult; pps: number; currentTime: number; /** A group's lanes are in composition time (§1.3), so this is their span. */ compositionDuration: number; beatTimes?: readonly number[]; contentGutter: number; trackContentWidth: number; } /** A group's own row: the accessible shell (shared with track rows) plus the group header. */ export function TimelineGroupRow({ index, rowKey, group, logicalRow, top, height, virtualized, contentOrigin, theme, rovingTargetId = null, collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded, lanes, pps, currentTime, compositionDuration, beatTimes, contentGutter, trackContentWidth, }: TimelineGroupRowProps) { // From the group, NOT from `tracks`: a collapsed group emits no member rows // into the display list, and every one of these reads silently degraded to // empty in that (default) state — half-lit solo went dark, the lane count // read 0, and the bus strip fell back to "track 1", "track 2". const memberElements = group.memberElements; // The group wearing a clip's shape so the lane machinery can render it — see // `groupAutomationElement` for why that beats a second, parallel lane path. const groupElement = groupAutomationElement(group, compositionDuration); // The binder writes through the dom-edit selection, so a group lane is // editable exactly when the group is the selected element — which clicking // its name in the header does. const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null; const isGroupSelected = domSelection?.id === group.id; const memberLabels = group.memberTracks.map((track, i) => { const owner = memberElements.find((el) => el.track === track && el.audioGroup); return owner?.label ?? owner?.id ?? `track ${i + 1}`; }); const isLaneOpen = expandedLaneOwnerIds.has(group.id); // Optional, like every sibling row: Timeline renders outside the edit // provider in read-only hosts (Timeline.test.ts asserts it), and the throwing // hook took the whole timeline down with it the moment a group existed — // not just this row. const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContextOptional(); const domEditActions = useDomEditActionsContextOptional(); const soloed = usePlayerStore((s) => s.soloed); const toggleSolo = usePlayerStore((s) => s.toggleSolo); // Bare DOM ids: this list is compared against the `soloed` set, which the // runtime matches on `el.id` (see `runtimeAudioId`). Store keys here made the // half-lit state unreachable — soloing a member lit nothing on its group. const memberIds = memberElements.map(runtimeAudioId).filter((id): id is string => id !== null); const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => { const value = next.nodes.length ? serializeAudioFxChain(next) : null; if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value); else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset"); }; // Hovering a preset on a muted bus is a question about the preset, not about // the mute — so the audition lifts the mute while it plays and puts it back // on the way out, the same borrow-and-return it already does with the // playhead. Live only: `data-hidden` stays in the document, so the row keeps // reading (and rendering) as muted throughout. const setGroupMutedLive = (muted: boolean) => onSetAudioGroupAttributeLive?.(group.id, "data-hidden", muted ? "" : null); const openGroupFxRack = () => { const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById( group.id, ); if (!target) return; void domEditActions ?.buildDomSelectionFromTarget(target) .then((selection) => selection && domEditActions.applyDomSelection(selection)); }; return ( toggleGroupExpanded(group.id)} // The GROUP's own lanes, not its members'. `∿` is per-row (groups doc // §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and // counting the members' here made the group advertise curves it does // not own and cannot show. laneCount={groupAutomationLanes([groupElement]).length} isLaneOpen={isLaneOpen} onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} hidden={group.hidden} onToggleHidden={() => onSetAudioGroupAttributeQuiet?.( group.id, "data-hidden", group.hidden ? null : "", group.hidden ? "Unmute group" : `Mute group ${group.label}`, ) } isSoloed={soloed.has(group.id)} isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)} onToggleSolo={(options) => toggleSolo(group.id, options)} fxChain={group.fxChain} onFxChainChange={(next) => writeGroupFxChain(next, false)} onFxChainPreview={(next) => writeGroupFxChain(next, true)} auditionSpans={memberElements} onSetMutedLive={setGroupMutedLive} onOpenFxRack={openGroupFxRack} // Same width as every other row's header. The group row needs a real // label column, but it gets one by turning `labelMode` on for the whole // timeline (see Timeline.tsx) rather than by overhanging alone — an // overhanging header paints opaquely across the rest of its row and // stays pinned there through horizontal scroll. columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} theme={theme} /> {isLaneOpen && ( onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value)) } onVolumeCommit={(value) => onSetAudioGroupAttributeQuiet?.( group.id, "data-volume", String(value), "Set group volume", ) } theme={theme} /> )} {/* The group's OWN curves, under the strip. Selected-gated exactly like a clip's: the binder writes through the dom-edit selection, so a lane is editable once the group is selected — which clicking its name does. */} {/* The label column for those lanes, on the accent rail. Outside the offset content cell below, because the labels belong to the sticky gutter the row header occupies, not to the scrolling canvas. */} {isLaneOpen && ( = LABEL_COL_W ? LABEL_COL_W : contentOrigin} gutterBackground={theme.gutterBackground} accentColor={GROUP_LANE_ACCENT} /> )} {isLaneOpen && ( // The same offset content cell a track row wraps its lanes in — the // slot positions absolutely, so mounted straight on the row it resolved // against the row instead and drew the envelope across the label gutter // from x=0.
isGroupSelected} lanes={lanes} pps={pps} // Below the strip, which sits directly under the header row. laneCount={0} topOffset={TRACK_H + STRIP_H} accentColor={GROUP_LANE_ACCENT} currentTime={currentTime} beatTimes={beatTimes} />
)}
); }