feat: added auto-center playhead to piano roll window

This commit is contained in:
Xiaohan-Tian
2026-04-16 20:53:01 -07:00
parent 425aeab2d1
commit cb027b6c20
3 changed files with 66 additions and 12 deletions
+6 -9
View File
@@ -33,6 +33,8 @@ const MainContent: React.FC<MainContentProps> = ({
setPlayheadPosition, setPlayheadPosition,
playheadPosition, playheadPosition,
isPlaying, isPlaying,
autoScrollEnabled,
setAutoScrollEnabled,
clearAllSelections, clearAllSelections,
setSelectedTrack, setSelectedTrack,
showPianoRoll, showPianoRoll,
@@ -84,7 +86,6 @@ const MainContent: React.FC<MainContentProps> = ({
// Refs for auto-scroll during playback // Refs for auto-scroll during playback
const mainContentRef = useRef<HTMLDivElement | null>(null); const mainContentRef = useRef<HTMLDivElement | null>(null);
const userManuallyScrolledRef = useRef(false);
const expectedScrollLeftRef = useRef<number>(-1); const expectedScrollLeftRef = useRef<number>(-1);
const isPlayingRef = useRef(false); const isPlayingRef = useRef(false);
@@ -95,11 +96,8 @@ const MainContent: React.FC<MainContentProps> = ({
const loopDragStartXRef = useRef<number | null>(null); const loopDragStartXRef = useRef<number | null>(null);
const loopDragOriginalSettingsRef = useRef<{ isLooping: boolean; loopingRange: [number, number] } | null>(null); const loopDragOriginalSettingsRef = useRef<{ isLooping: boolean; loopingRange: [number, number] } | null>(null);
// Sync isPlayingRef and reset manual-scroll flag when playback starts // Sync isPlayingRef for use inside scroll event closure
useEffect(() => { useEffect(() => {
if (isPlaying && !isPlayingRef.current) {
userManuallyScrolledRef.current = false;
}
isPlayingRef.current = isPlaying; isPlayingRef.current = isPlaying;
}, [isPlaying]); }, [isPlaying]);
@@ -110,9 +108,8 @@ const MainContent: React.FC<MainContentProps> = ({
const handleScroll = () => { const handleScroll = () => {
if (!isPlayingRef.current) return; if (!isPlayingRef.current) return;
// If scrollLeft matches what we programmatically set (within 1px), it's our scroll — ignore
if (Math.abs(container.scrollLeft - expectedScrollLeftRef.current) < 1) return; if (Math.abs(container.scrollLeft - expectedScrollLeftRef.current) < 1) return;
userManuallyScrolledRef.current = true; useProjectStore.getState().setAutoScrollEnabled(false);
}; };
container.addEventListener('scroll', handleScroll); container.addEventListener('scroll', handleScroll);
@@ -121,7 +118,7 @@ const MainContent: React.FC<MainContentProps> = ({
// Auto-scroll to keep playhead centered during playback // Auto-scroll to keep playhead centered during playback
useEffect(() => { useEffect(() => {
if (!isPlaying || userManuallyScrolledRef.current) return; if (!isPlaying || !autoScrollEnabled) return;
const container = mainContentRef.current; const container = mainContentRef.current;
if (!container) return; if (!container) return;
@@ -143,7 +140,7 @@ const MainContent: React.FC<MainContentProps> = ({
expectedScrollLeftRef.current = clampedScrollLeft; expectedScrollLeftRef.current = clampedScrollLeft;
container.scrollLeft = clampedScrollLeft; container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, timeSignature]); }, [playheadPosition, isPlaying, autoScrollEnabled, timeSignature]);
// Effect to verify track updates // Effect to verify track updates
useEffect(() => { useEffect(() => {
+52 -2
View File
@@ -29,7 +29,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
initialPosition, initialPosition,
initialSize initialSize
}) => { }) => {
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode } = useProjectStore(); const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled } = useProjectStore();
// Tool state for piano roll // Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
@@ -58,7 +58,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const pianoRollContentRef = useRef<HTMLDivElement>(null); const pianoRollContentRef = useRef<HTMLDivElement>(null);
const pianoGridRef = useRef<HTMLDivElement>(null); const pianoGridRef = useRef<HTMLDivElement>(null);
const wasDraggingRef = useRef<boolean>(false); const wasDraggingRef = useRef<boolean>(false);
// Refs for auto-scroll during playback
const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
const pianoRollIsPlayingRef = useRef(false);
// Ref for storing the setNoteUpdateCounter function // Ref for storing the setNoteUpdateCounter function
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null); const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
@@ -621,6 +625,52 @@ const PianoRoll: React.FC<PianoRollProps> = ({
} }
}, []); }, []);
// Sync isPlayingRef for use inside scroll event closure
useEffect(() => {
pianoRollIsPlayingRef.current = isPlaying;
}, [isPlaying]);
// Detect manual horizontal scroll during playback
useEffect(() => {
const container = pianoRollContentRef.current;
if (!container) return;
const handleScroll = () => {
if (!pianoRollIsPlayingRef.current) return;
if (Math.abs(container.scrollLeft - pianoRollExpectedScrollLeftRef.current) < 1) return;
useProjectStore.getState().setAutoScrollEnabled(false);
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, []);
// Auto-scroll to keep playhead centered during playback
useEffect(() => {
if (!isPlaying || !autoScrollEnabled) return;
const container = pianoRollContentRef.current;
if (!container) return;
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
const playheadPixel = playheadPosition * beatWidth;
// Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel)
const keysWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60;
const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2;
const clampedScrollLeft = Math.max(
0,
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
);
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, autoScrollEnabled]);
// Scroll horizontally to the active region's starting bar // Scroll horizontally to the active region's starting bar
useEffect(() => { useEffect(() => {
if (pianoRollContentRef.current && activeRegion) { if (pianoRollContentRef.current && activeRegion) {
+8 -1
View File
@@ -62,6 +62,7 @@ interface ProjectState {
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;
autoScrollEnabled: boolean;
currentTime: string; // formatted time string currentTime: string; // formatted time string
// Selection state for UI reactivity // Selection state for UI reactivity
@@ -114,6 +115,7 @@ interface ProjectState {
refreshStatus: () => void; refreshStatus: () => void;
loadProject: (project: KGProject | null, savedName?: string) => Promise<void>; loadProject: (project: KGProject | null, savedName?: string) => Promise<void>;
setPlayheadPosition: (position: number) => void; setPlayheadPosition: (position: number) => void;
setAutoScrollEnabled: (enabled: boolean) => void;
startPlaying: () => Promise<void>; startPlaying: () => Promise<void>;
stopPlaying: () => Promise<void>; stopPlaying: () => Promise<void>;
toggleLoop: () => void; toggleLoop: () => void;
@@ -270,6 +272,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
loopingRange: currentProject.getLoopingRange(), loopingRange: currentProject.getLoopingRange(),
playheadPosition: KGCore.instance().getPlayheadPosition(), playheadPosition: KGCore.instance().getPlayheadPosition(),
isPlaying: KGCore.instance().getIsPlaying(), isPlaying: KGCore.instance().getIsPlaying(),
autoScrollEnabled: true,
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
// Initial selection state // Initial selection state
@@ -752,9 +755,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}); });
}, },
setAutoScrollEnabled: (enabled: boolean) => {
set({ autoScrollEnabled: enabled });
},
startPlaying: async () => { startPlaying: async () => {
await KGCore.instance().startPlaying(); await KGCore.instance().startPlaying();
set({ isPlaying: true }); set({ isPlaying: true, autoScrollEnabled: true });
}, },
stopPlaying: async () => { stopPlaying: async () => {