mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
76fc3bb9c9
commit
1e2c651196
@@ -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
@@ -1,9 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { showToast } from '../utils/toast';
|
import { buildStreamUrl, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, setRating } from '../api/subsonic';
|
||||||
import i18n from '../i18n';
|
|
||||||
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, setRating } from '../api/subsonic';
|
|
||||||
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
import { lastfmUpdateNowPlaying, lastfmGetTrackLoved } from '../api/lastfm';
|
import { lastfmUpdateNowPlaying, lastfmGetTrackLoved } from '../api/lastfm';
|
||||||
@@ -69,14 +67,10 @@ import {
|
|||||||
clearSeekTarget,
|
clearSeekTarget,
|
||||||
setSeekTarget,
|
setSeekTarget,
|
||||||
} from './seekTargetState';
|
} from './seekTargetState';
|
||||||
import { reseedLoudnessForTrackId } from './loudnessReseed';
|
|
||||||
import { refreshWaveformForTrack } from './waveformRefresh';
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
||||||
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||||||
import {
|
import {
|
||||||
clearRadioReconnectTimer,
|
|
||||||
playRadioStream,
|
|
||||||
resumeRadio,
|
resumeRadio,
|
||||||
setRadioVolume,
|
|
||||||
stopRadio,
|
stopRadio,
|
||||||
} from './radioPlayer';
|
} from './radioPlayer';
|
||||||
import {
|
import {
|
||||||
@@ -158,6 +152,7 @@ export {
|
|||||||
import type { PlayerState, Track } from './playerStoreTypes';
|
import type { PlayerState, Track } from './playerStoreTypes';
|
||||||
export type { PlayerState, Track };
|
export type { PlayerState, Track };
|
||||||
import { createLastfmActions } from './lastfmActions';
|
import { createLastfmActions } from './lastfmActions';
|
||||||
|
import { createMiscActions } from './miscActions';
|
||||||
import { createQueueMutationActions } from './queueMutationActions';
|
import { createQueueMutationActions } from './queueMutationActions';
|
||||||
import { createTransportLightActions } from './transportLightActions';
|
import { createTransportLightActions } from './transportLightActions';
|
||||||
import { createUiStateActions } from './uiStateActions';
|
import { createUiStateActions } from './uiStateActions';
|
||||||
@@ -215,48 +210,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
...createQueueMutationActions(set, get),
|
...createQueueMutationActions(set, get),
|
||||||
...createTransportLightActions(set, get),
|
...createTransportLightActions(set, get),
|
||||||
...createUndoRedoActions(set, get),
|
...createUndoRedoActions(set, get),
|
||||||
|
...createMiscActions(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
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── playTrack ────────────────────────────────────────────────────────────
|
// ── playTrack ────────────────────────────────────────────────────────────
|
||||||
playTrack: (track, queue, manual = true, _orbitConfirmed = false, targetQueueIndex) => {
|
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 ───────────────────────────────────────────────────────────────
|
||||||
resume: () => {
|
resume: () => {
|
||||||
clearAllPlaybackScheduleTimers();
|
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 ─────────────────────────────────────────────────────────────────
|
// ── seek ─────────────────────────────────────────────────────────────────
|
||||||
// 100 ms debounce collapses rapid slider drags into one actual seek.
|
// 100 ms debounce collapses rapid slider drags into one actual seek.
|
||||||
seek: (progress) => {
|
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: () => {
|
updateReplayGainForCurrentTrack: () => {
|
||||||
const { currentTrack, queue, queueIndex, volume } = get();
|
const { currentTrack, queue, queueIndex, volume } = get();
|
||||||
if (!currentTrack || !currentTrack.id) return;
|
if (!currentTrack || !currentTrack.id) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user