From 3130a0ad74c8e7ae1cfeafd46d401e9115439f11 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 22:07:11 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.40=20=E2=80=94=20extract?= =?UTF-8?q?=20next=20action=20(#604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ~180-LOC `next` action body into `runNext(set, get, manual)` in `src/store/nextAction.ts`, following the helper-with-thin-wrapper pattern from E.37–E.39. Covers all three top-level outcomes: - Has next slot → playTrack + proactive infinite-queue / radio top-up when ≤ 2 of each remain ahead. Both top-ups skipped inside an Orbit session (host owns the queue). - Queue exhausted, repeat=all → wrap to index 0. - Queue exhausted, repeat=off → stop, unless radio-flagged or infinite queue is enabled (each fetches a fresh batch + continues), or an Orbit session is active (stop locally, let useOrbitGuest sync). Pure code-move. playerStore.ts: 702 → 515 LOC (−187). --- src/store/nextAction.ts | 222 +++++++++++++++++++++++++++++++++++++++ src/store/playerStore.ts | 197 +--------------------------------- 2 files changed, 227 insertions(+), 192 deletions(-) create mode 100644 src/store/nextAction.ts diff --git a/src/store/nextAction.ts b/src/store/nextAction.ts new file mode 100644 index 00000000..03430d76 --- /dev/null +++ b/src/store/nextAction.ts @@ -0,0 +1,222 @@ +import { invoke } from '@tauri-apps/api/core'; +import { getSimilarSongs2, getTopSongs } from '../api/subsonic'; +import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates'; +import { songToTrack } from '../utils/songToTrack'; +import { useAuthStore } from './authStore'; +import { setIsAudioPaused } from './engineState'; +import { + isInfiniteQueueFetching, + setInfiniteQueueFetching, +} from './infiniteQueueState'; +import { isInOrbitSession } from './orbitSession'; +import type { PlayerState, Track } from './playerStoreTypes'; +import { + addRadioSessionSeen, + getCurrentRadioArtistId, + hasRadioSessionSeen, + isRadioFetching, + setRadioFetching, +} from './radioSessionState'; +import { applySkipStarOnManualNext } from './skipStarRating'; + +type SetState = ( + partial: Partial | ((state: PlayerState) => Partial), +) => void; +type GetState = () => PlayerState; + +/** + * Advance to the next track. Three top-level outcomes: + * + * 1. **Has next slot** — `playTrack` the queue's `queueIndex + 1`, + * then proactively top up auto-added (infinite-queue) and + * radio-added tracks when ≤ 2 of each remain ahead. Both top-ups + * are skipped inside an Orbit session — the host owns the queue, + * and a silent local extension would drift the guest off the host + * or pop the bulk-add modal at the next track-end fallback. + * + * 2. **Queue exhausted, repeat=all** — wrap back to index 0. + * + * 3. **Queue exhausted, repeat=off** — stop, unless: + * - The current track is radio-flagged → fetch a fresh radio batch + * and continue. + * - Infinite queue is enabled → fetch more candidates and continue. + * - Orbit session active → stop locally and let `useOrbitGuest` + * sync to the host's next track. + */ +export function runNext(set: SetState, get: GetState, manual: boolean): void { + const { queue, queueIndex, repeatMode, currentTrack } = get(); + applySkipStarOnManualNext(currentTrack, manual); + const nextIdx = queueIndex + 1; + if (nextIdx < queue.length) { + get().playTrack(queue[nextIdx], queue, manual, false, nextIdx); + // Proactively top up auto-added tracks when ≤ 2 remain ahead, + // so the queue never runs dry without a visible loading pause. + // Skipped while in Orbit — the host's queue is the source of + // truth there, and any silent local extension would either + // drift this client off the host or pop the bulk-add modal at + // the next track-end fallback. + const { infiniteQueueEnabled } = useAuthStore.getState(); + if (infiniteQueueEnabled && repeatMode === 'off' && !isInfiniteQueueFetching() && !isInOrbitSession()) { + const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length; + if (remainingAuto <= 2) { + setInfiniteQueueFetching(true); + const existingIds = new Set(get().queue.map(t => t.id)); + buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => { + // Re-check at resolution time — the user may have joined + // an Orbit session between scheduling and resolving. + if (isInOrbitSession()) return; + if (newTracks.length > 0) { + set(state => ({ queue: [...state.queue, ...newTracks] })); + } + }).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); }); + } + } + // Proactively top up radio tracks when ≤ 2 remain — always, regardless + // of infinite queue setting. + const nextTrack = queue[nextIdx]; + if (nextTrack.radioAdded && !isRadioFetching()) { + const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length; + if (remainingRadio <= 2) { + const artistId = nextTrack.artistId ?? getCurrentRadioArtistId() ?? null; + const artistName = nextTrack.artist; + if (artistId) { + setRadioFetching(true); + Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]) + .then(([similar, top]) => { + const existingIds = new Set(get().queue.map(t => t.id)); + // Lead with similar (other artists) for variety; top tracks + // of the upcoming artist are only a fallback when similar + // is empty. Single-pass loop dedupes against the live queue, + // the session seen-set, and intra-batch overlap (issue #500). + const sourceList = similar.length > 0 ? similar : top; + const fresh: Track[] = []; + for (const raw of sourceList) { + if (fresh.length >= 10) break; + const t = songToTrack(raw); + if (existingIds.has(t.id) || hasRadioSessionSeen(t.id)) continue; + addRadioSessionSeen(t.id); + fresh.push({ ...t, radioAdded: true as const }); + } + if (fresh.length > 0) { + // Trim played tracks from the front to keep the queue bounded. + // Without trimming the queue grows unboundedly, making every + // Zustand persist write larger and causing UI freezes over time. + // Keep the last HISTORY_KEEP played tracks so the user can still + // navigate backwards a few songs. Trimmed ids stay in the seen-set. + const HISTORY_KEEP = 5; + set(state => { + const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP); + return { + queue: [...state.queue.slice(trimStart), ...fresh], + queueIndex: state.queueIndex - trimStart, + }; + }); + } + }) + .catch(() => {}) + .finally(() => { setRadioFetching(false); }); + } + } + } + } else if (repeatMode === 'all' && queue.length > 0) { + get().playTrack(queue[0], queue, manual, false, 0); + } else { + // ── Orbit short-circuit ── + // The host owns the shared queue. The radio / infinite-queue + // fallbacks below would either pop the orbitBulkGuard modal (with a + // 6-track add) or silently inject unrelated tracks into the local + // player and drift the guest off the host. Stop instead and let the + // next pull tick in `useOrbitGuest` sync to the host's next track. + // Covers any active orbit phase (`active` / `joining` / `starting`) + // so a fetch scheduled mid-join doesn't slip through. + if (isInOrbitSession()) { + invoke('audio_stop').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + return; + } + // Queue exhausted. Check radio first (independent of infinite queue setting), + // then infinite queue, then stop. + if (currentTrack?.radioAdded && !isRadioFetching()) { + const artistId = currentTrack.artistId ?? getCurrentRadioArtistId() ?? null; + if (artistId) { + setRadioFetching(true); + Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)]) + .then(([similar, top]) => { + setRadioFetching(false); + // The user may have joined an Orbit session while this + // fetch was in flight — bail without touching the queue. + if (isInOrbitSession()) { + invoke('audio_stop').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + return; + } + const existingIds = new Set(get().queue.map(t => t.id)); + // Same source preference + dedup contract as the proactive + // top-up: similar first, top only as a fallback (issue #500). + const sourceList = similar.length > 0 ? similar : top; + const fresh: Track[] = []; + for (const raw of sourceList) { + if (fresh.length >= 10) break; + const t = songToTrack(raw); + if (existingIds.has(t.id) || hasRadioSessionSeen(t.id)) continue; + addRadioSessionSeen(t.id); + fresh.push({ ...t, radioAdded: true as const }); + } + if (fresh.length > 0) { + const currentQueue = get().queue; + const newQueue = [...currentQueue, ...fresh]; + get().playTrack(fresh[0], newQueue, false, false, currentQueue.length); + } else { + invoke('audio_stop').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + } + }) + .catch(() => { + setRadioFetching(false); + invoke('audio_stop').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + }); + return; + } + } + const { infiniteQueueEnabled } = useAuthStore.getState(); + if (infiniteQueueEnabled && repeatMode === 'off') { + if (isInfiniteQueueFetching()) return; + setInfiniteQueueFetching(true); + const existingIds = new Set(get().queue.map(t => t.id)); + buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => { + setInfiniteQueueFetching(false); + // The user may have joined an Orbit session while this + // fetch was in flight — bail without invoking playTrack. + if (isInOrbitSession()) { + invoke('audio_stop').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + return; + } + if (newTracks.length === 0) { + invoke('audio_stop').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + return; + } + const currentQueue = get().queue; + const newQueue = [...currentQueue, ...newTracks]; + get().playTrack(newTracks[0], newQueue, false); + }).catch(() => { + setInfiniteQueueFetching(false); + invoke('audio_stop').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + }); + } else { + invoke('audio_stop').catch(console.error); + setIsAudioPaused(false); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); + } + } +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 58b9dbcb..2a7733db 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; -import { buildStreamUrl, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, setRating } from '../api/subsonic'; +import { buildStreamUrl, savePlayQueue, reportNowPlaying, getSong, setRating } from '../api/subsonic'; import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; import { lastfmUpdateNowPlaying, lastfmGetTrackLoved } from '../api/lastfm'; @@ -13,7 +13,6 @@ import { useOrbitStore } from './orbitStore'; import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; import { shuffleArray } from '../utils/shuffleArray'; import { songToTrack } from '../utils/songToTrack'; -import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates'; import { queuesStructuralEqual, sameQueueTrackId, @@ -40,7 +39,6 @@ import { clearAllPlaybackScheduleTimers, } from './scheduleTimers'; import { touchHotCacheOnPlayback } from './hotCacheTouch'; -import { applySkipStarOnManualNext } from './skipStarRating'; import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState'; import { flushPlayQueuePosition, @@ -72,17 +70,6 @@ import { getPlayGeneration, setIsAudioPaused, } from './engineState'; -import { - addRadioSessionSeen, - getCurrentRadioArtistId, - hasRadioSessionSeen, - isRadioFetching, - setRadioFetching, -} from './radioSessionState'; -import { - isInfiniteQueueFetching, - setInfiniteQueueFetching, -} from './infiniteQueueState'; import { initAudioListeners } from './initAudioListeners'; import { installQueueUndoHotkey } from './queueUndoHotkey'; import { readInitialQueueVisibility } from './queueVisibilityStorage'; @@ -132,6 +119,7 @@ import type { PlayerState, Track } from './playerStoreTypes'; export type { PlayerState, Track }; import { createLastfmActions } from './lastfmActions'; import { createMiscActions } from './miscActions'; +import { runNext } from './nextAction'; import { runResume } from './resumeAction'; import { runSeek } from './seekAction'; import { runUpdateReplayGainForCurrentTrack } from './updateReplayGainAction'; @@ -479,184 +467,9 @@ export const usePlayerStore = create()( // ── resume ─────────────────────────────────────────────────────────────── resume: () => runResume(set, get), - // ── next / previous ────────────────────────────────────────────────────── - next: (manual = true) => { - const { queue, queueIndex, repeatMode, currentTrack } = get(); - applySkipStarOnManualNext(currentTrack, manual); - const nextIdx = queueIndex + 1; - if (nextIdx < queue.length) { - get().playTrack(queue[nextIdx], queue, manual, false, nextIdx); - // Proactively top up auto-added tracks when ≤ 2 remain ahead, - // so the queue never runs dry without a visible loading pause. - // Skipped while in Orbit — the host's queue is the source of - // truth there, and any silent local extension would either - // drift this client off the host or pop the bulk-add modal at - // the next track-end fallback. - const { infiniteQueueEnabled } = useAuthStore.getState(); - if (infiniteQueueEnabled && repeatMode === 'off' && !isInfiniteQueueFetching() && !isInOrbitSession()) { - const remainingAuto = queue.slice(nextIdx + 1).filter(t => t.autoAdded).length; - if (remainingAuto <= 2) { - setInfiniteQueueFetching(true); - const existingIds = new Set(get().queue.map(t => t.id)); - buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => { - // Re-check at resolution time — the user may have joined - // an Orbit session between scheduling and resolving. - if (isInOrbitSession()) return; - if (newTracks.length > 0) { - set(state => ({ queue: [...state.queue, ...newTracks] })); - } - }).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); }); - } - } - // Proactively top up radio tracks when ≤ 2 remain — always, regardless - // of infinite queue setting. - const nextTrack = queue[nextIdx]; - if (nextTrack.radioAdded && !isRadioFetching()) { - const remainingRadio = queue.slice(nextIdx + 1).filter(t => t.radioAdded).length; - if (remainingRadio <= 2) { - const artistId = nextTrack.artistId ?? getCurrentRadioArtistId() ?? null; - const artistName = nextTrack.artist; - if (artistId) { - setRadioFetching(true); - Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]) - .then(([similar, top]) => { - const existingIds = new Set(get().queue.map(t => t.id)); - // Lead with similar (other artists) for variety; top tracks - // of the upcoming artist are only a fallback when similar - // is empty. Single-pass loop dedupes against the live queue, - // the session seen-set, and intra-batch overlap (issue #500). - const sourceList = similar.length > 0 ? similar : top; - const fresh: Track[] = []; - for (const raw of sourceList) { - if (fresh.length >= 10) break; - const t = songToTrack(raw); - if (existingIds.has(t.id) || hasRadioSessionSeen(t.id)) continue; - addRadioSessionSeen(t.id); - fresh.push({ ...t, radioAdded: true as const }); - } - if (fresh.length > 0) { - // Trim played tracks from the front to keep the queue bounded. - // Without trimming the queue grows unboundedly, making every - // Zustand persist write larger and causing UI freezes over time. - // Keep the last HISTORY_KEEP played tracks so the user can still - // navigate backwards a few songs. Trimmed ids stay in the seen-set. - const HISTORY_KEEP = 5; - set(state => { - const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP); - return { - queue: [...state.queue.slice(trimStart), ...fresh], - queueIndex: state.queueIndex - trimStart, - }; - }); - } - }) - .catch(() => {}) - .finally(() => { setRadioFetching(false); }); - } - } - } - } else if (repeatMode === 'all' && queue.length > 0) { - get().playTrack(queue[0], queue, manual, false, 0); - } else { - // ── Orbit short-circuit ── - // The host owns the shared queue. The radio / infinite-queue - // fallbacks below would either pop the orbitBulkGuard modal (with a - // 6-track add) or silently inject unrelated tracks into the local - // player and drift the guest off the host. Stop instead and let the - // next pull tick in `useOrbitGuest` sync to the host's next track. - // Covers any active orbit phase (`active` / `joining` / `starting`) - // so a fetch scheduled mid-join doesn't slip through. - if (isInOrbitSession()) { - invoke('audio_stop').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); - return; - } - // Queue exhausted. Check radio first (independent of infinite queue setting), - // then infinite queue, then stop. - if (currentTrack?.radioAdded && !isRadioFetching()) { - const artistId = currentTrack.artistId ?? getCurrentRadioArtistId() ?? null; - if (artistId) { - setRadioFetching(true); - Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)]) - .then(([similar, top]) => { - setRadioFetching(false); - // The user may have joined an Orbit session while this - // fetch was in flight — bail without touching the queue. - if (isInOrbitSession()) { - invoke('audio_stop').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); - return; - } - const existingIds = new Set(get().queue.map(t => t.id)); - // Same source preference + dedup contract as the proactive - // top-up: similar first, top only as a fallback (issue #500). - const sourceList = similar.length > 0 ? similar : top; - const fresh: Track[] = []; - for (const raw of sourceList) { - if (fresh.length >= 10) break; - const t = songToTrack(raw); - if (existingIds.has(t.id) || hasRadioSessionSeen(t.id)) continue; - addRadioSessionSeen(t.id); - fresh.push({ ...t, radioAdded: true as const }); - } - if (fresh.length > 0) { - const currentQueue = get().queue; - const newQueue = [...currentQueue, ...fresh]; - get().playTrack(fresh[0], newQueue, false, false, currentQueue.length); - } else { - invoke('audio_stop').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); - } - }) - .catch(() => { - setRadioFetching(false); - invoke('audio_stop').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); - }); - return; - } - } - const { infiniteQueueEnabled } = useAuthStore.getState(); - if (infiniteQueueEnabled && repeatMode === 'off') { - if (isInfiniteQueueFetching()) return; - setInfiniteQueueFetching(true); - const existingIds = new Set(get().queue.map(t => t.id)); - buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => { - setInfiniteQueueFetching(false); - // The user may have joined an Orbit session while this - // fetch was in flight — bail without invoking playTrack. - if (isInOrbitSession()) { - invoke('audio_stop').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); - return; - } - if (newTracks.length === 0) { - invoke('audio_stop').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); - return; - } - const currentQueue = get().queue; - const newQueue = [...currentQueue, ...newTracks]; - get().playTrack(newTracks[0], newQueue, false); - }).catch(() => { - setInfiniteQueueFetching(false); - invoke('audio_stop').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); - }); - } else { - invoke('audio_stop').catch(console.error); - setIsAudioPaused(false); - set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); - } - } - }, + // ── next ──────────────────────────────────────────────────────────────── + next: (manual = true) => runNext(set, get, manual), + // ── seek ───────────────────────────────────────────────────────────────── seek: (progress) => runSeek(set, get, progress),