feat: persist playhead position and restore centered timeline on project load
This commit is contained in:
+14
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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);
|
||||
|
||||
+17
-11
@@ -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<ProjectState>((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<ProjectState>((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<ProjectState>((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<ProjectState>((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)
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user