From ebb7410061bdcabe9ce2dc6b49a7d6c6b53c0ac4 Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Fri, 27 Mar 2026 14:22:19 +0800 Subject: [PATCH] feat: add follow actions for session clips and scenes Implement follow actions system that allows clips to automatically trigger other clips when they finish playing, similar to Ableton Live. - Add FollowActionType, FollowActionConfig types to project.ts - Add followAction field to SessionClipSlot, followActionsEnabled to SessionState - Add 'follow-action' to SessionPendingLaunch type union - Create src/utils/followActions.ts with clip group detection, action resolution, and probability rolling utilities - Add store actions: setSessionSlotFollowAction, setSessionFollowActionsEnabled, scheduleFollowAction - Handle follow-action in commitPendingSessionLaunches - Add UI: global toggle button, follow action badge on clips, context menu with action A/B selectors, chance slider, and time input - 29 comprehensive unit tests covering utilities and store actions Closes #920 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/session/SessionView.tsx | 127 ++++++++++++- src/store/projectStore.ts | 104 +++++++++++ src/types/project.ts | 25 ++- src/utils/followActions.ts | 107 +++++++++++ tests/unit/followActions.test.ts | 236 +++++++++++++++++++++++++ tests/unit/followActionsStore.test.ts | 194 ++++++++++++++++++++ 6 files changed, 791 insertions(+), 2 deletions(-) create mode 100644 src/utils/followActions.ts create mode 100644 tests/unit/followActions.test.ts create mode 100644 tests/unit/followActionsStore.test.ts diff --git a/src/components/session/SessionView.tsx b/src/components/session/SessionView.tsx index 87cb684c..4007da1b 100644 --- a/src/components/session/SessionView.tsx +++ b/src/components/session/SessionView.tsx @@ -9,7 +9,7 @@ import { useSessionDragDrop, type SessionDragState, type SessionDropTarget } fro import { ContextMenuWrapper, ContextMenuSeparator, ContextMenuItem } from '../ui/ContextMenu'; import { ColorSwatchPalette } from '../ui/ColorSwatchPalette'; import { SessionMixer } from './SessionMixer'; -import type { Clip, Track, SessionLaunchQuantization, SessionLaunchMode, SessionClipSlot, SessionPendingLaunch, SessionScene, SceneFollowActionType } from '../../types/project'; +import type { Clip, Track, SessionLaunchQuantization, SessionLaunchMode, SessionClipSlot, SessionPendingLaunch, SessionScene, SceneFollowActionType, FollowActionType, FollowActionConfig } from '../../types/project'; const LAUNCH_MODE_OPTIONS: SessionLaunchMode[] = ['trigger', 'gate', 'toggle', 'repeat']; @@ -80,8 +80,20 @@ interface SlotContextMenuState { currentColor: string | null; legato: boolean; currentLaunchMode: SessionLaunchMode; + followAction?: FollowActionConfig; } +const CLIP_FOLLOW_ACTION_OPTIONS: { value: FollowActionType; label: string }[] = [ + { value: 'stop', label: 'Stop' }, + { value: 'again', label: 'Again' }, + { value: 'previous', label: 'Previous' }, + { value: 'next', label: 'Next' }, + { value: 'first', label: 'First' }, + { value: 'last', label: 'Last' }, + { value: 'any', label: 'Any' }, + { value: 'other', label: 'Other' }, +]; + export function SessionView() { const project = useProjectStore((s) => s.project); @@ -94,6 +106,8 @@ export function SessionView() { const setSessionSlotColor = useProjectStore((s) => s.setSessionSlotColor); const setSessionSlotLegato = useProjectStore((s) => s.setSessionSlotLegato); const setSessionSlotLaunchMode = useProjectStore((s) => s.setSessionSlotLaunchMode); + const setSessionSlotFollowAction = useProjectStore((s) => s.setSessionSlotFollowAction); + const setSessionFollowActionsEnabled = useProjectStore((s) => s.setSessionFollowActionsEnabled); const selectedSessionSlot = useUIStore((s) => s.selectedSessionSlot); const setSelectedSessionSlot = useUIStore((s) => s.setSelectedSessionSlot); const setKeyboardContext = useUIStore((s) => s.setKeyboardContext); @@ -135,6 +149,20 @@ export function SessionView() { } }, [colorMenu, setSessionSlotLaunchMode]); + const handleFollowActionChange = useCallback((field: string, value: string | number | boolean) => { + if (!colorMenu) return; + setSessionSlotFollowAction(colorMenu.slotId, { [field]: value }); + // Update local state to reflect change immediately + setColorMenu((prev) => { + if (!prev) return null; + const defaultFA: FollowActionConfig = { actionA: 'next', actionB: 'stop', chanceA: 1, time: 4, enabled: true }; + return { + ...prev, + followAction: { ...(prev.followAction ?? defaultFA), [field]: value }, + }; + }); + }, [colorMenu, setSessionSlotFollowAction]); + // Set keyboard context to 'session' on mount, restore previous on unmount useEffect(() => { const previousScope = useUIStore.getState().keyboardContext.scope; @@ -166,6 +194,7 @@ export function SessionView() { const sessionSlots = project.session?.slots ?? []; const pendingLaunches = project.session?.pendingLaunches ?? []; const scenes = project.session?.scenes ?? []; + const followActionsEnabled = project.session?.followActionsEnabled !== false; return (
@@ -190,6 +219,17 @@ export function SessionView() { ))} +