feat(player): restore queue list scroll on queue undo/redo

Store the main queue panel viewport scrollTop in undo snapshots and
reapply it after undo/redo so Ctrl+Z / Ctrl+Shift+Z restores scroll
position alongside queue state.
This commit is contained in:
Maxim Isaev
2026-04-27 00:33:47 +03:00
parent 39f4d03da5
commit 55a49a9fb0
2 changed files with 52 additions and 1 deletions
+22 -1
View File
@@ -1,6 +1,12 @@
import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react'; import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import {
Track,
usePlayerStore,
songToTrack,
registerQueueListScrollTopReader,
consumePendingQueueListScrollTop,
} from '../store/playerStore';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import OrbitGuestQueue from './OrbitGuestQueue'; import OrbitGuestQueue from './OrbitGuestQueue';
import OrbitQueueHead from './OrbitQueueHead'; import OrbitQueueHead from './OrbitQueueHead';
@@ -414,6 +420,21 @@ function QueuePanelHostOrSolo() {
const queueListRef = useRef<HTMLDivElement>(null); const queueListRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
registerQueueListScrollTopReader(() => queueListRef.current?.scrollTop);
return () => registerQueueListScrollTopReader(null);
}, []);
useLayoutEffect(() => {
const top = consumePendingQueueListScrollTop();
if (top === undefined) return;
const el = queueListRef.current;
if (!el) return;
suppressNextAutoScrollRef.current = true;
el.scrollTop = top;
el.dispatchEvent(new Event('scroll', { bubbles: false }));
}, [queue, queueIndex, currentTrack?.id]);
const asideRef = useRef<HTMLElement>(null); const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop(); const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
+30
View File
@@ -362,15 +362,38 @@ type QueueUndoSnapshot = {
currentTime?: number; currentTime?: number;
progress?: number; progress?: number;
isPlaying?: boolean; isPlaying?: boolean;
/** Main queue panel list `scrollTop` when the snapshot was taken. */
queueListScrollTop?: number;
}; };
const queueUndoStack: QueueUndoSnapshot[] = []; const queueUndoStack: QueueUndoSnapshot[] = [];
const queueRedoStack: QueueUndoSnapshot[] = []; const queueRedoStack: QueueUndoSnapshot[] = [];
/** QueuePanel registers a reader so undo snapshots capture list scroll position. */
let queueListScrollTopReader: (() => number | undefined) | null = null;
export function registerQueueListScrollTopReader(reader: (() => number | undefined) | null): void {
queueListScrollTopReader = reader;
}
function readQueueListScrollTopForUndo(): number | undefined {
return queueListScrollTopReader?.() ?? undefined;
}
/** Set in applyQueueHistorySnapshot; QueuePanel consumes in useLayoutEffect after commit. */
let pendingQueueListScrollTop: number | undefined;
export function consumePendingQueueListScrollTop(): number | undefined {
const v = pendingQueueListScrollTop;
pendingQueueListScrollTop = undefined;
return v;
}
function shallowCloneQueueTracks(queue: Track[]): Track[] { function shallowCloneQueueTracks(queue: Track[]): Track[] {
return queue.map(t => ({ ...t })); return queue.map(t => ({ ...t }));
} }
function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot { function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
const scrollTop = readQueueListScrollTopForUndo();
return { return {
queue: shallowCloneQueueTracks(s.queue), queue: shallowCloneQueueTracks(s.queue),
queueIndex: s.queueIndex, queueIndex: s.queueIndex,
@@ -378,6 +401,7 @@ function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
currentTime: s.currentTime, currentTime: s.currentTime,
progress: s.progress, progress: s.progress,
isPlaying: s.isPlaying, isPlaying: s.isPlaying,
...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}),
}; };
} }
@@ -1926,6 +1950,9 @@ export const usePlayerStore = create<PlayerState>()(
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; isAudioPaused = false;
syncQueueToServer(nextQueue, null, 0); syncQueueToServer(nextQueue, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
pendingQueueListScrollTop = Math.max(0, snap.queueListScrollTop);
}
return true; return true;
} }
@@ -1946,6 +1973,9 @@ export const usePlayerStore = create<PlayerState>()(
wantPlaying: playingRestore, wantPlaying: playingRestore,
}); });
} }
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
pendingQueueListScrollTop = Math.max(0, snap.queueListScrollTop);
}
syncQueueToServer(nextQueue, nextTrack, tRestore); syncQueueToServer(nextQueue, nextTrack, tRestore);
return true; return true;
} }