mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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).
This commit is contained in:
@@ -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.
|
* 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
|
## [1.45.0] - 2026-05-04
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ export interface PlayerState {
|
|||||||
updateReplayGainForCurrentTrack: () => void;
|
updateReplayGainForCurrentTrack: () => void;
|
||||||
reanalyzeLoudnessForTrack: (trackId: string) => Promise<void>;
|
reanalyzeLoudnessForTrack: (trackId: string) => Promise<void>;
|
||||||
setProgress: (t: number, duration: number) => void;
|
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;
|
enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void;
|
||||||
/** "Play Next" — inserts after the current track. When
|
/** "Play Next" — inserts after the current track. When
|
||||||
* `preservePlayNextOrder` is on, appends to the existing Play-Next streak
|
* `preservePlayNextOrder` is on, appends to the existing Play-Next streak
|
||||||
@@ -117,8 +118,9 @@ export interface PlayerState {
|
|||||||
playNext: (tracks: Track[]) => void;
|
playNext: (tracks: Track[]) => void;
|
||||||
enqueueRadio: (tracks: Track[], artistId?: string) => void;
|
enqueueRadio: (tracks: Track[], artistId?: string) => void;
|
||||||
setRadioArtistId: (artistId: string) => void;
|
setRadioArtistId: (artistId: string) => void;
|
||||||
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */
|
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only.
|
||||||
pruneUpcomingToCurrent: () => void;
|
* When `skipQueueUndo` is true, callers must push undo separately (macro rebuild). */
|
||||||
|
pruneUpcomingToCurrent: (skipQueueUndo?: boolean) => void;
|
||||||
clearQueue: () => void;
|
clearQueue: () => void;
|
||||||
|
|
||||||
isQueueVisible: boolean;
|
isQueueVisible: boolean;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ function blockCrossServerEnqueue(): boolean {
|
|||||||
* Eleven queue-mutation actions. Shared invariant: every action except
|
* Eleven queue-mutation actions. Shared invariant: every action except
|
||||||
* `setRadioArtistId` pushes a queue-undo snapshot and calls
|
* `setRadioArtistId` pushes a queue-undo snapshot and calls
|
||||||
* `syncQueueToServer` so the Navidrome `savePlayQueue` stays in sync.
|
* `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<
|
export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||||
PlayerState,
|
PlayerState,
|
||||||
@@ -52,15 +54,15 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
| 'removeTrack'
|
| 'removeTrack'
|
||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
enqueue: (tracks, _orbitConfirmed = false) => {
|
enqueue: (tracks, _orbitConfirmed = false, skipQueueUndo = false) => {
|
||||||
if (blockCrossServerEnqueue()) return;
|
if (blockCrossServerEnqueue()) return;
|
||||||
if (!_orbitConfirmed && tracks.length > 1) {
|
if (!_orbitConfirmed && tracks.length > 1) {
|
||||||
void orbitBulkGuard(tracks.length).then(ok => {
|
void orbitBulkGuard(tracks.length).then(ok => {
|
||||||
if (ok) get().enqueue(tracks, true);
|
if (ok) get().enqueue(tracks, true, skipQueueUndo);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushQueueUndoFromGetter(get);
|
if (!skipQueueUndo) 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.
|
||||||
@@ -181,17 +183,17 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
get().enqueueAt(tagged, insertIdx);
|
get().enqueueAt(tagged, insertIdx);
|
||||||
},
|
},
|
||||||
|
|
||||||
pruneUpcomingToCurrent: () => {
|
pruneUpcomingToCurrent: (skipQueueUndo = false) => {
|
||||||
const s = get();
|
const s = get();
|
||||||
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);
|
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
|
||||||
set({ queue: [], queueIndex: 0 });
|
set({ queue: [], queueIndex: 0 });
|
||||||
syncQueueToServer([], null, 0);
|
syncQueueToServer([], null, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushQueueUndoFromGetter(get);
|
if (!skipQueueUndo) 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
|
||||||
|
|||||||
+15
-11
@@ -6,6 +6,7 @@ import { songToTrack } from '../playback/songToTrack';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import i18n from '../../i18n';
|
import i18n from '../../i18n';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { pushQueueUndoFromGetter } from '../../store/queueUndo';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { useLuckyMixStore } from '../../store/luckyMixStore';
|
import { useLuckyMixStore } from '../../store/luckyMixStore';
|
||||||
import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
||||||
@@ -89,18 +90,20 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
queueIndex: playerStateBefore.queueIndex,
|
queueIndex: playerStateBefore.queueIndex,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drop the old "upcoming" tail immediately so the queue UI does not show stale
|
// One undo step for the whole Lucky Mix run — internal prune/play/enqueue
|
||||||
// next tracks while the mix is still building (first playTrack may be delayed).
|
// batches must not each push (QUEUE_UNDO_MAX would drop this snapshot).
|
||||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
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 unsubPlayer: (() => void) | null = null;
|
||||||
let startedPlayback = false;
|
|
||||||
try {
|
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 mixQueueSize = () => usePlayerStore.getState().queue.length;
|
||||||
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id));
|
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id));
|
||||||
@@ -117,7 +120,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
const play = allowed[0];
|
const play = allowed[0];
|
||||||
startedPlayback = true;
|
startedPlayback = true;
|
||||||
const track = songToTrack(play);
|
const track = songToTrack(play);
|
||||||
usePlayerStore.getState().playTrack(track, [track], true);
|
usePlayerStore.getState().playTrack(track, [track], false);
|
||||||
logStep('start_immediate_playback', {
|
logStep('start_immediate_playback', {
|
||||||
source,
|
source,
|
||||||
song: songDebug([play])[0],
|
song: songDebug([play])[0],
|
||||||
@@ -160,7 +163,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
||||||
if (!toAdd.length) return 0;
|
if (!toAdd.length) return 0;
|
||||||
const before = mixQueueSize();
|
const before = mixQueueSize();
|
||||||
usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true);
|
usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true, true);
|
||||||
const added = mixQueueSize() - before;
|
const added = mixQueueSize() - before;
|
||||||
logStep('append_queue_batch', {
|
logStep('append_queue_batch', {
|
||||||
reason,
|
reason,
|
||||||
@@ -373,6 +376,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
|
if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
|
||||||
useLuckyMixStore.getState().stop();
|
useLuckyMixStore.getState().stop();
|
||||||
|
|||||||
Reference in New Issue
Block a user