refactor(player): E.34 — extract transport-light + undo/redo factories (#598)

Two small action-factory extractions in one PR, both following the
pattern from E.31–E.33.

- `createTransportLightActions` — `stop`, `pause`, `resetAudioPause`,
  `togglePlay`. Everything in the pause/togglePlay cluster except
  `resume` (~165 LOC, deferred to its own PR).
- `createUndoRedoActions` — `undoLastQueueEdit`, `redoLastQueueEdit`.
  Trivial wrappers around `applyQueueHistorySnapshot` + the queue-undo
  stack helpers.

Pure code-move. playerStore.ts: 1247 → 1167 LOC (−80).
This commit is contained in:
Frank Stellmacher
2026-05-12 20:52:22 +02:00
committed by GitHub
parent 168d5905c2
commit 76fc3bb9c9
3 changed files with 140 additions and 85 deletions
+5 -85
View File
@@ -58,7 +58,6 @@ import { applySkipStarOnManualNext } from './skipStarRating';
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState'; import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
import { import {
flushPlayQueuePosition, flushPlayQueuePosition,
flushQueueSyncToServer,
syncQueueToServer, syncQueueToServer,
} from './queueSync'; } from './queueSync';
import { import {
@@ -70,13 +69,11 @@ import {
clearSeekTarget, clearSeekTarget,
setSeekTarget, setSeekTarget,
} from './seekTargetState'; } from './seekTargetState';
import { tryAcquireTogglePlayLock } from './togglePlayLock';
import { reseedLoudnessForTrackId } from './loudnessReseed'; import { reseedLoudnessForTrackId } from './loudnessReseed';
import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshWaveformForTrack } from './waveformRefresh';
import { refreshLoudnessForTrack } from './loudnessRefresh'; import { refreshLoudnessForTrack } from './loudnessRefresh';
import { import {
clearRadioReconnectTimer, clearRadioReconnectTimer,
pauseRadio,
playRadioStream, playRadioStream,
resumeRadio, resumeRadio,
setRadioVolume, setRadioVolume,
@@ -141,14 +138,8 @@ export {
import { import {
_resetQueueUndoStacksForTest, _resetQueueUndoStacksForTest,
consumePendingQueueListScrollTop, consumePendingQueueListScrollTop,
popQueueRedoSnapshot,
popQueueUndoSnapshot,
pushQueueRedoSnapshot,
pushQueueUndoFromGetter, pushQueueUndoFromGetter,
pushQueueUndoSnapshot,
queueUndoSnapshotFromState,
registerQueueListScrollTopReader, registerQueueListScrollTopReader,
type QueueUndoSnapshot,
} from './queueUndo'; } from './queueUndo';
// Re-export for backward compatibility with the ~30 call sites that still // Re-export for backward compatibility with the ~30 call sites that still
@@ -166,10 +157,11 @@ export {
import type { PlayerState, Track } from './playerStoreTypes'; import type { PlayerState, Track } from './playerStoreTypes';
export type { PlayerState, Track }; export type { PlayerState, Track };
import { applyQueueHistorySnapshot } from './applyQueueHistorySnapshot';
import { createLastfmActions } from './lastfmActions'; import { createLastfmActions } from './lastfmActions';
import { createQueueMutationActions } from './queueMutationActions'; import { createQueueMutationActions } from './queueMutationActions';
import { createTransportLightActions } from './transportLightActions';
import { createUiStateActions } from './uiStateActions'; import { createUiStateActions } from './uiStateActions';
import { createUndoRedoActions } from './undoRedoActions';
// ─── Module-level playback primitives ───────────────────────────────────────── // ─── Module-level playback primitives ─────────────────────────────────────────
@@ -221,36 +213,8 @@ export const usePlayerStore = create<PlayerState>()(
...createUiStateActions(set), ...createUiStateActions(set),
...createLastfmActions(set, get), ...createLastfmActions(set, get),
...createQueueMutationActions(set, get), ...createQueueMutationActions(set, get),
...createTransportLightActions(set, get),
// ── stop ──────────────────────────────────────────────────────────────── ...createUndoRedoActions(set, get),
stop: () => {
clearAllPlaybackScheduleTimers();
if (get().currentRadio) {
stopRadio();
} else {
invoke('audio_stop').catch(console.error);
}
setIsAudioPaused(false);
clearSeekFallbackRetry();
clearSeekDebounce(); clearSeekTarget();
set({
isPlaying: false,
progress: 0,
buffered: 0,
currentTime: 0,
currentRadio: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null,
enginePreloadedTrackId: null,
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
});
},
// ── playRadio ──────────────────────────────────────────────────────────── // ── playRadio ────────────────────────────────────────────────────────────
playRadio: async (station) => { playRadio: async (station) => {
@@ -591,28 +555,7 @@ export const usePlayerStore = create<PlayerState>()(
if (!wasPlaying) get().resume(); if (!wasPlaying) get().resume();
}, },
// ── pause / resume / togglePlay ────────────────────────────────────────── // ── resume ───────────────────────────────────────────────────────────────
pause: () => {
clearAllPlaybackScheduleTimers();
if (get().currentRadio) {
pauseRadio();
} else {
invoke('audio_pause').catch(console.error);
setIsAudioPaused(true);
// Flush position so a quick close after pause still leaves the
// server with the right resume point for other devices.
const s = get();
if (s.currentTrack) {
void flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime);
}
}
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
},
resetAudioPause: () => {
setIsAudioPaused(false);
},
resume: () => { resume: () => {
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
@@ -815,12 +758,6 @@ export const usePlayerStore = create<PlayerState>()(
}); });
}, },
togglePlay: () => {
if (!tryAcquireTogglePlayLock()) return;
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},
// ── next / previous ────────────────────────────────────────────────────── // ── next / previous ──────────────────────────────────────────────────────
next: (manual = true) => { next: (manual = true) => {
const { queue, queueIndex, repeatMode, currentTrack } = get(); const { queue, queueIndex, repeatMode, currentTrack } = get();
@@ -1099,23 +1036,6 @@ export const usePlayerStore = create<PlayerState>()(
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 }); set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
}, },
// ── queue management ─────────────────────────────────────────────────────
undoLastQueueEdit: () => {
const prior = get();
const snap = popQueueUndoSnapshot();
if (!snap) return false;
pushQueueRedoSnapshot(queueUndoSnapshotFromState(prior));
return applyQueueHistorySnapshot(snap, prior, set, get);
},
redoLastQueueEdit: () => {
const prior = get();
const snap = popQueueRedoSnapshot();
if (!snap) return false;
pushQueueUndoSnapshot(queueUndoSnapshotFromState(prior));
return applyQueueHistorySnapshot(snap, prior, set, get);
},
// ── server queue restore ───────────────────────────────────────────────── // ── server queue restore ─────────────────────────────────────────────────
initializeFromServerQueue: async () => { initializeFromServerQueue: async () => {
try { try {
+92
View File
@@ -0,0 +1,92 @@
import { invoke } from '@tauri-apps/api/core';
import { setIsAudioPaused } from './engineState';
import type { PlayerState } from './playerStoreTypes';
import { flushQueueSyncToServer } from './queueSync';
import { pauseRadio, stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { clearSeekDebounce } from './seekDebounce';
import { clearSeekFallbackRetry } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState';
import { tryAcquireTogglePlayLock } from './togglePlayLock';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Light transport actions factored out of the playerStore `create()`
* body — everything except `resume` (~165 LOC, separate PR) and the
* scheduled pause/resume timer setters.
*
* - `stop` — full reset: stops audio/radio, clears timers + seek + visual
* state, blanks playback metadata.
* - `pause` — pauses audio (or radio), flushes queue position so other
* devices can pick up the resume point.
* - `resetAudioPause` — flips the engine-paused flag without touching
* the UI `isPlaying` state. Used by `audio:ended` paths.
* - `togglePlay` — guarded toggle so a double media-key tap can't race
* pause + resume into a stuck state.
*/
export function createTransportLightActions(set: SetState, get: GetState): Pick<
PlayerState,
'stop' | 'pause' | 'resetAudioPause' | 'togglePlay'
> {
return {
stop: () => {
clearAllPlaybackScheduleTimers();
if (get().currentRadio) {
stopRadio();
} else {
invoke('audio_stop').catch(console.error);
}
setIsAudioPaused(false);
clearSeekFallbackRetry();
clearSeekDebounce(); clearSeekTarget();
set({
isPlaying: false,
progress: 0,
buffered: 0,
currentTime: 0,
currentRadio: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null,
enginePreloadedTrackId: null,
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
});
},
pause: () => {
clearAllPlaybackScheduleTimers();
if (get().currentRadio) {
pauseRadio();
} else {
invoke('audio_pause').catch(console.error);
setIsAudioPaused(true);
// Flush position so a quick close after pause still leaves the
// server with the right resume point for other devices.
const s = get();
if (s.currentTrack) {
void flushQueueSyncToServer(s.queue, s.currentTrack, s.currentTime);
}
}
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
},
resetAudioPause: () => {
setIsAudioPaused(false);
},
togglePlay: () => {
if (!tryAcquireTogglePlayLock()) return;
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import { applyQueueHistorySnapshot } from './applyQueueHistorySnapshot';
import type { PlayerState } from './playerStoreTypes';
import {
popQueueRedoSnapshot,
popQueueUndoSnapshot,
pushQueueRedoSnapshot,
pushQueueUndoSnapshot,
queueUndoSnapshotFromState,
} from './queueUndo';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Undo / redo wrappers for queue edits. Both pop the matching history
* snapshot, push the *prior* state onto the opposite stack so the next
* call reverses direction, and delegate the actual state reconciliation
* to `applyQueueHistorySnapshot`.
*/
export function createUndoRedoActions(set: SetState, get: GetState): Pick<
PlayerState,
'undoLastQueueEdit' | 'redoLastQueueEdit'
> {
return {
undoLastQueueEdit: () => {
const prior = get();
const snap = popQueueUndoSnapshot();
if (!snap) return false;
pushQueueRedoSnapshot(queueUndoSnapshotFromState(prior));
return applyQueueHistorySnapshot(snap, prior, set, get);
},
redoLastQueueEdit: () => {
const prior = get();
const snap = popQueueRedoSnapshot();
if (!snap) return false;
pushQueueUndoSnapshot(queueUndoSnapshotFromState(prior));
return applyQueueHistorySnapshot(snap, prior, set, get);
},
};
}