feat: persist playhead position and restore centered timeline on project load

This commit is contained in:
Xiaohan-Tian
2026-07-07 17:18:01 -07:00
parent 93450aebec
commit cca874a516
4 changed files with 87 additions and 12 deletions
+14 -1
View File
@@ -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;
}
+32
View File
@@ -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
View File
@@ -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);
});
});
});