feat: persist global track visibility status and metronome status
This commit is contained in:
@@ -58,6 +58,10 @@ const storeState = {
|
|||||||
addAudioTrack: vi.fn(),
|
addAudioTrack: vi.fn(),
|
||||||
projectName: 'Test Project',
|
projectName: 'Test Project',
|
||||||
savedProjectName: 'Test Project',
|
savedProjectName: 'Test Project',
|
||||||
|
showGlobalTracks: false,
|
||||||
|
setShowGlobalTracks: vi.fn((show: boolean) => {
|
||||||
|
storeState.showGlobalTracks = show;
|
||||||
|
}),
|
||||||
requestPianoRollScroll: vi.fn(),
|
requestPianoRollScroll: vi.fn(),
|
||||||
mainContentScrollRequest: null,
|
mainContentScrollRequest: null,
|
||||||
activeTrackAutomationTrackId: null,
|
activeTrackAutomationTrackId: null,
|
||||||
@@ -82,6 +86,11 @@ const finishTrackCreateDialogClose = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleGlobalTracksAndRerender = (rerender: (ui: React.ReactNode) => void) => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||||
|
rerender(<MainContent />);
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('../stores/projectStore', () => ({
|
vi.mock('../stores/projectStore', () => ({
|
||||||
useProjectStore: (selector?: StoreSelector) => (
|
useProjectStore: (selector?: StoreSelector) => (
|
||||||
selector ? selector(storeState) : storeState
|
selector ? selector(storeState) : storeState
|
||||||
@@ -160,6 +169,7 @@ describe('MainContent', () => {
|
|||||||
storeState.selectedRegionIds = [];
|
storeState.selectedRegionIds = [];
|
||||||
storeState.activeRegionId = null;
|
storeState.activeRegionId = null;
|
||||||
storeState.showPianoRoll = false;
|
storeState.showPianoRoll = false;
|
||||||
|
storeState.showGlobalTracks = false;
|
||||||
storeState.playheadPosition = 0;
|
storeState.playheadPosition = 0;
|
||||||
storeState.timeSignature = { numerator: 4, denominator: 4 };
|
storeState.timeSignature = { numerator: 4, denominator: 4 };
|
||||||
storeState.clearAllSelections.mockClear();
|
storeState.clearAllSelections.mockClear();
|
||||||
@@ -171,6 +181,7 @@ describe('MainContent', () => {
|
|||||||
storeState.openSpectrogramViewer.mockClear();
|
storeState.openSpectrogramViewer.mockClear();
|
||||||
storeState.addTrack.mockClear();
|
storeState.addTrack.mockClear();
|
||||||
storeState.addAudioTrack.mockClear();
|
storeState.addAudioTrack.mockClear();
|
||||||
|
storeState.setShowGlobalTracks.mockClear();
|
||||||
executeCommandMock.mockClear();
|
executeCommandMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -385,29 +396,35 @@ describe('MainContent', () => {
|
|||||||
expect(screen.queryByRole('dialog', { name: 'Create New Track' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: 'Create New Track' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggles the mock global tracks button active state locally', () => {
|
it('toggles the persisted global tracks button active state through the store', () => {
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
const globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
|
let globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
|
||||||
|
|
||||||
expect(globalTracksButton.className).not.toContain('active');
|
expect(globalTracksButton.className).not.toContain('active');
|
||||||
|
|
||||||
fireEvent.click(globalTracksButton);
|
fireEvent.click(globalTracksButton);
|
||||||
|
expect(storeState.setShowGlobalTracks).toHaveBeenCalledWith(true);
|
||||||
|
rerender(<MainContent />);
|
||||||
|
globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
|
||||||
expect(globalTracksButton.className).toContain('active');
|
expect(globalTracksButton.className).toContain('active');
|
||||||
|
|
||||||
fireEvent.click(globalTracksButton);
|
fireEvent.click(globalTracksButton);
|
||||||
|
expect(storeState.setShowGlobalTracks).toHaveBeenCalledWith(false);
|
||||||
|
rerender(<MainContent />);
|
||||||
|
globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
|
||||||
expect(globalTracksButton.className).not.toContain('active');
|
expect(globalTracksButton.className).not.toContain('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the four mock global tracks only when toggled on', () => {
|
it('renders the four global tracks only when the persisted toggle is on', () => {
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
expect(screen.queryByText('Marker')).not.toBeInTheDocument();
|
expect(screen.queryByText('Marker')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Tempo')).not.toBeInTheDocument();
|
expect(screen.queryByText('Tempo')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Key Signature')).not.toBeInTheDocument();
|
expect(screen.queryByText('Key Signature')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
|
|
||||||
expect(screen.getByText('Marker')).toBeInTheDocument();
|
expect(screen.getByText('Marker')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Tempo')).toBeInTheDocument();
|
expect(screen.getByText('Tempo')).toBeInTheDocument();
|
||||||
@@ -416,7 +433,7 @@ describe('MainContent', () => {
|
|||||||
|
|
||||||
const globalTracksInfoShell = screen.getByRole('button', { name: 'Add Marker global track item' }).closest('.global-tracks-info-shell') as HTMLElement;
|
const globalTracksInfoShell = screen.getByRole('button', { name: 'Add Marker global track item' }).closest('.global-tracks-info-shell') as HTMLElement;
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.transitionEnd(globalTracksInfoShell);
|
fireEvent.transitionEnd(globalTracksInfoShell);
|
||||||
|
|
||||||
expect(screen.queryByText('Marker')).not.toBeInTheDocument();
|
expect(screen.queryByText('Marker')).not.toBeInTheDocument();
|
||||||
@@ -427,9 +444,9 @@ describe('MainContent', () => {
|
|||||||
|
|
||||||
it('routes the tempo and chord global track add buttons through commands', () => {
|
it('routes the tempo and chord global track add buttons through commands', () => {
|
||||||
storeState.playheadPosition = 5;
|
storeState.playheadPosition = 5;
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Tempo global track item' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Add Tempo global track item' }));
|
||||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -450,10 +467,10 @@ describe('MainContent', () => {
|
|||||||
]);
|
]);
|
||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' }));
|
fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.click(screen.getByText('Am'));
|
fireEvent.click(screen.getByText('Am'));
|
||||||
|
|
||||||
expect(storeState.selectedRegionIds).toEqual(['chord-1']);
|
expect(storeState.selectedRegionIds).toEqual(['chord-1']);
|
||||||
@@ -471,9 +488,9 @@ describe('MainContent', () => {
|
|||||||
]);
|
]);
|
||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.click(screen.getByText('Am'));
|
fireEvent.click(screen.getByText('Am'));
|
||||||
fireEvent.click(screen.getByText('G'), { shiftKey: true });
|
fireEvent.click(screen.getByText('G'), { shiftKey: true });
|
||||||
|
|
||||||
@@ -492,9 +509,9 @@ describe('MainContent', () => {
|
|||||||
]);
|
]);
|
||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
|
|
||||||
const firstChord = screen.getByText('Am');
|
const firstChord = screen.getByText('Am');
|
||||||
const lastChord = screen.getByText('G');
|
const lastChord = screen.getByText('G');
|
||||||
@@ -524,9 +541,9 @@ describe('MainContent', () => {
|
|||||||
]);
|
]);
|
||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.click(screen.getByText('Am'));
|
fireEvent.click(screen.getByText('Am'));
|
||||||
fireEvent.click(screen.getByText('128 BPM'), { metaKey: true });
|
fireEvent.click(screen.getByText('128 BPM'), { metaKey: true });
|
||||||
|
|
||||||
@@ -548,9 +565,9 @@ describe('MainContent', () => {
|
|||||||
]);
|
]);
|
||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.click(screen.getByText('Am'));
|
fireEvent.click(screen.getByText('Am'));
|
||||||
fireEvent.click(screen.getByText('140 BPM'), { shiftKey: true });
|
fireEvent.click(screen.getByText('140 BPM'), { shiftKey: true });
|
||||||
|
|
||||||
@@ -566,9 +583,9 @@ describe('MainContent', () => {
|
|||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
storeState.playheadPosition = 3;
|
storeState.playheadPosition = 3;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chord global track item' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Add Chord global track item' }));
|
||||||
|
|
||||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -586,9 +603,9 @@ describe('MainContent', () => {
|
|||||||
]);
|
]);
|
||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.doubleClick(screen.getByText('Am'));
|
fireEvent.doubleClick(screen.getByText('Am'));
|
||||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
||||||
|
|
||||||
@@ -605,9 +622,9 @@ describe('MainContent', () => {
|
|||||||
]);
|
]);
|
||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.doubleClick(screen.getByText('Am'));
|
fireEvent.doubleClick(screen.getByText('Am'));
|
||||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
||||||
|
|
||||||
@@ -626,9 +643,9 @@ describe('MainContent', () => {
|
|||||||
]);
|
]);
|
||||||
storeState.globalTracks = globalTracks;
|
storeState.globalTracks = globalTracks;
|
||||||
|
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.doubleClick(screen.getByText('G'));
|
fireEvent.doubleClick(screen.getByText('G'));
|
||||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true });
|
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true });
|
||||||
|
|
||||||
@@ -639,18 +656,18 @@ describe('MainContent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('routes the signature global track add button through a command', () => {
|
it('routes the signature global track add button through a command', () => {
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Key Signature global track item' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Add Key Signature global track item' }));
|
||||||
|
|
||||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates new marker regions with a one-bar default length', () => {
|
it('creates new marker regions with a one-bar default length', () => {
|
||||||
render(<MainContent />);
|
const { rerender } = render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Marker global track item' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Add Marker global track item' }));
|
||||||
|
|
||||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -672,13 +689,13 @@ describe('MainContent', () => {
|
|||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { container } = render(<MainContent />);
|
const { container, rerender } = render(<MainContent />);
|
||||||
const mainContent = container.querySelector('.main-content') as HTMLDivElement;
|
const mainContent = container.querySelector('.main-content') as HTMLDivElement;
|
||||||
Object.defineProperty(mainContent, 'scrollWidth', { configurable: true, value: 4000 });
|
Object.defineProperty(mainContent, 'scrollWidth', { configurable: true, value: 4000 });
|
||||||
Object.defineProperty(mainContent, 'clientWidth', { configurable: true, value: 900 });
|
Object.defineProperty(mainContent, 'clientWidth', { configurable: true, value: 900 });
|
||||||
mainContent.scrollLeft = 500;
|
mainContent.scrollLeft = 500;
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
toggleGlobalTracksAndRerender(rerender);
|
||||||
fireEvent.doubleClick(screen.getByText('Am'));
|
fireEvent.doubleClick(screen.getByText('Am'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
addAudioTrack,
|
addAudioTrack,
|
||||||
projectName,
|
projectName,
|
||||||
savedProjectName,
|
savedProjectName,
|
||||||
|
showGlobalTracks,
|
||||||
|
setShowGlobalTracks,
|
||||||
requestPianoRollScroll,
|
requestPianoRollScroll,
|
||||||
mainContentScrollRequest,
|
mainContentScrollRequest,
|
||||||
activeTrackAutomationTrackId,
|
activeTrackAutomationTrackId,
|
||||||
@@ -84,7 +86,6 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
|
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
|
||||||
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
|
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
|
||||||
const [showCreateTrackModal, setShowCreateTrackModal] = useState(false);
|
const [showCreateTrackModal, setShowCreateTrackModal] = useState(false);
|
||||||
const [showGlobalTracksMock, setShowGlobalTracksMock] = useState(false);
|
|
||||||
|
|
||||||
const mainContentRegions = useMainContentRegions({
|
const mainContentRegions = useMainContentRegions({
|
||||||
tracks,
|
tracks,
|
||||||
@@ -252,10 +253,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setShowCreateTrackModal(true);
|
setShowCreateTrackModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleGlobalTracksMock = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleToggleGlobalTracks = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setShowGlobalTracksMock(previous => !previous);
|
setShowGlobalTracks(!showGlobalTracks);
|
||||||
}, []);
|
}, [setShowGlobalTracks, showGlobalTracks]);
|
||||||
|
|
||||||
const handleGlobalChordDropToTrack = useCallback(async (draggedRegionId: string, trackIndex: number) => {
|
const handleGlobalChordDropToTrack = useCallback(async (draggedRegionId: string, trackIndex: number) => {
|
||||||
const targetTrack = tracks[trackIndex];
|
const targetTrack = tracks[trackIndex];
|
||||||
@@ -348,10 +349,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`track-header-button${showGlobalTracksMock ? ' active' : ''}`}
|
className={`track-header-button${showGlobalTracks ? ' active' : ''}`}
|
||||||
aria-label={t('mainContent.showGlobalTracks')}
|
aria-label={t('mainContent.showGlobalTracks')}
|
||||||
title={t('mainContent.showGlobalTracks')}
|
title={t('mainContent.showGlobalTracks')}
|
||||||
onClick={handleToggleGlobalTracksMock}
|
onClick={handleToggleGlobalTracks}
|
||||||
>
|
>
|
||||||
<FaSquareArrowUpRight />
|
<FaSquareArrowUpRight />
|
||||||
</button>
|
</button>
|
||||||
@@ -385,7 +386,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MainContentGlobalTracksSection
|
<MainContentGlobalTracksSection
|
||||||
visible={showGlobalTracksMock}
|
visible={showGlobalTracks}
|
||||||
{...mainContentGlobalTracks.sectionProps}
|
{...mainContentGlobalTracks.sectionProps}
|
||||||
chordLaneProps={{
|
chordLaneProps={{
|
||||||
...mainContentGlobalTracks.sectionProps.chordLaneProps,
|
...mainContentGlobalTracks.sectionProps.chordLaneProps,
|
||||||
|
|||||||
+28
-2
@@ -54,11 +54,19 @@ export class KGProject {
|
|||||||
@WithDefault(1)
|
@WithDefault(1)
|
||||||
private pianoRollZoom: number = 1;
|
private pianoRollZoom: number = 1;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault(false)
|
||||||
|
private showGlobalTracks: boolean = false;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault(false)
|
||||||
|
private isMetronomeEnabled: boolean = false;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 15;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 16;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
@@ -89,7 +97,7 @@ export class KGProject {
|
|||||||
private globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks();
|
private globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks();
|
||||||
|
|
||||||
// Constructor
|
// Constructor
|
||||||
constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = TIME_CONSTANTS.DEFAULT_BPM, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 2, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks()) {
|
constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = TIME_CONSTANTS.DEFAULT_BPM, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 2, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks(), showGlobalTracks: boolean = false, isMetronomeEnabled: boolean = false) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.maxBars = maxBars;
|
this.maxBars = maxBars;
|
||||||
this.currentBars = currentBars;
|
this.currentBars = currentBars;
|
||||||
@@ -104,6 +112,8 @@ export class KGProject {
|
|||||||
this.projectStructureVersion = projectStructureVersion;
|
this.projectStructureVersion = projectStructureVersion;
|
||||||
this.pianoRollZoom = pianoRollZoom;
|
this.pianoRollZoom = pianoRollZoom;
|
||||||
this.globalTracks = globalTracks;
|
this.globalTracks = globalTracks;
|
||||||
|
this.showGlobalTracks = showGlobalTracks;
|
||||||
|
this.isMetronomeEnabled = isMetronomeEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
@@ -188,6 +198,22 @@ export class KGProject {
|
|||||||
return this.projectStructureVersion;
|
return this.projectStructureVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getShowGlobalTracks(): boolean {
|
||||||
|
return this.showGlobalTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setShowGlobalTracks(showGlobalTracks: boolean): void {
|
||||||
|
this.showGlobalTracks = showGlobalTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getIsMetronomeEnabled(): boolean {
|
||||||
|
return this.isMetronomeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setIsMetronomeEnabled(isMetronomeEnabled: boolean): void {
|
||||||
|
this.isMetronomeEnabled = isMetronomeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
public getIsLooping(): boolean {
|
public getIsLooping(): boolean {
|
||||||
return this.isLooping;
|
return this.isLooping;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,20 @@ describe('KGProjectStorage', () => {
|
|||||||
expect(loaded!.getPianoRollZoom()).toBe(5);
|
expect(loaded!.getPianoRollZoom()).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves persisted global-track visibility and metronome state when saving and loading', async () => {
|
||||||
|
const project = createTestProject('Toggle Song');
|
||||||
|
project.setShowGlobalTracks(true);
|
||||||
|
project.setIsMetronomeEnabled(true);
|
||||||
|
|
||||||
|
await storage.save('Toggle Song', project);
|
||||||
|
|
||||||
|
const loaded = await storage.load('Toggle Song');
|
||||||
|
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.getShowGlobalTracks()).toBe(true);
|
||||||
|
expect(loaded!.getIsMetronomeEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves global tracks and marker regions when saving and loading', async () => {
|
it('preserves global tracks and marker regions when saving and loading', async () => {
|
||||||
const project = createTestProject('Marker Song');
|
const project = createTestProject('Marker Song');
|
||||||
const markerTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Marker);
|
const markerTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Marker);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { upgradeToV12 } from './upgradeToV12';
|
|||||||
import { upgradeToV13 } from './upgradeToV13';
|
import { upgradeToV13 } from './upgradeToV13';
|
||||||
import { upgradeToV14 } from './upgradeToV14';
|
import { upgradeToV14 } from './upgradeToV14';
|
||||||
import { upgradeToV15 } from './upgradeToV15';
|
import { upgradeToV15 } from './upgradeToV15';
|
||||||
|
import { upgradeToV16 } from './upgradeToV16';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upgrade the given project to the latest structure version, one version at a time.
|
* Upgrade the given project to the latest structure version, one version at a time.
|
||||||
@@ -93,6 +94,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
|||||||
workingProject = upgradeToV15(workingProject);
|
workingProject = upgradeToV15(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 16: {
|
||||||
|
workingProject = upgradeToV16(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
import { upgradeProjectToLatest } from './KGProjectUpgrader';
|
||||||
|
import { upgradeToV16 } from './upgradeToV16';
|
||||||
|
|
||||||
|
describe('upgradeToV16', () => {
|
||||||
|
it('defaults persisted UI/audio toggles to false for legacy projects', () => {
|
||||||
|
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 15, 1, []);
|
||||||
|
|
||||||
|
upgradeToV16(project);
|
||||||
|
|
||||||
|
expect(project.getProjectStructureVersion()).toBe(16);
|
||||||
|
expect(project.getShowGlobalTracks()).toBe(false);
|
||||||
|
expect(project.getIsMetronomeEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing persisted toggle values through the main upgrader path', () => {
|
||||||
|
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 15, 1, [], true, true);
|
||||||
|
|
||||||
|
const upgraded = upgradeProjectToLatest(project);
|
||||||
|
|
||||||
|
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
|
||||||
|
expect(upgraded.getShowGlobalTracks()).toBe(true);
|
||||||
|
expect(upgraded.getIsMetronomeEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
|
||||||
|
export function upgradeToV16(project: KGProject): KGProject {
|
||||||
|
try {
|
||||||
|
if (typeof project.getShowGlobalTracks() !== 'boolean') {
|
||||||
|
project.setShowGlobalTracks(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof project.getIsMetronomeEnabled() !== 'boolean') {
|
||||||
|
project.setIsMetronomeEnabled(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
project.setProjectStructureVersion(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { act } from '@testing-library/react';
|
import { act } from '@testing-library/react';
|
||||||
import { KGTrack } from '../core/track/KGTrack';
|
import { KGTrack } from '../core/track/KGTrack';
|
||||||
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
||||||
|
import { createDefaultGlobalTracks } from '../core/global-track';
|
||||||
|
|
||||||
const pianoRollStateMocks = vi.hoisted(() => ({
|
const pianoRollStateMocks = vi.hoisted(() => ({
|
||||||
setSheetMusicViewEnabled: vi.fn(),
|
setSheetMusicViewEnabled: vi.fn(),
|
||||||
@@ -9,16 +10,27 @@ const pianoRollStateMocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||||
|
let mockIsMetronomeEnabled = false;
|
||||||
|
let mockShowGlobalTracks = false;
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||||
getMaxBars: () => 32,
|
getMaxBars: () => 32,
|
||||||
getBarWidthMultiplier: () => 1,
|
getBarWidthMultiplier: () => 1,
|
||||||
getTracks: () => mockTracks,
|
getTracks: () => mockTracks,
|
||||||
|
getGlobalTracks: () => createDefaultGlobalTracks(),
|
||||||
getBpm: () => 120,
|
getBpm: () => 120,
|
||||||
getKeySignature: () => 'C major',
|
getKeySignature: () => 'C major',
|
||||||
getName: () => 'Test Project',
|
getName: () => 'Test Project',
|
||||||
getSelectedMode: () => 'major',
|
getSelectedMode: () => 'major',
|
||||||
getIsLooping: () => false,
|
getIsLooping: () => false,
|
||||||
|
getIsMetronomeEnabled: () => mockIsMetronomeEnabled,
|
||||||
|
setIsMetronomeEnabled: vi.fn((value: boolean) => {
|
||||||
|
mockIsMetronomeEnabled = value;
|
||||||
|
}),
|
||||||
|
getShowGlobalTracks: () => mockShowGlobalTracks,
|
||||||
|
setShowGlobalTracks: vi.fn((value: boolean) => {
|
||||||
|
mockShowGlobalTracks = value;
|
||||||
|
}),
|
||||||
getLoopingRange: () => [0, 0] as [number, number],
|
getLoopingRange: () => [0, 0] as [number, number],
|
||||||
getPianoRollZoom: () => 1,
|
getPianoRollZoom: () => 1,
|
||||||
};
|
};
|
||||||
@@ -37,6 +49,7 @@ const mockAudioInterface = {
|
|||||||
setTrackVolume: vi.fn(),
|
setTrackVolume: vi.fn(),
|
||||||
setTrackMute: vi.fn(),
|
setTrackMute: vi.fn(),
|
||||||
setTrackSolo: vi.fn(),
|
setTrackSolo: vi.fn(),
|
||||||
|
setMetronomeEnabled: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const configValues = new Map<string, unknown>([
|
const configValues = new Map<string, unknown>([
|
||||||
@@ -126,6 +139,11 @@ describe('projectStore piano roll state', () => {
|
|||||||
mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined);
|
mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined);
|
||||||
mockAudioInterface.getTransportPosition.mockReset();
|
mockAudioInterface.getTransportPosition.mockReset();
|
||||||
mockAudioInterface.getTransportPosition.mockReturnValue(8);
|
mockAudioInterface.getTransportPosition.mockReturnValue(8);
|
||||||
|
mockAudioInterface.setMetronomeEnabled.mockReset();
|
||||||
|
mockIsMetronomeEnabled = false;
|
||||||
|
mockShowGlobalTracks = false;
|
||||||
|
mockProject.setIsMetronomeEnabled.mockClear();
|
||||||
|
mockProject.setShowGlobalTracks.mockClear();
|
||||||
configValues.set('audio.input_device_id', 'default');
|
configValues.set('audio.input_device_id', 'default');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,6 +213,42 @@ describe('projectStore piano roll state', () => {
|
|||||||
expect(state.requestedSheetMusicViewEnabled).toBe(false);
|
expect(state.requestedSheetMusicViewEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('initializes persisted metronome and global-track visibility state from the project', async () => {
|
||||||
|
mockIsMetronomeEnabled = true;
|
||||||
|
mockShowGlobalTracks = true;
|
||||||
|
|
||||||
|
const { useProjectStore } = await import('./projectStore');
|
||||||
|
|
||||||
|
const state = useProjectStore.getState();
|
||||||
|
expect(state.isMetronomeEnabled).toBe(true);
|
||||||
|
expect(state.showGlobalTracks).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists metronome toggles to the project and audio interface', async () => {
|
||||||
|
const { useProjectStore } = await import('./projectStore');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProjectStore.getState().toggleMetronome();
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useProjectStore.getState();
|
||||||
|
expect(mockProject.setIsMetronomeEnabled).toHaveBeenCalledWith(true);
|
||||||
|
expect(mockAudioInterface.setMetronomeEnabled).toHaveBeenCalledWith(true);
|
||||||
|
expect(state.isMetronomeEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists global-track visibility changes to the project', async () => {
|
||||||
|
const { useProjectStore } = await import('./projectStore');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProjectStore.getState().setShowGlobalTracks(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useProjectStore.getState();
|
||||||
|
expect(mockProject.setShowGlobalTracks).toHaveBeenCalledWith(true);
|
||||||
|
expect(state.showGlobalTracks).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('tracks playback preparation around startPlaying success', async () => {
|
it('tracks playback preparation around startPlaying success', async () => {
|
||||||
let resolveStart: (() => void) | null = null;
|
let resolveStart: (() => void) | null = null;
|
||||||
mockCore.startPlaying.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
mockCore.startPlaying.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ interface ProjectState {
|
|||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
isLooping: boolean;
|
isLooping: boolean;
|
||||||
isMetronomeEnabled: boolean;
|
isMetronomeEnabled: boolean;
|
||||||
|
showGlobalTracks: boolean;
|
||||||
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
|
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
|
||||||
playheadPosition: number; // in beats
|
playheadPosition: number; // in beats
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -194,6 +195,7 @@ interface ProjectState {
|
|||||||
stopTransport: () => Promise<void>;
|
stopTransport: () => Promise<void>;
|
||||||
toggleLoop: () => void;
|
toggleLoop: () => void;
|
||||||
toggleMetronome: () => void;
|
toggleMetronome: () => void;
|
||||||
|
setShowGlobalTracks: (show: boolean) => void;
|
||||||
setBpm: (bpm: number) => void;
|
setBpm: (bpm: number) => void;
|
||||||
setMaxBars: (maxBars: number) => void;
|
setMaxBars: (maxBars: number) => void;
|
||||||
setBarWidthMultiplier: (multiplier: number) => void;
|
setBarWidthMultiplier: (multiplier: number) => void;
|
||||||
@@ -432,7 +434,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
keySignature: currentProject.getKeySignature(),
|
keySignature: currentProject.getKeySignature(),
|
||||||
selectedMode: currentProject.getSelectedMode(),
|
selectedMode: currentProject.getSelectedMode(),
|
||||||
isLooping: currentProject.getIsLooping(),
|
isLooping: currentProject.getIsLooping(),
|
||||||
isMetronomeEnabled: false,
|
isMetronomeEnabled: currentProject.getIsMetronomeEnabled(),
|
||||||
|
showGlobalTracks: currentProject.getShowGlobalTracks(),
|
||||||
loopingRange: currentProject.getLoopingRange(),
|
loopingRange: currentProject.getLoopingRange(),
|
||||||
playheadPosition: KGCore.instance().getPlayheadPosition(),
|
playheadPosition: KGCore.instance().getPlayheadPosition(),
|
||||||
isPlaying: KGCore.instance().getIsPlaying(),
|
isPlaying: KGCore.instance().getIsPlaying(),
|
||||||
@@ -870,6 +873,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Setup audio synths for all tracks
|
// Setup audio synths for all tracks
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
|
audioInterface.setMetronomeEnabled(projectToLoad.getIsMetronomeEnabled());
|
||||||
|
|
||||||
// Clear any existing synths/buses first
|
// Clear any existing synths/buses first
|
||||||
tracks.forEach(track => {
|
tracks.forEach(track => {
|
||||||
@@ -949,6 +953,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
keySignature,
|
keySignature,
|
||||||
selectedMode: projectToLoad.getSelectedMode(),
|
selectedMode: projectToLoad.getSelectedMode(),
|
||||||
isLooping: projectToLoad.getIsLooping(),
|
isLooping: projectToLoad.getIsLooping(),
|
||||||
|
isMetronomeEnabled: projectToLoad.getIsMetronomeEnabled(),
|
||||||
|
showGlobalTracks: projectToLoad.getShowGlobalTracks(),
|
||||||
loopingRange: projectToLoad.getLoopingRange(),
|
loopingRange: projectToLoad.getLoopingRange(),
|
||||||
activeTrackAutomationTrackId: null,
|
activeTrackAutomationTrackId: null,
|
||||||
activeTrackAutomationType: null,
|
activeTrackAutomationType: null,
|
||||||
@@ -1455,6 +1461,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
toggleMetronome: () => {
|
toggleMetronome: () => {
|
||||||
const { isMetronomeEnabled, isPlaying, timeSignature } = get();
|
const { isMetronomeEnabled, isPlaying, timeSignature } = get();
|
||||||
const newValue = !isMetronomeEnabled;
|
const newValue = !isMetronomeEnabled;
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
project.setIsMetronomeEnabled(newValue);
|
||||||
set({ isMetronomeEnabled: newValue });
|
set({ isMetronomeEnabled: newValue });
|
||||||
|
|
||||||
const audio = KGAudioInterface.instance();
|
const audio = KGAudioInterface.instance();
|
||||||
@@ -1470,6 +1478,12 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setShowGlobalTracks: (show: boolean) => {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
project.setShowGlobalTracks(show);
|
||||||
|
set({ showGlobalTracks: show });
|
||||||
|
},
|
||||||
|
|
||||||
setBpm: (bpm: number) => {
|
setBpm: (bpm: number) => {
|
||||||
try {
|
try {
|
||||||
// Create and execute the change project property command
|
// Create and execute the change project property command
|
||||||
@@ -1886,6 +1900,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
bpm: project.getBpm(),
|
bpm: project.getBpm(),
|
||||||
keySignature: project.getKeySignature(),
|
keySignature: project.getKeySignature(),
|
||||||
selectedMode: project.getSelectedMode(),
|
selectedMode: project.getSelectedMode(),
|
||||||
|
isMetronomeEnabled: project.getIsMetronomeEnabled(),
|
||||||
|
showGlobalTracks: project.getShowGlobalTracks(),
|
||||||
currentTime: formatCurrentTime(project, core.getPlayheadPosition()),
|
currentTime: formatCurrentTime(project, core.getPlayheadPosition()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user