diff --git a/packages/studio/src/player/store/automationSelectionSlice.test.ts b/packages/studio/src/player/store/automationSelectionSlice.test.ts new file mode 100644 index 0000000000..cb1af6c155 --- /dev/null +++ b/packages/studio/src/player/store/automationSelectionSlice.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { usePlayerStore } from "./playerStore"; + +describe("automationSelectionSlice", () => { + it("stores one ordered selection and clears it", () => { + const store = usePlayerStore.getState(); + store.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 1 }); + const sel = usePlayerStore.getState().automationSelection; + // Ordered on write, so every consumer can assume t0 < t1. + expect(sel).toEqual({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 }); + usePlayerStore.getState().clearAutomationSelection(); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/store/automationSelectionSlice.ts b/packages/studio/src/player/store/automationSelectionSlice.ts new file mode 100644 index 0000000000..8af1440237 --- /dev/null +++ b/packages/studio/src/player/store/automationSelectionSlice.ts @@ -0,0 +1,38 @@ +/** + * The active time selection on one automation lane. + * + * A store slice, not lane-local state, for the same reason keyframe selection + * is one: Delete/copy/paste handlers and the shape menu live outside the lane + * component and need to read it. Ephemeral by construction — nothing + * serializes store state, and the selection must never survive into a render. + */ +import type { StoreApi } from "zustand"; + +export interface AutomationSelection { + /** TimelineElement key (key ?? id) of the clip that owns the lane. */ + elementKey: string; + /** Lane target: "volume" or "fx..". */ + target: string; + /** Clip-local seconds; always t0 < t1 (ordered on write). */ + t0: number; + t1: number; +} + +export interface AutomationSelectionSlice { + automationSelection: AutomationSelection | null; + setAutomationSelection: (sel: AutomationSelection) => void; + clearAutomationSelection: () => void; +} + +export function createAutomationSelectionSlice( + set: StoreApi["setState"], +): AutomationSelectionSlice { + return { + automationSelection: null, + setAutomationSelection: (sel) => + set({ + automationSelection: sel.t0 <= sel.t1 ? sel : { ...sel, t0: sel.t1, t1: sel.t0 }, + }), + clearAutomationSelection: () => set({ automationSelection: null }), + }; +} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 275021f300..e6274ca347 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -10,6 +10,10 @@ import { } from "../../utils/studioUiPreferences"; import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom"; import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice"; +import { + createAutomationSelectionSlice, + type AutomationSelectionSlice, +} from "./automationSelectionSlice"; export type { KeyframeCacheEntry } from "./keyframeSlice"; @@ -40,7 +44,7 @@ function resolveElementSelection( }; } -interface PlayerState extends KeyframeSlice { +interface PlayerState extends KeyframeSlice, AutomationSelectionSlice { isPlaying: boolean; currentTime: number; duration: number; @@ -296,6 +300,8 @@ export const usePlayerStore = create((set, get) => ({ timelineSessionEpoch: get().timelineSessionEpoch, })), + ...createAutomationSelectionSlice(set), + activeKeyframePct: null, setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), motionPathArmed: false,