mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
feat(studio,core)!: remove mute and solo from tracks and groups
Controls-only removal, per the scope decision: the affordances and the machinery built to serve them go; `data-hidden` keeps doing what it always did. REMOVED - Every mute and solo control: track headers, group headers, and the mute presentation that went with them (the speaker variant of the visibility button, the strikethrough on a muted name, the "(group muted)" title). - Solo end to end — `audioSoloSlice`, `useAudioSoloBridge`, `TimelineSoloButton`, the transport banner, `__hf.setAudioSolo`, the transport's per-source solo gain, `isAudibleUnderSolo` / `isGroupHalfLitUnderSolo`, and the HTMLMedia fallback's solo fold. Four modules deleted outright. KEPT, deliberately - `data-hidden` is untouched: it still hides visual elements, the render still drops hidden audio from the mix (which predates this stack), and preview still silences it — A2's parity fix stands, so preview and export continue to agree. - Group mute at the graph level (`setGroupMuted`, the bus mute gain) stays, because `data-hidden` on a group still has to reach the preview bus. Only the button that wrote it is gone. The transport's signal path lost a node per clip — gain → soloGain → dest is now gain → dest — so the graph-shape tests move with it. Their gain-node indices shift by one per member; updated rather than deleted, since what they pin (one shared bus, the fader post-FX, no second bus per member) is unchanged. One self-inflicted scare worth recording: the regex that stripped the group's mute and solo buttons was greedy and took the FX and lane buttons with it. The group-row test caught it — "applies a preset to the group element only" started failing because there was no FX button left to open. Restored from HEAD. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, core 2387 green, studio 4326 green, full `bun run test` green.
This commit is contained in:
@@ -6,7 +6,6 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
import { Tooltip } from "../../components/ui";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
|
||||
import { ShortcutsPanel } from "./ShortcutsPanel";
|
||||
import { SpeedMenu } from "./SpeedMenu";
|
||||
import { VolumeControl } from "./VolumeControl";
|
||||
@@ -154,34 +153,6 @@ const FullscreenButton = memo(function FullscreenButton({
|
||||
);
|
||||
});
|
||||
|
||||
const SoloBanner = memo(function SoloBanner({
|
||||
previewIframeRef,
|
||||
}: {
|
||||
previewIframeRef: { current: HTMLIFrameElement | null };
|
||||
}) {
|
||||
const bannerText = useSoloBannerText(previewIframeRef);
|
||||
const clearSolo = usePlayerStore.getState().clearSolo;
|
||||
if (bannerText === null) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
|
||||
>
|
||||
<span>
|
||||
Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
|
||||
export is not affected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearSolo()}
|
||||
className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Main component ──────────────────────────────────────────────── */
|
||||
|
||||
interface PlayerControlsProps {
|
||||
@@ -190,7 +161,6 @@ interface PlayerControlsProps {
|
||||
disabled?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
onToggleFullscreen?: () => void;
|
||||
previewIframeRef?: { current: HTMLIFrameElement | null };
|
||||
}
|
||||
|
||||
export const PlayerControls = memo(function PlayerControls({
|
||||
@@ -199,7 +169,6 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
disabled = false,
|
||||
isFullscreen = false,
|
||||
onToggleFullscreen,
|
||||
previewIframeRef,
|
||||
}: PlayerControlsProps) {
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const duration = usePlayerStore((s) => s.duration);
|
||||
@@ -252,7 +221,6 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
|
||||
<div
|
||||
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
|
||||
aria-disabled={disabled || undefined}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
@@ -15,23 +14,13 @@ interface TimelineGroupHeaderProps {
|
||||
laneCount: number;
|
||||
isLaneOpen: boolean;
|
||||
onToggleLanes: () => void;
|
||||
/** The group element's own `data-hidden` — mutes every member at once. */
|
||||
hidden: boolean;
|
||||
onToggleHidden: () => void;
|
||||
/** This group id is itself in the soloed set (fully lit). */
|
||||
isSoloed: boolean;
|
||||
/** Not soloed itself, but at least one member is (half-lit). */
|
||||
isHalfLitSolo: boolean;
|
||||
/** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
|
||||
onToggleSolo: (options?: { add?: boolean }) => void;
|
||||
/** C1: the group's serialized `data-fx-chain`, when set. */
|
||||
fxChain?: string;
|
||||
onFxChainChange: (next: HfAudioFxChain) => void;
|
||||
onFxChainPreview?: (next: HfAudioFxChain) => void;
|
||||
/** Member clips, so hovering a preset auditions where the group sounds. */
|
||||
auditionSpans?: readonly AuditionSpan[];
|
||||
/** Set the group mute on the running graph only, so an audition can lift it. */
|
||||
onSetMutedLive?: (muted: boolean) => void;
|
||||
onOpenFxRack: () => void;
|
||||
columnWidth: number;
|
||||
theme: TimelineTheme;
|
||||
@@ -39,7 +28,7 @@ interface TimelineGroupHeaderProps {
|
||||
|
||||
/**
|
||||
* A group's own row header: caret (member disclosure) + `▤` + label + count +
|
||||
* mute + solo + FX + `∿ n` (lane disclosure).
|
||||
* FX + `∿ n` (lane disclosure).
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -51,12 +40,10 @@ interface TimelineGroupHeaderProps {
|
||||
function GroupNameButton({
|
||||
label,
|
||||
memberCount,
|
||||
hidden,
|
||||
onOpenFxRack,
|
||||
}: {
|
||||
label: string;
|
||||
memberCount: number;
|
||||
hidden: boolean;
|
||||
onOpenFxRack: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -75,13 +62,7 @@ function GroupNameButton({
|
||||
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
||||
▤
|
||||
</span>
|
||||
{/* Struck through, not merely dimmed — the designs are explicit that "a
|
||||
muted track that only looks dim is a track someone re-mutes by
|
||||
accident", and a muted GROUP silences every member at once, so it is
|
||||
the most expensive one to misread. */}
|
||||
<span className={`min-w-0 truncate font-medium${hidden ? " line-through" : ""}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-medium">{label}</span>
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
||||
aria-hidden="true"
|
||||
@@ -105,16 +86,10 @@ export function TimelineGroupHeader({
|
||||
laneCount,
|
||||
isLaneOpen,
|
||||
onToggleLanes,
|
||||
hidden,
|
||||
onToggleHidden,
|
||||
isSoloed,
|
||||
isHalfLitSolo,
|
||||
onToggleSolo,
|
||||
fxChain,
|
||||
onFxChainChange,
|
||||
onFxChainPreview,
|
||||
auditionSpans,
|
||||
onSetMutedLive,
|
||||
onOpenFxRack,
|
||||
columnWidth,
|
||||
theme,
|
||||
@@ -154,72 +129,16 @@ export function TimelineGroupHeader({
|
||||
▸
|
||||
</span>
|
||||
</button>
|
||||
<GroupNameButton
|
||||
label={label}
|
||||
memberCount={memberCount}
|
||||
hidden={hidden}
|
||||
onOpenFxRack={onOpenFxRack}
|
||||
/>
|
||||
<GroupNameButton label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
|
||||
</div>
|
||||
{/* Line two: what you can DO to it. Its own row so the name is not
|
||||
squeezed to a few characters by five controls sharing 232px. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={hidden ? "Unmute group" : `Mute group ${label}`}
|
||||
title={hidden ? "Unmute group" : `Mute group ${label}`}
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
||||
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/55 hover:text-white"
|
||||
}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleHidden();
|
||||
}}
|
||||
>
|
||||
{hidden ? (
|
||||
<SpeakerSlash size={14} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<SpeakerHigh size={14} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
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 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 });
|
||||
}}
|
||||
>
|
||||
{/* 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}
|
||||
onChainChange={onFxChainChange}
|
||||
onChainPreview={onFxChainPreview}
|
||||
auditionSpans={auditionSpans}
|
||||
isMuted={hidden}
|
||||
onSetMutedLive={onSetMutedLive}
|
||||
onOpenRack={onOpenFxRack}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
|
||||
import { runtimeAudioId } from "../lib/timelineElementHelpers";
|
||||
import {
|
||||
HF_AUDIO_FX_ATTR,
|
||||
serializeAudioFxChain,
|
||||
@@ -78,7 +75,7 @@ export function TimelineGroupRow({
|
||||
}: 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
|
||||
// empty in that (default) state — 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
|
||||
@@ -101,24 +98,11 @@ export function TimelineGroupRow({
|
||||
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,
|
||||
@@ -155,23 +139,10 @@ export function TimelineGroupRow({
|
||||
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
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* "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.
|
||||
*/
|
||||
export function TimelineSoloButton({
|
||||
isSoloed,
|
||||
onToggle,
|
||||
}: {
|
||||
isSoloed: boolean;
|
||||
onToggle: (options?: { add?: boolean }) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
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 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 });
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { type TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
@@ -757,20 +757,6 @@ describe("TimelineTrackHeader", () => {
|
||||
// The set is pushed straight into the runtime, which compares it against
|
||||
// `el.id`. A store key here matches nothing, `isAudibleUnderSolo` returns
|
||||
// false for every element, and soloing silences the whole preview.
|
||||
it("solos by bare DOM id, not by the store key", () => {
|
||||
enabledCanaries.add("audio-track-mute");
|
||||
const view = renderHeader({
|
||||
keyframeClip: VOICE,
|
||||
animations: [],
|
||||
expanded: false,
|
||||
isAudioTrack: true,
|
||||
});
|
||||
click(view.host, "Hear only this");
|
||||
expect([...usePlayerStore.getState().soloed]).toEqual(["voice-1"]);
|
||||
act(() => view.root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
// A member row is `aria-level="2"`, and without this it looked identical to
|
||||
// every top-level row — the nesting existed for a screen reader and not for
|
||||
// an eye. B2's design called for the accent rail; only the semantics shipped.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type HfAudioFxChain,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import { classifyAudioName } from "@hyperframes/core/audio-carve";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { type TimelineElement } from "../store/playerStore";
|
||||
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
@@ -434,16 +434,6 @@ export function TimelineTrackHeader({
|
||||
// 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
|
||||
@@ -525,11 +515,8 @@ export function TimelineTrackHeader({
|
||||
showTrackLabel={showTrackLabel}
|
||||
isTrackHidden={isTrackHidden}
|
||||
isAudioTrack={isAudioTrack}
|
||||
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
|
||||
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.
|
||||
// On the control line rather than a third row of its own.
|
||||
trailing={
|
||||
<>
|
||||
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
|
||||
@@ -606,7 +593,6 @@ export function TimelineTrackHeader({
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
visible={!isAudioTrack}
|
||||
isAudioTrack={isAudioTrack}
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
</LayerDisclosureRow>
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import type React from "react";
|
||||
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import { Music } from "../../icons/SystemIcons";
|
||||
import { TimelineSoloButton } from "./TimelineSoloButton";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { TrackClipCount } from "./TrackClipCount";
|
||||
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
||||
|
||||
// Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
|
||||
// Gated: the relabel ships behind the canary, unlike the preview fix.
|
||||
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
||||
if (showAsMute) return hidden ? "Muted" : "Mute";
|
||||
// Hide, plainly. The speaker variant was the mute presentation; with mute gone
|
||||
// this is the visibility eye it always was, and audio rows do not render it.
|
||||
function visibilityButtonLabel(hidden: boolean, suffix: string): string {
|
||||
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
||||
}
|
||||
|
||||
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
|
||||
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
|
||||
function visibilityButtonIcon(hidden: boolean) {
|
||||
const Icon = hidden ? EyeSlash : Eye;
|
||||
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
@@ -24,22 +21,19 @@ export function VisibilityButton({
|
||||
trackNumber,
|
||||
trackDisplayNumber,
|
||||
visible,
|
||||
isAudioTrack,
|
||||
onToggle,
|
||||
}: {
|
||||
hidden: boolean;
|
||||
trackNumber: number;
|
||||
trackDisplayNumber: number | null;
|
||||
visible: boolean;
|
||||
isAudioTrack?: boolean;
|
||||
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||
}) {
|
||||
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
||||
// Display number in the text, real key in the callback. The two must not be
|
||||
// conflated in either direction.
|
||||
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
||||
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
|
||||
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
||||
const label = visibilityButtonLabel(hidden, suffix);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -54,7 +48,7 @@ export function VisibilityButton({
|
||||
void onToggle?.(trackNumber, !hidden);
|
||||
}}
|
||||
>
|
||||
{visibilityButtonIcon(showAsMute, hidden)}
|
||||
{visibilityButtonIcon(hidden)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -69,9 +63,6 @@ export function PlainTrackHeader({
|
||||
showTrackLabel,
|
||||
isTrackHidden,
|
||||
isAudioTrack,
|
||||
isGroupMuted,
|
||||
isSoloed,
|
||||
onToggleSolo,
|
||||
onToggleTrackHidden,
|
||||
trailing,
|
||||
}: {
|
||||
@@ -83,9 +74,6 @@ export function PlainTrackHeader({
|
||||
isAudioTrack: boolean;
|
||||
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||
showTrackLabel: boolean;
|
||||
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;
|
||||
@@ -100,14 +88,7 @@ export function PlainTrackHeader({
|
||||
<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}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
|
||||
{trackLabel}
|
||||
</span>
|
||||
)}
|
||||
@@ -124,12 +105,8 @@ export function PlainTrackHeader({
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
visible={!isAudioTrack}
|
||||
isAudioTrack={isAudioTrack}
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
|
||||
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
|
||||
)}
|
||||
{trailing}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -19,7 +19,7 @@ function hasKeyframedTimelineClips(
|
||||
* for the same reason a keyframed clip does — a row whose name has nowhere else
|
||||
* to go. A track row survives a narrow gutter because its CLIPS carry the name
|
||||
* on the bar; a group row has no clips at all, so in the 80px gutter its label
|
||||
* rendered at zero width and its solo, FX and lane buttons were clipped off the
|
||||
* rendered at zero width and its FX and lane buttons were clipped off the
|
||||
* side.
|
||||
*
|
||||
* Widening the column for the whole timeline, rather than letting just the
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface TimelineTrackGroupInfo {
|
||||
*
|
||||
* Collapsing a group stops emitting its member rows into `tracks`, so anything
|
||||
* that recovered member elements by looking them up there got an empty list in
|
||||
* the default (collapsed) state — silently disabling half-lit solo, the
|
||||
* the default (collapsed) state — silently disabling the
|
||||
* automation-lane count, and the bus strip's member labels. Membership is not
|
||||
* a display concern, so it does not travel through the display list.
|
||||
*/
|
||||
|
||||
@@ -232,8 +232,8 @@ export function useTimelinePlayer() {
|
||||
} catch {}
|
||||
}, []);
|
||||
const applyPreviewAudioState = useCallback(() => {
|
||||
const { audioMuted, audioVolume, soloed } = usePlayerStore.getState();
|
||||
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume, soloed);
|
||||
const { audioMuted, audioVolume } = usePlayerStore.getState();
|
||||
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
|
||||
}, []);
|
||||
const play = useCallback(() => {
|
||||
stopRAFLoop();
|
||||
|
||||
@@ -12,12 +12,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
audioGroupOf,
|
||||
isAudibleUnderSolo,
|
||||
isGroupHalfLitUnderSolo,
|
||||
resolveAudioGroups,
|
||||
} from "@hyperframes/core/audio-groups";
|
||||
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
||||
import { parseTimelineFromDOM } from "./timelineDOM";
|
||||
import { runtimeAudioId } from "./timelineElementHelpers";
|
||||
|
||||
@@ -35,51 +30,6 @@ const COMPOSITION = `
|
||||
<hf-audio-group id="voiceover"></hf-audio-group>
|
||||
`;
|
||||
|
||||
describe("solo ids cross into the runtime", () => {
|
||||
it("keeps the soloed clip audible and silences the rest", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const elements = parseTimelineFromDOM(doc, 30);
|
||||
const voice1 = elements.find((el) => el.domId === "voice-1");
|
||||
expect(voice1).toBeDefined();
|
||||
// The store key is NOT the runtime's id space — that is the whole point.
|
||||
expect(voice1?.key).not.toBe("voice-1");
|
||||
|
||||
const soloTargetId = runtimeAudioId(voice1 ?? {});
|
||||
expect(soloTargetId).toBe("voice-1");
|
||||
const soloed = new Set([soloTargetId as string]);
|
||||
|
||||
const audible = (id: string) => {
|
||||
const el = doc.getElementById(id);
|
||||
expect(el).not.toBeNull();
|
||||
return isAudibleUnderSolo(soloed, (el as Element).id, audioGroupOf(el as Element));
|
||||
};
|
||||
expect(audible("voice-1")).toBe(true);
|
||||
expect(audible("music-bed")).toBe(false);
|
||||
// Soloing a member does not open its sibling — group solo is the other button.
|
||||
expect(audible("voice-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("soloing the group opens every member", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const group = resolveAudioGroups(doc)[0];
|
||||
const soloed = new Set([group.id]);
|
||||
for (const id of group.memberIds) {
|
||||
const el = doc.getElementById(id) as Element;
|
||||
expect(isAudibleUnderSolo(soloed, el.id, audioGroupOf(el))).toBe(true);
|
||||
}
|
||||
const bed = doc.getElementById("music-bed") as Element;
|
||||
expect(isAudibleUnderSolo(soloed, bed.id, audioGroupOf(bed))).toBe(false);
|
||||
});
|
||||
|
||||
it("a composite key matches nothing — the regression this file exists for", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const voice1 = parseTimelineFromDOM(doc, 30).find((el) => el.domId === "voice-1");
|
||||
const soloed = new Set([voice1?.key ?? ""]);
|
||||
const el = doc.getElementById("voice-1") as Element;
|
||||
expect(isAudibleUnderSolo(soloed, el.id, audioGroupOf(el))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("group membership ids cross into the runtime", () => {
|
||||
it("the ids the timeline hands to onGroupClips are the ids resolveAudioGroups reads back", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
@@ -96,25 +46,7 @@ describe("group membership ids cross into the runtime", () => {
|
||||
for (const id of memberIds) expect(clipIds).toContain(id);
|
||||
});
|
||||
|
||||
// TimelineGroupRow's half-lit state ("some of what's under here still
|
||||
// plays") compares its member list against the same soloed set. Built from
|
||||
// store keys it never matched, so soloing a member lit nothing on its group.
|
||||
it("half-lights the group when one member is soloed", () => {
|
||||
const doc = docWith(COMPOSITION);
|
||||
const members = parseTimelineFromDOM(doc, 30).filter((el) => el.audioGroup === "voiceover");
|
||||
const memberIds = members.map(runtimeAudioId).filter((id): id is string => id !== null);
|
||||
expect(memberIds).toEqual(["voice-1", "voice-2"]);
|
||||
|
||||
const soloed = new Set(["voice-1"]);
|
||||
expect(isGroupHalfLitUnderSolo(soloed, "voiceover", memberIds)).toBe(true);
|
||||
// Store keys are the shape that silently failed.
|
||||
const storeKeys = members.map((el) => el.key ?? el.id);
|
||||
expect(isGroupHalfLitUnderSolo(soloed, "voiceover", storeKeys)).toBe(false);
|
||||
// Soloing the group itself is lit, not half-lit.
|
||||
expect(isGroupHalfLitUnderSolo(new Set(["voiceover"]), "voiceover", memberIds)).toBe(false);
|
||||
});
|
||||
|
||||
it("an element with no DOM id is not groupable or soloable", () => {
|
||||
it("an element with no DOM id is not groupable", () => {
|
||||
const doc = docWith(`
|
||||
<div data-composition-id="root" data-duration="10"></div>
|
||||
<audio data-start="0" data-duration="5"></audio>
|
||||
|
||||
@@ -361,13 +361,12 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
|
||||
* Studio addresses rows by `buildTimelineElementKey`'s composite
|
||||
* `<sourceFile>#<domId>`, but everything audio in `@hyperframes/core` keys off
|
||||
* the live document: `resolveAudioGroups` collects `member.id`,
|
||||
* `isAudibleUnderSolo` compares `el.id`, `resolveCarveSourceIds` and
|
||||
* `resolveSoloLabel` both go through `getElementById`. Anything crossing into
|
||||
* that space — a solo id, a group membership list, a carve source — has to be
|
||||
* `resolveCarveSourceIds` goes through `getElementById`. Anything crossing into
|
||||
* that space — a group membership list, a carve source — has to be
|
||||
* converted here first; a composite key silently matches nothing.
|
||||
*
|
||||
* `null` for a row with no DOM id at all (selector-addressed elements): such an
|
||||
* element cannot be soloed or grouped, because `resolveAudioGroups` skips
|
||||
* element cannot be grouped, because `resolveAudioGroups` skips
|
||||
* members without an `id` and would build a group that is half there.
|
||||
*/
|
||||
export function runtimeAudioId(element: { domId?: string | null }): string | null {
|
||||
|
||||
@@ -94,9 +94,6 @@ describe("applyPreviewAudioFlags", () => {
|
||||
const calls: Record<string, unknown[]> = {};
|
||||
const win = {
|
||||
__hf: {
|
||||
setAudioSolo: (ids: readonly string[]) => {
|
||||
calls.solo = [...ids];
|
||||
},
|
||||
setCanaries: (states: Record<string, boolean>) => {
|
||||
calls.canaries = [states];
|
||||
},
|
||||
@@ -111,23 +108,13 @@ describe("applyPreviewAudioFlags", () => {
|
||||
}
|
||||
|
||||
// Everything pushed here is state the runtime loses on reload and nothing
|
||||
// else re-sends: the solo bridge's effect deps do not change across a
|
||||
// reload, so the button stayed lit while every track played.
|
||||
it("re-pushes the whole audio state, solo included", () => {
|
||||
// else re-sends, so the push has to carry all of it every time.
|
||||
it("re-pushes the whole audio state", () => {
|
||||
const { iframe, calls } = fakeIframe();
|
||||
|
||||
applyPreviewAudioFlags(iframe, false, 1, new Set(["voice-1"]));
|
||||
applyPreviewAudioFlags(iframe, false, 1);
|
||||
|
||||
expect(calls.solo).toEqual(["voice-1"]);
|
||||
// Every runtime-visible flag in one push, each resolved by the host.
|
||||
expect(calls.canaries?.[0]).toMatchObject({ "audio-track-mute": expect.any(Boolean) });
|
||||
});
|
||||
|
||||
it("pushes an empty solo set rather than skipping the call", () => {
|
||||
const { iframe, calls } = fakeIframe();
|
||||
|
||||
applyPreviewAudioFlags(iframe, false, 1, new Set());
|
||||
|
||||
expect(calls.solo).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,23 +167,9 @@ function setPreviewCanaries(iframe: HTMLIFrameElement | null): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Replace the runtime's soloed set. Same channel `useAudioSoloBridge` uses for
|
||||
* live changes; repeated here because the bridge's effect deps do not change
|
||||
* across a preview reload, so it never re-fires and the reloaded runtime would
|
||||
* keep an empty set while the button stays lit. */
|
||||
function setPreviewSolo(iframe: HTMLIFrameElement | null, ids: readonly string[]): void {
|
||||
if (!iframe) return;
|
||||
try {
|
||||
const win = iframe.contentWindow as
|
||||
| (Window & { __hf?: { setAudioSolo?: (ids: readonly string[]) => void } })
|
||||
| null;
|
||||
win?.__hf?.setAudioSolo?.(ids);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the preview runtime has to be told about audio after it loads:
|
||||
* the transport's mute, the session's solo set, and the canary flags core
|
||||
* the transport's mute and the canary flags core
|
||||
* cannot resolve for itself. Called from `applyPreviewAudioState`, which is the
|
||||
* path that re-runs after a preview reload — the runtime comes back with every
|
||||
* one of these at its default and nothing else pushes them again.
|
||||
@@ -192,14 +178,12 @@ export function applyPreviewAudioFlags(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
muted: boolean,
|
||||
volume: number,
|
||||
soloed: ReadonlySet<string>,
|
||||
): void {
|
||||
setPreviewMediaMuted(iframe, muted);
|
||||
// Volume too: the transport comes back at unity after a reload, so a preview
|
||||
// the author had turned down came back loud.
|
||||
setPreviewMediaVolume(iframe, volume);
|
||||
setPreviewCanaries(iframe);
|
||||
setPreviewSolo(iframe, [...soloed]);
|
||||
}
|
||||
|
||||
export function setPreviewPlaybackRate(
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore } from "./playerStore";
|
||||
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "./audioSoloSlice";
|
||||
import * as studioFileHistory from "../../utils/studioFileHistory";
|
||||
|
||||
describe("audioSoloSlice", () => {
|
||||
it("is exclusive by default, and clicking the only soloed element again clears it", () => {
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a"]));
|
||||
usePlayerStore.getState().toggleSolo("b");
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
||||
usePlayerStore.getState().toggleSolo("b");
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("⌘/Ctrl-click adds and removes membership without disturbing the rest", () => {
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a", "b"]));
|
||||
usePlayerStore.getState().toggleSolo("a", { add: true });
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
||||
});
|
||||
|
||||
it("clearSolo empties the set", () => {
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
usePlayerStore.getState().clearSolo();
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("export-safety: solo never touches an attribute or the save path", () => {
|
||||
it("toggling, ⌘-adding, and clearing solo never calls setAttribute/removeAttribute on any element", () => {
|
||||
usePlayerStore.getState().clearSolo();
|
||||
const setAttributeSpy = vi.spyOn(Element.prototype, "setAttribute");
|
||||
const removeAttributeSpy = vi.spyOn(Element.prototype, "removeAttribute");
|
||||
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||
usePlayerStore.getState().toggleSolo("a", { add: true });
|
||||
usePlayerStore.getState().clearSolo();
|
||||
|
||||
expect(setAttributeSpy).not.toHaveBeenCalled();
|
||||
expect(removeAttributeSpy).not.toHaveBeenCalled();
|
||||
setAttributeSpy.mockRestore();
|
||||
removeAttributeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("toggling, ⌘-adding, and clearing solo never invokes the project save path (setElementsHidden's own write function)", () => {
|
||||
usePlayerStore.getState().clearSolo();
|
||||
const saveSpy = vi.spyOn(studioFileHistory, "saveProjectFilesWithHistory");
|
||||
|
||||
usePlayerStore.getState().toggleSolo("a");
|
||||
usePlayerStore.getState().toggleSolo("b", { add: true });
|
||||
usePlayerStore.getState().clearSolo();
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
saveSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAudibleUnderSolo — the three-bullet rule", () => {
|
||||
it("everything is audible when no solo is active", () => {
|
||||
expect(isAudibleUnderSolo(new Set(), "clip-a")).toBe(true);
|
||||
expect(isAudibleUnderSolo(new Set(), "clip-a", "group-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("a soloed element is audible", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("a sibling of a soloed element is silent", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b")).toBe(false);
|
||||
});
|
||||
|
||||
it("a member of a soloed group is audible (group solo = members solo)", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["group-1"]), "clip-a", "group-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("a member of an UNsoloed group, while a sibling group is soloed, is silent", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["group-2"]), "clip-a", "group-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("soloing one member of a group does not un-attenuate its siblings in the same group", () => {
|
||||
// clip-a is soloed directly; clip-b shares its group but is not itself
|
||||
// soloed and the group itself is not soloed — clip-b stays silent.
|
||||
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b", "group-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("the group itself, monitored as a bus, is audible only when it or a member is soloed", () => {
|
||||
expect(isAudibleUnderSolo(new Set(["clip-a"]), "group-1", null)).toBe(false);
|
||||
expect(isAudibleUnderSolo(new Set(["group-1"]), "group-1", null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGroupHalfLitUnderSolo", () => {
|
||||
it("is false when no solo is active", () => {
|
||||
expect(isGroupHalfLitUnderSolo(new Set(), "group-1", ["a", "b"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the group itself is soloed (fully lit, not half)", () => {
|
||||
expect(isGroupHalfLitUnderSolo(new Set(["group-1"]), "group-1", ["a", "b"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when a member is soloed but the group is not", () => {
|
||||
expect(isGroupHalfLitUnderSolo(new Set(["a"]), "group-1", ["a", "b"])).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when an unrelated element is soloed", () => {
|
||||
expect(isGroupHalfLitUnderSolo(new Set(["other"]), "group-1", ["a", "b"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("solo does not outlive its composition", () => {
|
||||
// Solo ids only mean anything against the document they were taken from.
|
||||
// Carried into another composition they match nothing, and "match nothing"
|
||||
// is exactly the state that silences every track while the banner still
|
||||
// claims to be hearing one of them.
|
||||
it("is cleared by the timeline reset that a composition switch runs", () => {
|
||||
usePlayerStore.getState().toggleSolo("voice-1");
|
||||
expect(usePlayerStore.getState().soloed.size).toBe(1);
|
||||
|
||||
usePlayerStore.getState().reset();
|
||||
|
||||
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* "Hear only this" — session-only monitoring override, never persisted.
|
||||
*
|
||||
* Holds clip ids and group ids (never track numbers, per the design doc: solo
|
||||
* is a per-element/per-group concept, not a row-position one). Exclusive by
|
||||
* default (a plain toggle replaces the set); ⌘/Ctrl-click adds/removes one
|
||||
* member without disturbing the rest. Never written to any attribute or
|
||||
* document — the export-safety guarantee this slice exists to hold.
|
||||
*/
|
||||
import type { StoreApi } from "zustand";
|
||||
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "@hyperframes/core/audio-groups";
|
||||
|
||||
export { isAudibleUnderSolo, isGroupHalfLitUnderSolo };
|
||||
|
||||
export interface AudioSoloSlice {
|
||||
soloed: ReadonlySet<string>;
|
||||
toggleSolo: (id: string, options?: { add?: boolean }) => void;
|
||||
clearSolo: () => void;
|
||||
}
|
||||
|
||||
export function createAudioSoloSlice(
|
||||
set: StoreApi<AudioSoloSlice>["setState"],
|
||||
get: StoreApi<AudioSoloSlice>["getState"],
|
||||
): AudioSoloSlice {
|
||||
return {
|
||||
soloed: new Set(),
|
||||
toggleSolo: (id, options) => {
|
||||
const current = get().soloed;
|
||||
if (options?.add) {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
set({ soloed: next });
|
||||
return;
|
||||
}
|
||||
// Exclusive click: soloing the only-soloed element again clears it;
|
||||
// otherwise it replaces the set.
|
||||
const isOnlyMember = current.size === 1 && current.has(id);
|
||||
set({ soloed: isOnlyMember ? new Set() : new Set([id]) });
|
||||
},
|
||||
clearSolo: () => set({ soloed: new Set() }),
|
||||
};
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "./automationSelectionSlice";
|
||||
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
||||
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
|
||||
import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice";
|
||||
|
||||
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
||||
export { liveTime } from "./liveTime";
|
||||
@@ -48,8 +47,7 @@ function resolveElementSelection(
|
||||
};
|
||||
}
|
||||
|
||||
interface PlayerState
|
||||
extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, AudioSoloSlice {
|
||||
interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -278,7 +276,6 @@ export function createTimelineResetState() {
|
||||
automationSelection: null,
|
||||
expandedClipIds: new Set<string>(),
|
||||
// Per-composition: ids from comp A match nothing in B, silencing all of it.
|
||||
soloed: new Set<string>(),
|
||||
collapsedGroupIds: new Set<string>(),
|
||||
expandedLaneOwnerIds: new Set<string>(),
|
||||
focusedEaseSegment: null,
|
||||
@@ -331,7 +328,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
...createThumbnailSlice(set),
|
||||
|
||||
...createAutomationSelectionSlice(set),
|
||||
...createAudioSoloSlice(set, get),
|
||||
|
||||
activeKeyframePct: null,
|
||||
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
||||
|
||||
Reference in New Issue
Block a user