refactor(player): E.35 — extract misc-actions factory (#599)

Heterogeneous "misc" cluster — seven small-to-medium actions that
didn't fit the more focused factories (transport / queue / Last.fm /
UI state):

- `playRadio` — switches the player into HTML5 radio mode.
- `previous` — Subsonic-style back: restart if past 3 s, otherwise
  jump to the previous queue index.
- `setVolume` — clamps + propagates to Rust engine and radio sink.
- `setProgress` — pure UI state update for progress polling.
- `initializeFromServerQueue` — startup queue restore from Navidrome.
- `reanalyzeLoudnessForTrack` — toast + reseed the loudness cache.
- `reseedQueueForInstantMix` — replace the queue with a single track.

Pure code-move. playerStore.ts: 1167 → 1030 LOC (−137).
This commit is contained in:
Frank Stellmacher
2026-05-12 21:01:12 +02:00
committed by GitHub
parent 76fc3bb9c9
commit 1e2c651196
2 changed files with 200 additions and 140 deletions
+197
View File
@@ -0,0 +1,197 @@
import { invoke } from '@tauri-apps/api/core';
import i18n from '../i18n';
import { getPlayQueue } from '../api/subsonic';
import { songToTrack } from '../utils/songToTrack';
import { showToast } from '../utils/toast';
import { useAuthStore } from './authStore';
import {
bumpPlayGeneration,
setIsAudioPaused,
} from './engineState';
import { clearPreloadingIds } from './gaplessPreloadState';
import { reseedLoudnessForTrackId } from './loudnessReseed';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
import type { PlayerState, Track } from './playerStoreTypes';
import { pushQueueUndoFromGetter } from './queueUndo';
import { syncQueueToServer } from './queueSync';
import {
clearRadioReconnectTimer,
playRadioStream,
setRadioVolume,
} from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { clearSeekDebounce } from './seekDebounce';
import {
clearSeekFallbackRetry,
setSeekFallbackVisualTarget,
} from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
/**
* Heterogeneous "misc" cluster — seven small-to-medium actions that
* don't fit the more focused factories (transport / queue / Last.fm /
* UI state):
*
* - `playRadio` — switches the player into HTML5 radio mode (Rust
* engine stopped, queue cleared, ICY stream resolved + played).
* - `previous` — Subsonic-style back: restart current track if past
* 3 s, otherwise jump to the previous queue index.
* - `setVolume` — clamps + propagates to Rust engine and radio sink.
* - `setProgress` — pure UI state update used by progress polling.
* - `initializeFromServerQueue` — startup queue restore from
* Navidrome's `getPlayQueue` endpoint.
* - `reanalyzeLoudnessForTrack` — toast + reseed the loudness cache
* for a single track.
* - `reseedQueueForInstantMix` — replaces the queue with a single
* track when "Instant Mix" is triggered on the currently-playing
* song.
*/
export function createMiscActions(set: SetState, get: GetState): Pick<
PlayerState,
| 'playRadio'
| 'previous'
| 'setVolume'
| 'setProgress'
| 'initializeFromServerQueue'
| 'reanalyzeLoudnessForTrack'
| 'reseedQueueForInstantMix'
> {
return {
playRadio: async (station) => {
const { volume } = get();
bumpPlayGeneration();
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
setIsAudioPaused(false);
clearRadioReconnectTimer();
clearPreloadingIds();
clearSeekFallbackRetry();
clearSeekDebounce(); clearSeekTarget();
// Stop Rust engine in case a regular track was playing.
invoke('audio_stop').catch(() => {});
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
// to HTML5 <audio> — the browser cannot play playlist files directly.
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
.catch(() => station.streamUrl);
const { replayGainFallbackDb } = useAuthStore.getState();
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
playRadioStream(streamUrl, Math.min(1, volume * fallbackFactor)).catch((err: unknown) => {
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
set({ isPlaying: false, currentRadio: null });
});
set({
currentRadio: station,
currentTrack: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null,
queue: [],
queueIndex: 0,
isPlaying: true,
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: true, // no scrobbling for radio
});
},
previous: () => {
const { queue, queueIndex, currentTrack } = get();
const currentTime = getPlaybackProgressSnapshot().currentTime;
if (currentTime > 3) {
// Restart current track from the beginning.
const authState = useAuthStore.getState();
const sid = authState.activeServerId ?? '';
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() });
get().playTrack(currentTrack, queue, true);
return;
}
invoke('audio_seek', { seconds: 0 }).catch(console.error);
set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue, true, false, prevIdx);
},
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
setRadioVolume(clamped);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map(songToTrack);
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
}
// Prefer the server position if available; otherwise keep the
// localStorage-persisted currentTime (more reliable than server
// queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime;
set({
queue: mappedTracks,
queueIndex,
currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime,
});
void refreshWaveformForTrack(currentTrack.id);
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
}
},
reanalyzeLoudnessForTrack: async (trackId: string) => {
try {
showToast(i18n.t('queue.recalculatingLoudnessWaveform'), 2000, 'info');
} catch {
// no-op
}
await reseedLoudnessForTrackId(trackId);
},
reseedQueueForInstantMix: (track) => {
const s = get();
if (s.currentTrack?.id !== track.id) {
get().playTrack(track, [track]);
return;
}
pushQueueUndoFromGetter(get);
const wasPlaying = s.isPlaying;
set({
queue: [track],
queueIndex: 0,
currentTrack: track,
});
syncQueueToServer([track], track, s.currentTime);
if (!wasPlaying) get().resume();
},
};
}
+3 -140
View File
@@ -1,9 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { showToast } from '../utils/toast';
import i18n from '../i18n';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, setRating } from '../api/subsonic';
import { buildStreamUrl, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, setRating } from '../api/subsonic';
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { lastfmUpdateNowPlaying, lastfmGetTrackLoved } from '../api/lastfm';
@@ -69,14 +67,10 @@ import {
clearSeekTarget,
setSeekTarget,
} from './seekTargetState';
import { reseedLoudnessForTrackId } from './loudnessReseed';
import { refreshWaveformForTrack } from './waveformRefresh';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import {
clearRadioReconnectTimer,
playRadioStream,
resumeRadio,
setRadioVolume,
stopRadio,
} from './radioPlayer';
import {
@@ -158,6 +152,7 @@ export {
import type { PlayerState, Track } from './playerStoreTypes';
export type { PlayerState, Track };
import { createLastfmActions } from './lastfmActions';
import { createMiscActions } from './miscActions';
import { createQueueMutationActions } from './queueMutationActions';
import { createTransportLightActions } from './transportLightActions';
import { createUiStateActions } from './uiStateActions';
@@ -215,48 +210,7 @@ export const usePlayerStore = create<PlayerState>()(
...createQueueMutationActions(set, get),
...createTransportLightActions(set, get),
...createUndoRedoActions(set, get),
// ── playRadio ────────────────────────────────────────────────────────────
playRadio: async (station) => {
const { volume } = get();
bumpPlayGeneration();
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
setIsAudioPaused(false);
clearRadioReconnectTimer();
clearPreloadingIds();
clearSeekFallbackRetry();
clearSeekDebounce(); clearSeekTarget();
// Stop Rust engine in case a regular track was playing.
invoke('audio_stop').catch(() => {});
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
// to HTML5 <audio> — the browser cannot play playlist files directly.
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
.catch(() => station.streamUrl);
const { replayGainFallbackDb } = useAuthStore.getState();
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
playRadioStream(streamUrl, Math.min(1, volume * fallbackFactor)).catch((err: unknown) => {
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
set({ isPlaying: false, currentRadio: null });
});
set({
currentRadio: station,
currentTrack: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null,
queue: [],
queueIndex: 0,
isPlaying: true,
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: true, // no scrobbling for radio
});
},
...createMiscActions(set, get),
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue, manual = true, _orbitConfirmed = false, targetQueueIndex) => {
@@ -538,23 +492,6 @@ export const usePlayerStore = create<PlayerState>()(
}
},
reseedQueueForInstantMix: (track) => {
const s = get();
if (s.currentTrack?.id !== track.id) {
get().playTrack(track, [track]);
return;
}
pushQueueUndoFromGetter(get);
const wasPlaying = s.isPlaying;
set({
queue: [track],
queueIndex: 0,
currentTrack: track,
});
syncQueueToServer([track], track, s.currentTime);
if (!wasPlaying) get().resume();
},
// ── resume ───────────────────────────────────────────────────────────────
resume: () => {
clearAllPlaybackScheduleTimers();
@@ -937,26 +874,6 @@ export const usePlayerStore = create<PlayerState>()(
}
},
previous: () => {
const { queue, queueIndex, currentTrack } = get();
const currentTime = getPlaybackProgressSnapshot().currentTime;
if (currentTime > 3) {
// Restart current track from the beginning.
const authState = useAuthStore.getState();
const sid = authState.activeServerId ?? '';
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() });
get().playTrack(currentTrack, queue, true);
return;
}
invoke('audio_seek', { seconds: 0 }).catch(console.error);
set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue, true, false, prevIdx);
},
// ── seek ─────────────────────────────────────────────────────────────────
// 100 ms debounce collapses rapid slider drags into one actual seek.
seek: (progress) => {
@@ -1024,60 +941,6 @@ export const usePlayerStore = create<PlayerState>()(
});
},
// ── volume ───────────────────────────────────────────────────────────────
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
setRadioVolume(clamped);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
// ── server queue restore ─────────────────────────────────────────────────
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map(songToTrack);
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
}
// Prefer the server position if available; otherwise keep the
// localStorage-persisted currentTime (more reliable than server
// queue position, which may not flush before app close).
const serverTime = q.position ? q.position / 1000 : 0;
const localTime = get().currentTime;
set({
queue: mappedTracks,
queueIndex,
currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime,
});
void refreshWaveformForTrack(currentTrack.id);
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
}
},
reanalyzeLoudnessForTrack: async (trackId: string) => {
try {
showToast(i18n.t('queue.recalculatingLoudnessWaveform'), 2000, 'info');
} catch {
// no-op
}
await reseedLoudnessForTrackId(trackId);
},
updateReplayGainForCurrentTrack: () => {
const { currentTrack, queue, queueIndex, volume } = get();
if (!currentTrack || !currentTrack.id) return;