mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): two-line track headers, and a real solo button
Extends the group header's split to every track row: line one is what the row IS (the audio glyph, the name, the clip count), line two is what you can do to it (mute, solo, FX). Same reason as the group — a name and four controls sharing 232px truncated the name to a few characters. The FX entry points were rendered by the parent AFTER the header component, so they would have landed on a third line. `PlainTrackHeader` takes a `trailing` slot for them instead: the caller still owns them, because only it knows the clip they act on, but they sit on the control line where they belong. Solo is a boxed `S` rather than `⌗`. That glyph is not solo anywhere — the letter in a box is what an author who has met a DAW is looking for, and it is what the designs draw beside `M` and `FX`. Filled when on, outlined when off, because a colour change alone does not read at a glance across the track column. The group's copy keeps three states, not two: filled when the group itself is soloed, half-lit when a MEMBER is — the affordance for "this bus is passing audio, but I did not solo it" (groups doc §2.2). Verified live: soloing Vo 1 gives the member `border-[#F5C542] bg-[#F5C542]`, its group `border-[#F5C542]/60 bg-[#F5C542]/25`, and an unrelated sibling `border-white/30`. One test walked `button.parentElement.parentElement` to reach the row and broke on the extra level; it climbs from the rowheader now instead of counting. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, studio suite 4349 green.
This commit is contained in:
@@ -433,7 +433,10 @@ describe("Timeline provider boundary", () => {
|
||||
button.click();
|
||||
});
|
||||
|
||||
const row = button.parentElement?.parentElement;
|
||||
// Up from the rowheader rather than counting parents: the header now lays
|
||||
// its name and its controls out on two lines, so the button sits one level
|
||||
// deeper than it used to.
|
||||
const row = button.closest('[role="rowheader"]')?.parentElement;
|
||||
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
|
||||
const trackContent = row?.children.item(1);
|
||||
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
|
||||
|
||||
@@ -190,20 +190,28 @@ export function TimelineGroupHeader({
|
||||
aria-pressed={isSoloed}
|
||||
aria-label="Hear only this"
|
||||
title="Hear only this"
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
isSoloed
|
||||
? "text-[#F5C542] hover:text-white"
|
||||
: isHalfLitSolo
|
||||
? "text-[#F5C542]/50 hover:text-white"
|
||||
: "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleSolo({ add: event.metaKey || event.ctrlKey });
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">⌗</span>
|
||||
{/* Three states, not two: filled when this group is soloed, and
|
||||
HALF-lit when a member is — the affordance for "this bus is
|
||||
passing audio, but I did not solo it" (groups doc §2.2). */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex h-[15px] w-[15px] items-center justify-center rounded-[3px] border text-[10px] font-bold leading-none transition-colors ${
|
||||
isSoloed
|
||||
? "border-[#F5C542] bg-[#F5C542] text-black"
|
||||
: isHalfLitSolo
|
||||
? "border-[#F5C542]/60 bg-[#F5C542]/25 text-[#F5C542]"
|
||||
: "border-white/30 text-white/45 hover:border-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
S
|
||||
</span>
|
||||
</button>
|
||||
<TimelineFxButton
|
||||
fxChainRaw={fxChain}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* "Hear only this" — the `⌗` toggle beside a track's mute control. Session
|
||||
* "Hear only this" — the boxed `S` beside a track's mute control, which is what
|
||||
* a solo button looks like in every DAW an author might have met. Session
|
||||
* state only (see `audioSoloSlice`): a plain click is exclusive, ⌘/Ctrl-click
|
||||
* toggles membership without disturbing the rest of the set.
|
||||
*/
|
||||
@@ -17,16 +18,25 @@ export function TimelineSoloButton({
|
||||
aria-pressed={isSoloed}
|
||||
aria-label="Hear only this"
|
||||
title="Hear only this"
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
isSoloed ? "text-[#F5C542] hover:text-white" : "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle({ add: event.metaKey || event.ctrlKey });
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">⌗</span>
|
||||
{/* Filled when on, outlined when off — the state has to read at a glance
|
||||
from across the track column, and a colour change alone does not. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex h-[15px] w-[15px] items-center justify-center rounded-[3px] border text-[10px] font-bold leading-none transition-colors ${
|
||||
isSoloed
|
||||
? "border-[#F5C542] bg-[#F5C542] text-black"
|
||||
: "border-white/30 text-white/45 hover:border-white/60 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
S
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -493,7 +493,7 @@ export function TimelineTrackHeader({
|
||||
className={`sticky left-0 z-[12] shrink-0 ${
|
||||
!isKeyframeLayer
|
||||
? showTrackLabel
|
||||
? "flex items-center gap-1 px-1.5 text-white/55"
|
||||
? "flex flex-col justify-center gap-0.5 px-1.5 text-white/55"
|
||||
: "flex flex-col items-center justify-center gap-0.5"
|
||||
: ""
|
||||
}`}
|
||||
@@ -529,25 +529,30 @@ export function TimelineTrackHeader({
|
||||
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
|
||||
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
// On the control line, beside mute and solo — not a third row.
|
||||
trailing={
|
||||
<>
|
||||
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={singleAudioClip.fxChain}
|
||||
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
|
||||
onChainChange={(next) => 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={singleAudioClip.fxChain}
|
||||
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
|
||||
onChainChange={(next) => 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. */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from "react";
|
||||
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { Music } from "../../icons/SystemIcons";
|
||||
@@ -72,6 +73,7 @@ export function PlainTrackHeader({
|
||||
isSoloed,
|
||||
onToggleSolo,
|
||||
onToggleTrackHidden,
|
||||
trailing,
|
||||
}: {
|
||||
trackNumber: number;
|
||||
trackDisplayNumber: number | null;
|
||||
@@ -84,41 +86,52 @@ export function PlainTrackHeader({
|
||||
isGroupMuted: boolean;
|
||||
isSoloed: boolean;
|
||||
onToggleSolo?: (options?: { add?: boolean }) => void;
|
||||
/** Trailing controls that belong on the control line — the FX entry points,
|
||||
* which the caller owns because only it knows the clip they act on. */
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{isAudioTrack && (
|
||||
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
||||
)}
|
||||
{showTrackLabel && (
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-[11px] ${
|
||||
isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
|
||||
? "line-through"
|
||||
: ""
|
||||
}`}
|
||||
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
|
||||
>
|
||||
{trackLabel}
|
||||
</span>
|
||||
)}
|
||||
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
||||
{/* Not on an audio track. The control is the old visibility eye, and on
|
||||
{/* Line one: what the row IS. Line two (below) is what you can do to it —
|
||||
the same split the group header uses, for the same reason: a name and
|
||||
four controls sharing 232px truncated the name to a few characters. */}
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{isAudioTrack && (
|
||||
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
||||
)}
|
||||
{showTrackLabel && (
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-[11px] ${
|
||||
isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
|
||||
? "line-through"
|
||||
: ""
|
||||
}`}
|
||||
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
|
||||
>
|
||||
{trackLabel}
|
||||
</span>
|
||||
)}
|
||||
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Not on an audio track. The control is the old visibility eye, and on
|
||||
audio it silences rather than hides — but a row that already says what
|
||||
it is with a speaker does not also need the hide affordance sitting in
|
||||
the eye's slot. `visible={false}` rather than omitting the element, so
|
||||
the spacer keeps every row's control columns aligned. */}
|
||||
<VisibilityButton
|
||||
hidden={isTrackHidden}
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
visible={!isAudioTrack}
|
||||
isAudioTrack={isAudioTrack}
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
|
||||
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
|
||||
)}
|
||||
<VisibilityButton
|
||||
hidden={isTrackHidden}
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
visible={!isAudioTrack}
|
||||
isAudioTrack={isAudioTrack}
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
|
||||
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
|
||||
)}
|
||||
{trailing}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user