mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
+11
-3
@@ -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) {
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|||||||
@@ -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,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
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user