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)
|
@WithDefault(false)
|
||||||
private isMetronomeEnabled: boolean = false;
|
private isMetronomeEnabled: boolean = false;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@WithDefault(0)
|
||||||
|
private playheadPosition: number = 0;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
@@ -97,7 +101,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(), 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.name = name;
|
||||||
this.maxBars = maxBars;
|
this.maxBars = maxBars;
|
||||||
this.currentBars = currentBars;
|
this.currentBars = currentBars;
|
||||||
@@ -114,6 +118,7 @@ export class KGProject {
|
|||||||
this.globalTracks = globalTracks;
|
this.globalTracks = globalTracks;
|
||||||
this.showGlobalTracks = showGlobalTracks;
|
this.showGlobalTracks = showGlobalTracks;
|
||||||
this.isMetronomeEnabled = isMetronomeEnabled;
|
this.isMetronomeEnabled = isMetronomeEnabled;
|
||||||
|
this.playheadPosition = playheadPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
@@ -210,10 +215,18 @@ export class KGProject {
|
|||||||
return this.isMetronomeEnabled;
|
return this.isMetronomeEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getPlayheadPosition(): number {
|
||||||
|
return this.playheadPosition;
|
||||||
|
}
|
||||||
|
|
||||||
public setIsMetronomeEnabled(isMetronomeEnabled: boolean): void {
|
public setIsMetronomeEnabled(isMetronomeEnabled: boolean): void {
|
||||||
this.isMetronomeEnabled = isMetronomeEnabled;
|
this.isMetronomeEnabled = isMetronomeEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setPlayheadPosition(playheadPosition: number): void {
|
||||||
|
this.playheadPosition = playheadPosition;
|
||||||
|
}
|
||||||
|
|
||||||
public getIsLooping(): boolean {
|
public getIsLooping(): boolean {
|
||||||
return this.isLooping;
|
return this.isLooping;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,38 @@ describe('KGProjectStorage', () => {
|
|||||||
expect(loaded!.getPianoRollZoom()).toBe(5);
|
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 () => {
|
it('preserves persisted global-track visibility and metronome state when saving and loading', async () => {
|
||||||
const project = createTestProject('Toggle Song');
|
const project = createTestProject('Toggle Song');
|
||||||
project.setShowGlobalTracks(true);
|
project.setShowGlobalTracks(true);
|
||||||
|
|||||||
+17
-11
@@ -62,6 +62,11 @@ function formatCurrentTime(project: KGProject, beat: number): string {
|
|||||||
return beatsToTimeString(beat, bpmForLegacyFormatting, project.getTimeSignature());
|
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[] {
|
function getProjectGlobalTracks(project: KGProject): KGGlobalTrack[] {
|
||||||
return (project.getGlobalTracks?.() ?? []) as 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) => {
|
loadProject: async (project: KGProject | null = null, savedName?: string) => {
|
||||||
try {
|
try {
|
||||||
const { setPlayheadPosition } = get();
|
|
||||||
|
|
||||||
// Upgrade incoming project data to latest structure version (only when provided explicitly)
|
// Upgrade incoming project data to latest structure version (only when provided explicitly)
|
||||||
if (project) {
|
if (project) {
|
||||||
project = upgradeProjectToLatest(project);
|
project = upgradeProjectToLatest(project);
|
||||||
@@ -944,15 +947,15 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
KGCore.instance().executeCommand(addDefaultTrackCommand);
|
KGCore.instance().executeCommand(addDefaultTrackCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset playhead to 0 when loading a project
|
|
||||||
setPlayheadPosition(0);
|
|
||||||
|
|
||||||
// Get project properties
|
// Get project properties
|
||||||
const maxBars = projectToLoad.getMaxBars();
|
const maxBars = projectToLoad.getMaxBars();
|
||||||
const timeSignature = projectToLoad.getTimeSignature();
|
const timeSignature = projectToLoad.getTimeSignature();
|
||||||
const bpm = projectToLoad.getBpm();
|
const bpm = projectToLoad.getBpm();
|
||||||
const keySignature = projectToLoad.getKeySignature();
|
const keySignature = projectToLoad.getKeySignature();
|
||||||
const tracks = projectToLoad.getTracks();
|
const tracks = projectToLoad.getTracks();
|
||||||
|
const restoredPlayheadPosition = clampPlayheadPosition(projectToLoad, projectToLoad.getPlayheadPosition());
|
||||||
|
projectToLoad.setPlayheadPosition(restoredPlayheadPosition);
|
||||||
|
KGCore.instance().setPlayheadPosition(restoredPlayheadPosition);
|
||||||
|
|
||||||
// Setup audio synths for all tracks
|
// Setup audio synths for all tracks
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
@@ -1036,8 +1039,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
recordingAudioPreviewPeaks: [],
|
recordingAudioPreviewPeaks: [],
|
||||||
recordingAudioPreviewCurrentBeat: 0,
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
recordingAudioPreviewFileName: null,
|
recordingAudioPreviewFileName: null,
|
||||||
playheadPosition: 0, // Ensure store state is also updated
|
playheadPosition: restoredPlayheadPosition,
|
||||||
currentTime: formatCurrentTime(projectToLoad, 0) // Reset time display
|
currentTime: formatCurrentTime(projectToLoad, restoredPlayheadPosition),
|
||||||
|
mainContentScrollRequest: restoredPlayheadPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
// After loading a project, auto-select the first track and open Instrument Selection
|
// 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) => {
|
setPlayheadPosition: (position: number) => {
|
||||||
const { bpm, timeSignature } = get();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
KGCore.instance().setPlayheadPosition(position);
|
const clampedPosition = clampPlayheadPosition(project, position);
|
||||||
|
project.setPlayheadPosition(clampedPosition);
|
||||||
|
KGCore.instance().setPlayheadPosition(clampedPosition);
|
||||||
set({
|
set({
|
||||||
playheadPosition: position,
|
playheadPosition: clampedPosition,
|
||||||
currentTime: formatCurrentTime(KGCore.instance().getCurrentProject(), position)
|
currentTime: formatCurrentTime(project, clampedPosition)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ describe('Project Store Synchronization Integration Tests', () => {
|
|||||||
// Verify store state updated
|
// Verify store state updated
|
||||||
const storeState = useProjectStore.getState();
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.playheadPosition).toBe(newPosition);
|
expect(storeState.playheadPosition).toBe(newPosition);
|
||||||
|
expect(testProject.getPlayheadPosition()).toBe(newPosition);
|
||||||
|
|
||||||
// Verify formatted time string was updated
|
// Verify formatted time string was updated
|
||||||
expect(storeState.currentTime).toBeDefined();
|
expect(storeState.currentTime).toBeDefined();
|
||||||
@@ -632,6 +633,7 @@ describe('Project Store Synchronization Integration Tests', () => {
|
|||||||
newProject.setTimeSignature({ numerator: 6, denominator: 8 });
|
newProject.setTimeSignature({ numerator: 6, denominator: 8 });
|
||||||
newProject.setKeySignature('D major');
|
newProject.setKeySignature('D major');
|
||||||
newProject.setMaxBars(48);
|
newProject.setMaxBars(48);
|
||||||
|
newProject.setPlayheadPosition(17.5);
|
||||||
|
|
||||||
// Add a track with region and notes
|
// Add a track with region and notes
|
||||||
const track = new KGMidiTrack('Loaded Track', 0, 'violin');
|
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.timeSignature).toEqual({ numerator: 6, denominator: 8 });
|
||||||
expect(storeState.keySignature).toBe('D major');
|
expect(storeState.keySignature).toBe('D major');
|
||||||
expect(storeState.maxBars).toBe(48);
|
expect(storeState.maxBars).toBe(48);
|
||||||
|
expect(storeState.playheadPosition).toBe(17.5);
|
||||||
|
expect(storeState.mainContentScrollRequest).toBe(17.5);
|
||||||
expect(storeState.tracks).toHaveLength(1);
|
expect(storeState.tracks).toHaveLength(1);
|
||||||
|
|
||||||
// Verify core model is updated
|
// Verify core model is updated
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project');
|
expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project');
|
||||||
|
expect(core.getCurrentProject()?.getPlayheadPosition()).toBe(17.5);
|
||||||
|
|
||||||
// Verify CSS properties were updated
|
// Verify CSS properties were updated
|
||||||
const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
|
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');
|
const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars');
|
||||||
expect(maxBarsCSS.trim()).toBe('48');
|
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