From 603660d407a548be71109b4a01cee3241b75abb9 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 15 May 2026 21:32:04 +0300 Subject: [PATCH] fix(queue): Lucky Mix uses one undo snapshot for Ctrl+Z (#728) Many prune/play/enqueue steps exhausted QUEUE_UNDO_MAX and dropped the pre-mix snapshot. Push undo once before the macro rebuild; add skipQueueUndo for enqueue and pruneUpcomingToCurrent; Lucky Mix playTrack uses manual=false. CHANGELOG: document fix under 1.46.0 Fixed (PR #728). --- CHANGELOG.md | 6 ++++++ src/store/playerStoreTypes.ts | 8 +++++--- src/store/queueMutationActions.ts | 14 ++++++++------ src/utils/mix/luckyMix.ts | 26 +++++++++++++++----------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f53d105..ffcb487b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -540,6 +540,12 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * The three header dropdowns (Orbit launch, Server picker, Live listeners) each had their own container styling. Live in particular used a glass / backdrop-filter utility that read poorly on many themes. All three now share the **`.nav-library-dropdown-panel`** container — same background, border, shadow and radius via the existing semantic tokens. Item layouts per dropdown stay case-specific. +### Queue — Lucky Mix coalesced into one Ctrl+Z / Cmd+Z undo step + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#728](https://github.com/Psychotoxical/psysonic/pull/728)** + +* **Lucky Mix** ran many queue edits (`pruneUpcomingToCurrent`, **`playTrack`**, **`enqueue`** batches). Each pushed onto the bounded **`QUEUE_UNDO_MAX`** stack, so the snapshot taken **before** Lucky Mix was usually shifted off — Ctrl+Z only stepped through small edits or could not restore the prior queue. The mix flow now pushes **one** undo snapshot up-front and skips intermediate **`enqueue`** / prune snapshots (**`skipQueueUndo`**) so a single undo restores the queue from immediately **before** Lucky Mix. + ## [1.45.0] - 2026-05-04 ## Added diff --git a/src/store/playerStoreTypes.ts b/src/store/playerStoreTypes.ts index 5498bd58..b0f82f0a 100644 --- a/src/store/playerStoreTypes.ts +++ b/src/store/playerStoreTypes.ts @@ -107,7 +107,8 @@ export interface PlayerState { updateReplayGainForCurrentTrack: () => void; reanalyzeLoudnessForTrack: (trackId: string) => Promise; setProgress: (t: number, duration: number) => void; - enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void; + /** `_orbitConfirmed` bypasses the bulk-append gate. `skipQueueUndo` skips the undo snapshot (macro builders such as Lucky Mix push once up-front). */ + enqueue: (tracks: Track[], _orbitConfirmed?: boolean, skipQueueUndo?: boolean) => void; enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void; /** "Play Next" — inserts after the current track. When * `preservePlayNextOrder` is on, appends to the existing Play-Next streak @@ -117,8 +118,9 @@ export interface PlayerState { playNext: (tracks: Track[]) => void; enqueueRadio: (tracks: Track[], artistId?: string) => void; setRadioArtistId: (artistId: string) => void; - /** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */ - pruneUpcomingToCurrent: () => void; + /** For Lucky Mix: drop upcoming tail; keep the currently playing item only. + * When `skipQueueUndo` is true, callers must push undo separately (macro rebuild). */ + pruneUpcomingToCurrent: (skipQueueUndo?: boolean) => void; clearQueue: () => void; isQueueVisible: boolean; diff --git a/src/store/queueMutationActions.ts b/src/store/queueMutationActions.ts index b3447b2e..ac355588 100644 --- a/src/store/queueMutationActions.ts +++ b/src/store/queueMutationActions.ts @@ -36,6 +36,8 @@ function blockCrossServerEnqueue(): boolean { * Eleven queue-mutation actions. Shared invariant: every action except * `setRadioArtistId` pushes a queue-undo snapshot and calls * `syncQueueToServer` so the Navidrome `savePlayQueue` stays in sync. + * Exceptions: `enqueue`'s optional third argument **`skipQueueUndo`** and + * **`pruneUpcomingToCurrent(true)`** — Lucky Mix pushes one snapshot up-front. */ export function createQueueMutationActions(set: SetState, get: GetState): Pick< PlayerState, @@ -52,15 +54,15 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< | 'removeTrack' > { return { - enqueue: (tracks, _orbitConfirmed = false) => { + enqueue: (tracks, _orbitConfirmed = false, skipQueueUndo = false) => { if (blockCrossServerEnqueue()) return; if (!_orbitConfirmed && tracks.length > 1) { void orbitBulkGuard(tracks.length).then(ok => { - if (ok) get().enqueue(tracks, true); + if (ok) get().enqueue(tracks, true, skipQueueUndo); }); return; } - pushQueueUndoFromGetter(get); + if (!skipQueueUndo) pushQueueUndoFromGetter(get); set(state => { // Insert before the first upcoming auto-added track so the // "Added automatically" separator always stays at the boundary. @@ -181,17 +183,17 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< get().enqueueAt(tagged, insertIdx); }, - pruneUpcomingToCurrent: () => { + pruneUpcomingToCurrent: (skipQueueUndo = false) => { const s = get(); if (s.currentRadio) return; if (!s.currentTrack) { if (s.queue.length === 0) return; - pushQueueUndoFromGetter(get); + if (!skipQueueUndo) pushQueueUndoFromGetter(get); set({ queue: [], queueIndex: 0 }); syncQueueToServer([], null, 0); return; } - pushQueueUndoFromGetter(get); + if (!skipQueueUndo) pushQueueUndoFromGetter(get); const at = s.queue.findIndex(t => t.id === s.currentTrack!.id); const newQueue: Track[] = at >= 0 diff --git a/src/utils/mix/luckyMix.ts b/src/utils/mix/luckyMix.ts index bd41876a..996fd78c 100644 --- a/src/utils/mix/luckyMix.ts +++ b/src/utils/mix/luckyMix.ts @@ -6,6 +6,7 @@ import { songToTrack } from '../playback/songToTrack'; import { invoke } from '@tauri-apps/api/core'; import i18n from '../../i18n'; import { useAuthStore } from '../../store/authStore'; +import { pushQueueUndoFromGetter } from '../../store/queueUndo'; import { usePlayerStore } from '../../store/playerStore'; import { useLuckyMixStore } from '../../store/luckyMixStore'; import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable'; @@ -89,18 +90,20 @@ export async function buildAndPlayLuckyMix(): Promise { queueIndex: playerStateBefore.queueIndex, }; - // Drop the old "upcoming" tail immediately so the queue UI does not show stale - // next tracks while the mix is still building (first playTrack may be delayed). - usePlayerStore.getState().pruneUpcomingToCurrent(); + // One undo step for the whole Lucky Mix run — internal prune/play/enqueue + // batches must not each push (QUEUE_UNDO_MAX would drop this snapshot). + pushQueueUndoFromGetter(() => usePlayerStore.getState()); - lucky.start(); - // Per-run handles. Live outside the try so `finally`/`catch` can read - // `startedPlayback` (drives the queue-restore decision) and clean up the - // player-store subscription unconditionally. let unsubPlayer: (() => void) | null = null; - let startedPlayback = false; try { - let allSeedSongs: SubsonicSong[] = []; + // Drop the old "upcoming" tail immediately so the queue UI does not show stale + // next tracks while the mix is still building (first playTrack may be delayed). + usePlayerStore.getState().pruneUpcomingToCurrent(true); + + lucky.start(); + let startedPlayback = false; + try { + let allSeedSongs: SubsonicSong[] = []; const mixQueueSize = () => usePlayerStore.getState().queue.length; const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id)); @@ -117,7 +120,7 @@ export async function buildAndPlayLuckyMix(): Promise { const play = allowed[0]; startedPlayback = true; const track = songToTrack(play); - usePlayerStore.getState().playTrack(track, [track], true); + usePlayerStore.getState().playTrack(track, [track], false); logStep('start_immediate_playback', { source, song: songDebug([play])[0], @@ -160,7 +163,7 @@ export async function buildAndPlayLuckyMix(): Promise { const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length)); if (!toAdd.length) return 0; const before = mixQueueSize(); - usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true); + usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true, true); const added = mixQueueSize() - before; logStep('append_queue_batch', { reason, @@ -373,6 +376,7 @@ export async function buildAndPlayLuckyMix(): Promise { }); } showToast(i18n.t('luckyMix.failed'), 5000, 'error'); + } } finally { if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } } useLuckyMixStore.getState().stop();