Merge pull request #331 from Psychotoxical/feat/player-queue-undo-redo

feat(player): queue undo/redo with hotkeys, playback-aware restore, and scroll restoration
This commit is contained in:
cucadmuh
2026-04-27 01:05:49 +03:00
committed by GitHub
6 changed files with 460 additions and 8 deletions
+11 -3
View File
@@ -93,7 +93,13 @@ import { initHotCachePrefetch } from './hotCachePrefetch';
import i18n from './i18n'; import i18n from './i18n';
import { playByOpaqueId } from './utils/playByOpaqueId'; import { playByOpaqueId } from './utils/playByOpaqueId';
import { switchActiveServer } from './utils/switchActiveServer'; import { switchActiveServer } from './utils/switchActiveServer';
import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray, flushPlayQueuePosition } from './store/playerStore'; import {
usePlayerStore,
initAudioListeners,
songToTrack,
shuffleArray,
flushPlayQueuePosition,
} from './store/playerStore';
import { useThemeStore } from './store/themeStore'; import { useThemeStore } from './store/themeStore';
import { useThemeScheduler } from './hooks/useThemeScheduler'; import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore'; import { useFontStore } from './store/fontStore';
@@ -858,8 +864,10 @@ function TauriEventBridge() {
// Configurable keybindings // Configurable keybindings
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName; const el = e.target as HTMLElement;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; const tag = el?.tagName;
const editable = Boolean(el?.isContentEditable);
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
const chord = buildInAppBinding(e); const chord = buildInAppBinding(e);
if (chord) { if (chord) {
+10
View File
@@ -221,6 +221,16 @@ export default function MiniPlayer() {
return; return;
} }
if ((e.ctrlKey || e.metaKey) && (e.code === 'KeyZ' || e.key?.toLowerCase() === 'z')) {
e.preventDefault();
if (e.shiftKey) {
emit('mini:redo-queue', {}).catch(() => {});
} else {
emit('mini:undo-queue', {}).catch(() => {});
}
return;
}
if (e.key === ' ' || e.code === 'Space') { if (e.key === ' ' || e.code === 'Space') {
e.preventDefault(); e.preventDefault();
emit('mini:control', 'toggle').catch(() => {}); emit('mini:control', 'toggle').catch(() => {});
+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();
+3
View File
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import App from './App'; import App from './App';
import { installQueueUndoHotkey } from './store/playerStore';
import './i18n'; import './i18n';
import './styles/theme.css'; import './styles/theme.css';
import './styles/layout.css'; import './styles/layout.css';
@@ -46,6 +47,8 @@ try {
// Ignore parse / non-Tauri. // Ignore parse / non-Tauri.
} }
installQueueUndoHotkey();
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
+404 -4
View File
@@ -235,6 +235,21 @@ interface PlayerState {
/** Shuffle only the tracks after the current one — leaves played history intact. */ /** Shuffle only the tracks after the current one — leaves played history intact. */
shuffleUpcomingQueue: () => void; shuffleUpcomingQueue: () => void;
/**
* Revert the last explicit queue edit (enqueue, reorder, remove, shuffle, manual
* `playTrack`, ). Returns true if a snapshot was applied. Snapshots include queue,
* current track, playback time, progress, and pause state. If the undone edit did
* not change which song is current (reorder, enqueue, remove another row, ), only
* the queue is restored and playback continues; otherwise the Rust engine is
* resynced to the snapshot track/position. Does not cover `clearQueue` or automatic advances from
* `next()` / gapless.
* If the snapshot had no `currentTrack` but playback is active, the playing track
* is kept: prepended when missing from the restored queue, otherwise re-bound by id.
*/
undoLastQueueEdit: () => boolean;
/** Ctrl+Shift+Z / Cmd+Shift+Z — opposite of `undoLastQueueEdit` while redo stack is non-empty. */
redoLastQueueEdit: () => boolean;
toggleLastfmLove: () => void; toggleLastfmLove: () => void;
setLastfmLoved: (v: boolean) => void; setLastfmLoved: (v: boolean) => void;
setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void; setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void;
@@ -337,6 +352,135 @@ let cachedLoudnessGainByTrackId: Record<string, number> = {};
let stableLoudnessGainByTrackId: Record<string, true> = {}; let stableLoudnessGainByTrackId: Record<string, true> = {};
let lastNormalizationUiUpdateAtMs = 0; let lastNormalizationUiUpdateAtMs = 0;
/** Bounded stack of queue snapshots for Ctrl+Z / Cmd+Z undo. */
const QUEUE_UNDO_MAX = 32;
type QueueUndoSnapshot = {
queue: Track[];
queueIndex: number;
currentTrack: Track | null;
/** Seconds — captured with the snapshot (older entries may omit). */
currentTime?: number;
progress?: number;
isPlaying?: boolean;
/** Main queue panel list `scrollTop` when the snapshot was taken. */
queueListScrollTop?: number;
};
const queueUndoStack: 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[] {
return queue.map(t => ({ ...t }));
}
function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
const scrollTop = readQueueListScrollTopForUndo();
return {
queue: shallowCloneQueueTracks(s.queue),
queueIndex: s.queueIndex,
currentTrack: s.currentTrack ? { ...s.currentTrack } : null,
currentTime: s.currentTime,
progress: s.progress,
isPlaying: s.isPlaying,
...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}),
};
}
function pushQueueUndoFromGetter(get: () => PlayerState) {
queueRedoStack.length = 0;
queueUndoStack.push(queueUndoSnapshotFromState(get()));
while (queueUndoStack.length > QUEUE_UNDO_MAX) queueUndoStack.shift();
}
/** Reload Rust audio to match a queue-undo snapshot (Zustand alone does not move the engine). */
function queueUndoRestoreAudioEngine(opts: {
generation: number;
track: Track;
queue: Track[];
queueIndex: number;
atSeconds: number;
wantPlaying: boolean;
}): void {
const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts;
const authState = useAuthStore.getState();
const vol = usePlayerStore.getState().volume;
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, coldPrev, coldNext,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
setDeferHotCachePrefetch(true);
invoke('audio_play', {
url,
volume: vol,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
manual: false,
hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
})
.then(() => {
if (playGeneration !== generation) return;
if (keepPreloadHint) {
usePlayerStore.setState({ enginePreloadedTrackId: null });
}
const dur = track.duration && track.duration > 0 ? track.duration : null;
const seekTo = Math.max(0, atSeconds);
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
const afterSeek = () => {
if (playGeneration !== generation) return;
if (!wantPlaying) {
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
usePlayerStore.setState({ isPlaying: false });
} else {
isAudioPaused = false;
}
};
if (canSeek) {
void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek);
} else {
afterSeek();
}
})
.catch((err: unknown) => {
if (playGeneration !== generation) return;
console.error('[psysonic] queue-undo audio_play failed:', err);
usePlayerStore.setState({ isPlaying: false });
})
.finally(() => {
setDeferHotCachePrefetch(false);
});
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
}
function emitNormalizationDebug(step: string, details?: Record<string, unknown>) { function emitNormalizationDebug(step: string, details?: Record<string, unknown>) {
if (useAuthStore.getState().loggingMode !== 'debug') return; if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', { void invoke('frontend_debug_log', {
@@ -351,6 +495,22 @@ function normalizeAnalysisTrackId(trackId?: string | null): string | null {
return trackId; return trackId;
} }
/** Compare track ids across `stream:` / bare Subsonic forms. */
function sameQueueTrackId(a: string | undefined | null, b: string | undefined | null): boolean {
if (a == null || b == null) return false;
const na = normalizeAnalysisTrackId(a) ?? a;
const nb = normalizeAnalysisTrackId(b) ?? b;
return na === nb;
}
function queuesStructuralEqual(a: Track[], b: Track[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!sameQueueTrackId(a[i]?.id, b[i]?.id)) return false;
}
return true;
}
function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean { function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean {
if (a == null && b == null) return true; if (a == null && b == null) return true;
if (a == null || b == null) return false; if (a == null || b == null) return false;
@@ -1660,7 +1820,167 @@ export function initAudioListeners(): () => void {
export const usePlayerStore = create<PlayerState>()( export const usePlayerStore = create<PlayerState>()(
persist( persist(
(set, get) => ({ (set, get) => {
function applyQueueHistorySnapshot(snap: QueueUndoSnapshot, prior: PlayerState): boolean {
if (prior.currentRadio) {
clearRadioReconnectTimer();
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
}
let nextQueue = shallowCloneQueueTracks(snap.queue);
let nextIndex = snap.queueIndex;
let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null;
if (snap.currentTrack == null && prior.currentTrack) {
const playing = prior.currentTrack;
const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id));
if (pos === -1) {
nextQueue = [{ ...playing }, ...nextQueue];
nextIndex = 0;
nextTrack = { ...playing };
} else {
nextTrack = { ...playing };
nextIndex = pos;
}
}
nextIndex = Math.max(0, Math.min(nextIndex, Math.max(0, nextQueue.length - 1)));
const keepPlaybackFromPrior =
prior.currentTrack != null
&& nextTrack != null
&& sameQueueTrackId(prior.currentTrack.id, nextTrack.id)
&& nextQueue.some(t => sameQueueTrackId(t.id, prior.currentTrack!.id))
&& (
(snap.currentTrack != null && sameQueueTrackId(prior.currentTrack.id, snap.currentTrack.id))
|| snap.currentTrack == null
);
if (keepPlaybackFromPrior) {
const playingKeep = prior.currentTrack;
if (playingKeep) {
const idxPrior = nextQueue.findIndex(t => sameQueueTrackId(t.id, playingKeep.id));
if (idxPrior >= 0) {
nextIndex = idxPrior;
nextTrack = { ...playingKeep };
}
}
}
let tRestoreRaw = typeof snap.currentTime === 'number' && Number.isFinite(snap.currentTime)
? snap.currentTime
: 0;
let playingRestore = snap.isPlaying !== false;
if (keepPlaybackFromPrior && prior.currentTrack) {
tRestoreRaw = prior.currentTime;
playingRestore = prior.isPlaying;
}
const durForProgress = nextTrack?.duration && nextTrack.duration > 0 ? nextTrack.duration : null;
let pRestore = typeof snap.progress === 'number' && Number.isFinite(snap.progress)
? snap.progress
: (durForProgress != null && durForProgress > 0
? Math.max(0, Math.min(1, tRestoreRaw / durForProgress))
: 0);
if (keepPlaybackFromPrior) {
pRestore = prior.progress;
}
const tRestore = durForProgress != null
? Math.max(0, Math.min(tRestoreRaw, durForProgress))
: Math.max(0, tRestoreRaw);
const keepWaveform =
prior.currentTrack?.id != null &&
nextTrack?.id != null &&
sameQueueTrackId(prior.currentTrack.id, nextTrack.id);
const norm =
nextTrack != null
? deriveNormalizationSnapshot(nextTrack, nextQueue, nextIndex)
: ({
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
} as Pick<
PlayerState,
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
>);
const authSnap = useAuthStore.getState();
const playbackSourceUndo = nextTrack
? getPlaybackSourceKind(nextTrack.id, authSnap.activeServerId ?? '', null)
: null;
const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null
? prior.currentPlaybackSource
: playbackSourceUndo;
clearAllPlaybackScheduleTimers();
set({
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
});
gaplessPreloadingId = null;
bytePreloadingId = null;
let gen = playGeneration;
const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior;
if (resyncEngine || !nextTrack) {
gen = ++playGeneration;
if (resyncEngine) {
isAudioPaused = false;
}
}
set({
queue: nextQueue,
queueIndex: nextIndex,
currentTrack: nextTrack,
currentRadio: null,
currentTime: tRestore,
progress: pRestore,
isPlaying: playingRestore,
waveformBins: keepWaveform ? prior.waveformBins : null,
enginePreloadedTrackId: keepPlaybackFromPrior ? prior.enginePreloadedTrackId : null,
currentPlaybackSource: playbackSourceFinal,
...norm,
});
if (!nextTrack) {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
syncQueueToServer(nextQueue, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
pendingQueueListScrollTop = Math.max(0, snap.queueListScrollTop);
}
return true;
}
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
get().updateReplayGainForCurrentTrack();
if (!keepPlaybackFromPrior) {
const { nowPlayingEnabled: npUndo } = useAuthStore.getState();
if (npUndo) reportNowPlaying(nextTrack.id);
queueUndoRestoreAudioEngine({
generation: gen,
track: nextTrack,
queue: nextQueue,
queueIndex: nextIndex,
atSeconds: tRestore,
wantPlaying: playingRestore,
});
}
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
pendingQueueListScrollTop = Math.max(0, snap.queueListScrollTop);
}
syncQueueToServer(nextQueue, nextTrack, tRestore);
return true;
}
return {
currentTrack: null, currentTrack: null,
waveformBins: null, waveformBins: null,
normalizationNowDb: null, normalizationNowDb: null,
@@ -1868,7 +2188,7 @@ export const usePlayerStore = create<PlayerState>()(
if (!_orbitConfirmed && queue && queue.length > 1) { if (!_orbitConfirmed && queue && queue.length > 1) {
const current = get().queue; const current = get().queue;
const sameAsCurrent = queue.length === current.length const sameAsCurrent = queue.length === current.length
&& queue.every((t, i) => current[i]?.id === t.id); && queue.every((t, i) => sameQueueTrackId(current[i]?.id, t.id));
if (!sameAsCurrent) { if (!sameAsCurrent) {
void orbitBulkGuard(queue.length).then(ok => { void orbitBulkGuard(queue.length).then(ok => {
if (!ok) return; if (!ok) return;
@@ -1919,7 +2239,10 @@ export const usePlayerStore = create<PlayerState>()(
seekFallbackVisualTarget = null; seekFallbackVisualTarget = null;
} }
const newQueue = queue ?? state.queue; const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id); const idx = newQueue.findIndex(t => sameQueueTrackId(t.id, track.id));
if (manual) {
pushQueueUndoFromGetter(get);
}
const pendingVisualTarget = seekFallbackVisualTarget?.trackId === track.id const pendingVisualTarget = seekFallbackVisualTarget?.trackId === track.id
? seekFallbackVisualTarget.seconds ? seekFallbackVisualTarget.seconds
: null; : null;
@@ -2042,6 +2365,7 @@ export const usePlayerStore = create<PlayerState>()(
get().playTrack(track, [track]); get().playTrack(track, [track]);
return; return;
} }
pushQueueUndoFromGetter(get);
const wasPlaying = s.isPlaying; const wasPlaying = s.isPlaying;
set({ set({
queue: [track], queue: [track],
@@ -2057,10 +2381,12 @@ export const usePlayerStore = create<PlayerState>()(
if (s.currentRadio) return; if (s.currentRadio) return;
if (!s.currentTrack) { if (!s.currentTrack) {
if (s.queue.length === 0) return; if (s.queue.length === 0) return;
pushQueueUndoFromGetter(get);
set({ queue: [], queueIndex: 0 }); set({ queue: [], queueIndex: 0 });
syncQueueToServer([], null, 0); syncQueueToServer([], null, 0);
return; return;
} }
pushQueueUndoFromGetter(get);
const at = s.queue.findIndex(t => t.id === s.currentTrack!.id); const at = s.queue.findIndex(t => t.id === s.currentTrack!.id);
const newQueue: Track[] = const newQueue: Track[] =
at >= 0 at >= 0
@@ -2500,6 +2826,7 @@ export const usePlayerStore = create<PlayerState>()(
}); });
return; return;
} }
pushQueueUndoFromGetter(get);
set(state => { set(state => {
// Insert before the first upcoming auto-added track so the // Insert before the first upcoming auto-added track so the
// "Added automatically" separator always stays at the boundary. // "Added automatically" separator always stays at the boundary.
@@ -2523,6 +2850,7 @@ export const usePlayerStore = create<PlayerState>()(
enqueueRadio: (tracks, artistId) => { enqueueRadio: (tracks, artistId) => {
if (artistId) currentRadioArtistId = artistId; if (artistId) currentRadioArtistId = artistId;
pushQueueUndoFromGetter(get);
set(state => { set(state => {
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio" // Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
// again replaces the pending radio batch instead of stacking on top. // again replaces the pending radio batch instead of stacking on top.
@@ -2550,6 +2878,7 @@ export const usePlayerStore = create<PlayerState>()(
}); });
return; return;
} }
pushQueueUndoFromGetter(get);
set(state => { set(state => {
const idx = Math.max(0, Math.min(insertIndex, state.queue.length)); const idx = Math.max(0, Math.min(insertIndex, state.queue.length));
const newQueue = [ const newQueue = [
@@ -2576,6 +2905,7 @@ export const usePlayerStore = create<PlayerState>()(
}, },
reorderQueue: (startIndex, endIndex) => { reorderQueue: (startIndex, endIndex) => {
pushQueueUndoFromGetter(get);
const { queue, queueIndex, currentTrack } = get(); const { queue, queueIndex, currentTrack } = get();
const result = Array.from(queue); const result = Array.from(queue);
const [removed] = result.splice(startIndex, 1); const [removed] = result.splice(startIndex, 1);
@@ -2589,6 +2919,7 @@ export const usePlayerStore = create<PlayerState>()(
shuffleQueue: () => { shuffleQueue: () => {
const { queue, currentTrack } = get(); const { queue, currentTrack } = get();
if (queue.length < 2) return; if (queue.length < 2) return;
pushQueueUndoFromGetter(get);
const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1; const currentIdx = currentTrack ? queue.findIndex(t => t.id === currentTrack.id) : -1;
const others = queue.filter((_, i) => i !== currentIdx); const others = queue.filter((_, i) => i !== currentIdx);
for (let i = others.length - 1; i > 0; i--) { for (let i = others.length - 1; i > 0; i--) {
@@ -2608,6 +2939,7 @@ export const usePlayerStore = create<PlayerState>()(
const upcomingStart = queueIndex + 1; const upcomingStart = queueIndex + 1;
const upcomingCount = queue.length - upcomingStart; const upcomingCount = queue.length - upcomingStart;
if (upcomingCount < 2) return; if (upcomingCount < 2) return;
pushQueueUndoFromGetter(get);
const head = queue.slice(0, upcomingStart); const head = queue.slice(0, upcomingStart);
const upcoming = queue.slice(upcomingStart); const upcoming = queue.slice(upcomingStart);
for (let i = upcoming.length - 1; i > 0; i--) { for (let i = upcoming.length - 1; i > 0; i--) {
@@ -2619,7 +2951,26 @@ export const usePlayerStore = create<PlayerState>()(
syncQueueToServer(result, currentTrack, get().currentTime); syncQueueToServer(result, currentTrack, get().currentTime);
}, },
undoLastQueueEdit: () => {
const prior = get();
const snap = queueUndoStack.pop();
if (!snap) return false;
queueRedoStack.push(queueUndoSnapshotFromState(prior));
while (queueRedoStack.length > QUEUE_UNDO_MAX) queueRedoStack.shift();
return applyQueueHistorySnapshot(snap, prior);
},
redoLastQueueEdit: () => {
const prior = get();
const snap = queueRedoStack.pop();
if (!snap) return false;
queueUndoStack.push(queueUndoSnapshotFromState(prior));
while (queueUndoStack.length > QUEUE_UNDO_MAX) queueUndoStack.shift();
return applyQueueHistorySnapshot(snap, prior);
},
removeTrack: (index) => { removeTrack: (index) => {
pushQueueUndoFromGetter(get);
const { queue, queueIndex } = get(); const { queue, queueIndex } = get();
const newQueue = [...queue]; const newQueue = [...queue];
newQueue.splice(index, 1); newQueue.splice(index, 1);
@@ -2703,7 +3054,8 @@ export const usePlayerStore = create<PlayerState>()(
fallbackDb: authState.replayGainFallbackDb, fallbackDb: authState.replayGainFallbackDb,
}); });
}, },
}), };
},
{ {
name: 'psysonic-player', name: 'psysonic-player',
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
@@ -2725,3 +3077,51 @@ export const usePlayerStore = create<PlayerState>()(
} }
) )
); );
const QUEUE_UNDO_HOTKEY_FLAG = '__psyQueueUndoListenerInstalled';
/** True when the event path includes a real text field — skip queue undo so Ctrl+Z stays native there. */
function keyboardEventTargetIsEditableField(e: KeyboardEvent): boolean {
for (const n of e.composedPath()) {
if (!(n instanceof HTMLElement)) continue;
const tag = n.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (n.isContentEditable) return true;
}
return false;
}
/**
* Ctrl+Z / Cmd+Z undo and Ctrl+Shift+Z / Cmd+Shift+Z redo for the queue document capture.
* Call once at startup (e.g. from main.tsx); idempotent. Skips the mini-player window.
*/
export function installQueueUndoHotkey(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (w[QUEUE_UNDO_HOTKEY_FLAG]) return;
const label = w.__PSY_WINDOW_LABEL__;
if (label === 'mini') return;
w[QUEUE_UNDO_HOTKEY_FLAG] = true;
document.addEventListener(
'keydown',
(e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.code !== 'KeyZ' && String(e.key || '').toLowerCase() !== 'z') return;
if (keyboardEventTargetIsEditableField(e)) return;
if (e.shiftKey) {
if (usePlayerStore.getState().redoLastQueueEdit()) {
e.preventDefault();
e.stopPropagation();
}
return;
}
if (usePlayerStore.getState().undoLastQueueEdit()) {
e.preventDefault();
e.stopPropagation();
}
},
true,
);
}
+10
View File
@@ -198,6 +198,14 @@ export function initMiniPlayerBridgeOnMain(): () => void {
usePlayerStore.getState().shuffleQueue(); usePlayerStore.getState().shuffleQueue();
}); });
const undoQueueUnlisten = listen('mini:undo-queue', () => {
usePlayerStore.getState().undoLastQueueEdit();
});
const redoQueueUnlisten = listen('mini:redo-queue', () => {
usePlayerStore.getState().redoLastQueueEdit();
});
// Gapless ↔ Crossfade are mutually exclusive (see CLAUDE.md). Bridge handles // Gapless ↔ Crossfade are mutually exclusive (see CLAUDE.md). Bridge handles
// the exclusion so the mini doesn't need to know about both states to act. // the exclusion so the mini doesn't need to know about both states to act.
const gaplessUnlisten = listen<{ value: boolean }>('mini:set-gapless', (e) => { const gaplessUnlisten = listen<{ value: boolean }>('mini:set-gapless', (e) => {
@@ -241,6 +249,8 @@ export function initMiniPlayerBridgeOnMain(): () => void {
navigateUnlisten.then(fn => fn()).catch(() => {}); navigateUnlisten.then(fn => fn()).catch(() => {});
volumeUnlisten.then(fn => fn()).catch(() => {}); volumeUnlisten.then(fn => fn()).catch(() => {});
shuffleUnlisten.then(fn => fn()).catch(() => {}); shuffleUnlisten.then(fn => fn()).catch(() => {});
undoQueueUnlisten.then(fn => fn()).catch(() => {});
redoQueueUnlisten.then(fn => fn()).catch(() => {});
gaplessUnlisten.then(fn => fn()).catch(() => {}); gaplessUnlisten.then(fn => fn()).catch(() => {});
crossfadeUnlisten.then(fn => fn()).catch(() => {}); crossfadeUnlisten.then(fn => fn()).catch(() => {});
infiniteQueueUnlisten.then(fn => fn()).catch(() => {}); infiniteQueueUnlisten.then(fn => fn()).catch(() => {});