diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index 2a9e997..52d9fcc 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -62,6 +62,10 @@ export class KGProject { @WithDefault(false) private isMetronomeEnabled: boolean = false; + @Expose() + @WithDefault(0) + private playheadPosition: number = 0; + @Expose() @WithDefault(0) private projectStructureVersion: number = 0; @@ -97,7 +101,7 @@ export class KGProject { private globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks(); // 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(), showGlobalTracks: boolean = false, isMetronomeEnabled: boolean = false) { + 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, playheadPosition: number = 0) { this.name = name; this.maxBars = maxBars; this.currentBars = currentBars; @@ -114,6 +118,7 @@ export class KGProject { this.globalTracks = globalTracks; this.showGlobalTracks = showGlobalTracks; this.isMetronomeEnabled = isMetronomeEnabled; + this.playheadPosition = playheadPosition; } // Getters @@ -210,10 +215,18 @@ export class KGProject { return this.isMetronomeEnabled; } + public getPlayheadPosition(): number { + return this.playheadPosition; + } + public setIsMetronomeEnabled(isMetronomeEnabled: boolean): void { this.isMetronomeEnabled = isMetronomeEnabled; } + public setPlayheadPosition(playheadPosition: number): void { + this.playheadPosition = playheadPosition; + } + public getIsLooping(): boolean { return this.isLooping; } diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts index 2dcc4ee..aa49fa5 100644 --- a/src/core/io/KGProjectStorage.test.ts +++ b/src/core/io/KGProjectStorage.test.ts @@ -175,6 +175,38 @@ describe('KGProjectStorage', () => { expect(loaded!.getPianoRollZoom()).toBe(5); }); + it('preserves playhead position when saving and loading', async () => { + const project = createTestProject('Playhead Song'); + project.setPlayheadPosition(18.5); + + await storage.save('Playhead Song', project); + + const loaded = await storage.load('Playhead Song'); + + expect(loaded).not.toBeNull(); + expect(loaded!.getPlayheadPosition()).toBe(18.5); + }); + + it('defaults playhead position to 0 when loading older project data without the field', async () => { + const project = createTestProject('Legacy Song'); + await storage.save('Legacy Song', project); + + const projectsDir = await mockRoot.getDirectoryHandle('projects'); + const projectDir = await projectsDir.getDirectoryHandle('Legacy Song'); + const projectHandle = await projectDir.getFileHandle('project.json'); + const legacyPayload = JSON.parse(await (await projectHandle.getFile()).text()) as Record; + delete legacyPayload.playheadPosition; + + const writable = await projectHandle.createWritable(); + await writable.write(JSON.stringify(legacyPayload, null, 2)); + await writable.close(); + + const loaded = await storage.load('Legacy Song'); + + expect(loaded).not.toBeNull(); + expect(loaded!.getPlayheadPosition()).toBe(0); + }); + it('preserves persisted global-track visibility and metronome state when saving and loading', async () => { const project = createTestProject('Toggle Song'); project.setShowGlobalTracks(true); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 6ec6eca..2d69bb0 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -62,6 +62,11 @@ function formatCurrentTime(project: KGProject, beat: number): string { return beatsToTimeString(beat, bpmForLegacyFormatting, project.getTimeSignature()); } +function clampPlayheadPosition(project: KGProject, position: number): number { + const maxBeat = project.getMaxBars() * project.getTimeSignature().numerator; + return Math.max(0, Math.min(position, maxBeat)); +} + function getProjectGlobalTracks(project: KGProject): KGGlobalTrack[] { return (project.getGlobalTracks?.() ?? []) as KGGlobalTrack[]; } @@ -923,8 +928,6 @@ export const useProjectStore = create((set, get) => { loadProject: async (project: KGProject | null = null, savedName?: string) => { try { - const { setPlayheadPosition } = get(); - // Upgrade incoming project data to latest structure version (only when provided explicitly) if (project) { project = upgradeProjectToLatest(project); @@ -944,15 +947,15 @@ export const useProjectStore = create((set, get) => { KGCore.instance().executeCommand(addDefaultTrackCommand); } - // Reset playhead to 0 when loading a project - setPlayheadPosition(0); - // Get project properties const maxBars = projectToLoad.getMaxBars(); const timeSignature = projectToLoad.getTimeSignature(); const bpm = projectToLoad.getBpm(); const keySignature = projectToLoad.getKeySignature(); const tracks = projectToLoad.getTracks(); + const restoredPlayheadPosition = clampPlayheadPosition(projectToLoad, projectToLoad.getPlayheadPosition()); + projectToLoad.setPlayheadPosition(restoredPlayheadPosition); + KGCore.instance().setPlayheadPosition(restoredPlayheadPosition); // Setup audio synths for all tracks const audioInterface = KGAudioInterface.instance(); @@ -1036,8 +1039,9 @@ export const useProjectStore = create((set, get) => { recordingAudioPreviewPeaks: [], recordingAudioPreviewCurrentBeat: 0, recordingAudioPreviewFileName: null, - playheadPosition: 0, // Ensure store state is also updated - currentTime: formatCurrentTime(projectToLoad, 0) // Reset time display + playheadPosition: restoredPlayheadPosition, + currentTime: formatCurrentTime(projectToLoad, restoredPlayheadPosition), + mainContentScrollRequest: restoredPlayheadPosition, }); // After loading a project, auto-select the first track and open Instrument Selection @@ -1065,11 +1069,13 @@ export const useProjectStore = create((set, get) => { }, setPlayheadPosition: (position: number) => { - const { bpm, timeSignature } = get(); - KGCore.instance().setPlayheadPosition(position); + const project = KGCore.instance().getCurrentProject(); + const clampedPosition = clampPlayheadPosition(project, position); + project.setPlayheadPosition(clampedPosition); + KGCore.instance().setPlayheadPosition(clampedPosition); set({ - playheadPosition: position, - currentTime: formatCurrentTime(KGCore.instance().getCurrentProject(), position) + playheadPosition: clampedPosition, + currentTime: formatCurrentTime(project, clampedPosition) }); }, diff --git a/src/test/integration/store/project-store-sync.integration.test.ts b/src/test/integration/store/project-store-sync.integration.test.ts index 27461c0..1ae05d6 100644 --- a/src/test/integration/store/project-store-sync.integration.test.ts +++ b/src/test/integration/store/project-store-sync.integration.test.ts @@ -247,6 +247,7 @@ describe('Project Store Synchronization Integration Tests', () => { // Verify store state updated const storeState = useProjectStore.getState(); expect(storeState.playheadPosition).toBe(newPosition); + expect(testProject.getPlayheadPosition()).toBe(newPosition); // Verify formatted time string was updated expect(storeState.currentTime).toBeDefined(); @@ -632,6 +633,7 @@ describe('Project Store Synchronization Integration Tests', () => { newProject.setTimeSignature({ numerator: 6, denominator: 8 }); newProject.setKeySignature('D major'); newProject.setMaxBars(48); + newProject.setPlayheadPosition(17.5); // Add a track with region and notes const track = new KGMidiTrack('Loaded Track', 0, 'violin'); @@ -654,11 +656,14 @@ describe('Project Store Synchronization Integration Tests', () => { expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 }); expect(storeState.keySignature).toBe('D major'); expect(storeState.maxBars).toBe(48); + expect(storeState.playheadPosition).toBe(17.5); + expect(storeState.mainContentScrollRequest).toBe(17.5); expect(storeState.tracks).toHaveLength(1); // Verify core model is updated const core = KGCore.instance(); expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project'); + expect(core.getCurrentProject()?.getPlayheadPosition()).toBe(17.5); // Verify CSS properties were updated const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator'); @@ -667,5 +672,24 @@ describe('Project Store Synchronization Integration Tests', () => { const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars'); expect(maxBarsCSS.trim()).toBe('48'); }); + + it('should clamp restored playhead position when loading beyond the project end', async () => { + const { loadProject } = useProjectStore.getState(); + const newProject = new KGProject('Clamped Loaded Project'); + newProject.setTimeSignature({ numerator: 4, denominator: 4 }); + newProject.setMaxBars(8); + newProject.setPlayheadPosition(100); + + await act(async () => { + await loadProject(newProject); + }); + + const clampedBeat = 32; + const storeState = useProjectStore.getState(); + expect(storeState.playheadPosition).toBe(clampedBeat); + expect(storeState.mainContentScrollRequest).toBe(clampedBeat); + expect(newProject.getPlayheadPosition()).toBe(clampedBeat); + expect(KGCore.instance().getCurrentProject().getPlayheadPosition()).toBe(clampedBeat); + }); }); });