diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ded711..4006eb2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -374,6 +374,13 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa ## Fixed +### Multi-server — Lucky Mix and Now Playing no longer revert the browsed server + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#768](https://github.com/Psychotoxical/psysonic/pull/768)** + +* **Lucky Mix** on a server you switched to while another server still owned the queue stopped mid-build and snapped the UI back to the playback server — Now Playing called `ensurePlaybackServerActive()` as soon as the route opened. Lucky Mix now clears the old queue and pins the active server before building when playback and browse servers differ. +* **Now Playing** and the queue info panel no longer force the browsed server to match the queue on every Connection Indicator switch; metadata loads via `apiForServer` against the playback server instead. Album/artist links still switch to the queue server when you navigate into the library. + ### Playback — M4A / MP4 streaming (moov-at-end) and seekbar during buffer **By [@cucadmuh](https://github.com/cucadmuh), PR [#737](https://github.com/Psychotoxical/psysonic/pull/737)** diff --git a/src/api/subsonicArtists.ts b/src/api/subsonicArtists.ts index 78dbb992..6be79b79 100644 --- a/src/api/subsonicArtists.ts +++ b/src/api/subsonicArtists.ts @@ -1,5 +1,6 @@ import { useAuthStore } from '../store/authStore'; -import { api, libraryFilterParams } from './subsonicClient'; +import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient'; +import { filterSongsToServerLibrary } from './subsonicLibrary'; import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary'; import type { SubsonicAlbum, @@ -29,20 +30,53 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a return { artist, albums: album ?? [] }; } +export async function getArtistForServer( + serverId: string, + id: string, +): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { + const data = await apiForServer<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(serverId, 'getArtist.view', { id }); + const { album, ...artist } = data.artist; + return { artist, albums: album ?? [] }; +} + export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise { const count = options?.similarArtistCount ?? 5; const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() }); return data.artistInfo2 ?? {}; } +export async function getArtistInfoForServer( + serverId: string, + id: string, + options?: { similarArtistCount?: number }, +): Promise { + const count = options?.similarArtistCount ?? 5; + const data = await apiForServer<{ artistInfo2: SubsonicArtistInfo }>( + serverId, + 'getArtistInfo2.view', + { id, count, ...libraryFilterParamsForServer(serverId) }, + ); + return data.artistInfo2 ?? {}; +} + export async function getTopSongs(artist: string): Promise { + const { activeServerId } = useAuthStore.getState(); + if (!activeServerId) return []; + return getTopSongsForServer(activeServerId, artist); +} + +export async function getTopSongsForServer(serverId: string, artist: string): Promise { try { - const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); - const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all'; + const { musicLibraryFilterByServer } = useAuthStore.getState(); + const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all'; const topCount = scoped ? 20 : 5; - const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() }); + const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>( + serverId, + 'getTopSongs.view', + { artist, count: topCount, ...libraryFilterParamsForServer(serverId) }, + ); const raw = data.topSongs?.song ?? []; - const filtered = await filterSongsToActiveLibrary(raw); + const filtered = await filterSongsToServerLibrary(raw, serverId); return filtered.slice(0, 5); } catch { return []; diff --git a/src/api/subsonicClient.ts b/src/api/subsonicClient.ts index 13e81547..69707ffd 100644 --- a/src/api/subsonicClient.ts +++ b/src/api/subsonicClient.ts @@ -83,9 +83,13 @@ export async function api(endpoint: string, extra: Record = /** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */ export function libraryFilterParams(): Record { - const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); - if (!activeServerId) return {}; - const f = musicLibraryFilterByServer[activeServerId]; + const { activeServerId } = useAuthStore.getState(); + return activeServerId ? libraryFilterParamsForServer(activeServerId) : {}; +} + +/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */ +export function libraryFilterParamsForServer(serverId: string): Record { + const f = useAuthStore.getState().musicLibraryFilterByServer[serverId]; if (f === undefined || f === 'all') return {}; return { musicFolderId: f }; } diff --git a/src/api/subsonicLibrary.ts b/src/api/subsonicLibrary.ts index e1f59fef..6f50b8e5 100644 --- a/src/api/subsonicLibrary.ts +++ b/src/api/subsonicLibrary.ts @@ -1,5 +1,5 @@ import { useAuthStore } from '../store/authStore'; -import { api, libraryFilterParams } from './subsonicClient'; +import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient'; import type { RandomSongsFilters, SubsonicAlbum, @@ -94,10 +94,10 @@ let scopedLibraryAlbumIdCache: { ids: Set; } | null = null; -async function albumIdsInActiveLibraryScope(): Promise | null> { - const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState(); - if (!activeServerId) return null; - const folder = musicLibraryFilterByServer[activeServerId]; +async function albumIdsInLibraryScope(serverId: string): Promise | null> { + const { musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState(); + if (!serverId) return null; + const folder = musicLibraryFilterByServer[serverId]; if (folder === undefined || folder === 'all') { scopedLibraryAlbumIdCache = null; return null; @@ -105,7 +105,7 @@ async function albumIdsInActiveLibraryScope(): Promise | null> { const hit = scopedLibraryAlbumIdCache; if ( hit && - hit.serverId === activeServerId && + hit.serverId === serverId && hit.folderId === folder && hit.filterVersion === musicLibraryFilterVersion ) { @@ -115,14 +115,14 @@ async function albumIdsInActiveLibraryScope(): Promise | null> { const pageSize = 500; let offset = 0; for (;;) { - const albums = await getAlbumList('alphabeticalByName', pageSize, offset); + const albums = await getAlbumListForServer(serverId, 'alphabeticalByName', pageSize, offset); for (const a of albums) ids.add(a.id); if (albums.length < pageSize) break; offset += pageSize; if (offset > 500_000) break; } scopedLibraryAlbumIdCache = { - serverId: activeServerId, + serverId, folderId: folder, filterVersion: musicLibraryFilterVersion, ids, @@ -130,12 +130,26 @@ async function albumIdsInActiveLibraryScope(): Promise | null> { return ids; } -export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise { - const allowed = await albumIdsInActiveLibraryScope(); +async function albumIdsInActiveLibraryScope(): Promise | null> { + const { activeServerId } = useAuthStore.getState(); + return activeServerId ? albumIdsInLibraryScope(activeServerId) : null; +} + +export async function filterSongsToServerLibrary( + songs: SubsonicSong[], + serverId: string, +): Promise { + const allowed = await albumIdsInLibraryScope(serverId); if (!allowed || allowed.size === 0) return songs; return songs.filter(s => s.albumId && allowed.has(s.albumId)); } +export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise { + const { activeServerId } = useAuthStore.getState(); + if (!activeServerId) return songs; + return filterSongsToServerLibrary(songs, activeServerId); +} + /** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */ export function similarSongsRequestCount(desired: number): number { const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); @@ -168,6 +182,24 @@ export async function getRandomSongsFiltered( return data.randomSongs?.song ?? []; } +export async function getAlbumListForServer( + serverId: string, + type: 'random' | 'newest' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'byYear' | 'recent' | 'starred' | 'frequent' | 'highest', + size = 30, + offset = 0, + extra: Record = {}, +): Promise { + const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', { + type, + size, + offset, + _t: Date.now(), + ...libraryFilterParamsForServer(serverId), + ...extra, + }); + return data.albumList2?.album ?? []; +} + export async function getSong(id: string): Promise { try { const data = await api<{ song: SubsonicSong }>('getSong.view', { id }); @@ -177,8 +209,26 @@ export async function getSong(id: string): Promise { } } +export async function getSongForServer(serverId: string, id: string): Promise { + try { + const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id }); + return data.song ?? null; + } catch { + return null; + } +} + export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id }); const { song, ...album } = data.album; return { album, songs: song ?? [] }; } + +export async function getAlbumForServer( + serverId: string, + id: string, +): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { + const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id }); + const { song, ...album } = data.album; + return { album, songs: song ?? [] }; +} diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index 5c1c386c..db176bc4 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -5,7 +5,6 @@ import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react'; import { useNavigate } from 'react-router-dom'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; -import { useEnsurePlaybackServerOnMount } from '../hooks/useEnsurePlaybackServerOnMount'; import { useTranslation } from 'react-i18next'; import { ChevronDown, Play, Pause, SkipBack, SkipForward, @@ -155,8 +154,6 @@ export default function MobilePlayerView() { const { t } = useTranslation(); const navigate = useNavigate(); const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); - useEnsurePlaybackServerOnMount(); - // Lock body scroll while full-screen player is mounted useEffect(() => { const prev = document.body.style.overflow; diff --git a/src/components/NowPlayingInfo.tsx b/src/components/NowPlayingInfo.tsx index 51b3b2de..b63bee41 100644 --- a/src/components/NowPlayingInfo.tsx +++ b/src/components/NowPlayingInfo.tsx @@ -1,5 +1,5 @@ -import { getSong } from '../api/subsonicLibrary'; -import { getArtistInfo } from '../api/subsonicArtists'; +import { getSongForServer } from '../api/subsonicLibrary'; +import { getArtistInfoForServer } from '../api/subsonicArtists'; import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -7,7 +7,7 @@ import { Info } from 'lucide-react'; import { open as shellOpen } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; -import { useEnsurePlaybackServerOnMount } from '../hooks/useEnsurePlaybackServerOnMount'; +import { usePlaybackServerId } from '../hooks/usePlaybackServerId'; import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown'; import CachedImage from './CachedImage'; import OverlayScrollArea from './OverlayScrollArea'; @@ -85,8 +85,8 @@ export default function NowPlayingInfo() { const currentTrack = usePlayerStore(s => s.currentTrack); const enableBandsintown = useAuthStore(s => s.enableBandsintown); const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown); - const subsonicReady = useEnsurePlaybackServerOnMount(); - const subsonicServerId = useAuthStore(s => s.activeServerId ?? ''); + const subsonicServerId = usePlaybackServerId(); + const subsonicReady = Boolean(subsonicServerId); const artistName = currentTrack?.artist || ''; const artistId = currentTrack?.artistId || ''; @@ -126,7 +126,7 @@ export default function NowPlayingInfo() { if (cached !== undefined) { setArtistInfoEntry({ id: artistId, info: cached }); return; } setArtistInfoEntry(null); let cancelled = false; - getArtistInfo(artistId) + getArtistInfoForServer(subsonicServerId, artistId) .then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfoEntry({ id: artistId, info: info ?? null }); } }) .catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, info: null }); } }); return () => { cancelled = true; }; @@ -140,7 +140,7 @@ export default function NowPlayingInfo() { if (cached !== undefined) { setSongDetailEntry({ id: songId, song: cached }); return; } setSongDetailEntry(null); let cancelled = false; - getSong(songId) + getSongForServer(subsonicServerId, songId) .then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetailEntry({ id: songId, song: song ?? null }); } }) .catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetailEntry({ id: songId, song: null }); } }); return () => { cancelled = true; }; diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 42327b8a..25b1f7d3 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -117,6 +117,7 @@ const CONTRIBUTOR_ENTRIES = [ 'M4A/MP4 streaming: moov-at-end tail prefetch and Symphonia isomp4 probe fix (PR #737)', 'HTTP stream buffering — seekbar/timer at zero and cover overlay until playback arms (PR #737)', 'M4A playback: fix AtomIterator overread in patched isomp4 demuxer — probe gate for ranged tail prefetch, zero-hole fallback detection (PR #757)', + 'Multi-server: Lucky Mix and Now Playing keep browsed server; queue metadata via apiForServer (PR #768)', ], }, { diff --git a/src/hooks/useEnsurePlaybackServerOnMount.ts b/src/hooks/useEnsurePlaybackServerOnMount.ts deleted file mode 100644 index 5c13e8f0..00000000 --- a/src/hooks/useEnsurePlaybackServerOnMount.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useAuthStore } from '../store/authStore'; -import { usePlayerStore } from '../store/playerStore'; -import { - ensurePlaybackServerActive, - playbackServerDiffersFromActive, -} from '../utils/playback/playbackServer'; - -/** - * On Now Playing surfaces, switch the browsed server to {@link queueServerId} - * before Subsonic fetches run. Returns false while a switch is in flight. - */ -export function useEnsurePlaybackServerOnMount(): boolean { - const queueServerId = usePlayerStore(s => s.queueServerId); - const queueLength = usePlayerStore(s => s.queue.length); - const activeServerId = useAuthStore(s => s.activeServerId); - const [ready, setReady] = useState(() => !playbackServerDiffersFromActive()); - - useEffect(() => { - if (!playbackServerDiffersFromActive()) { - setReady(true); - return; - } - let cancelled = false; - setReady(false); - void ensurePlaybackServerActive().then(ok => { - if (!cancelled) setReady(ok); - }); - return () => { cancelled = true; }; - }, [queueServerId, queueLength, activeServerId]); - - return ready; -} diff --git a/src/hooks/useNowPlayingFetchers.test.ts b/src/hooks/useNowPlayingFetchers.test.ts index e4f3b249..e184c042 100644 --- a/src/hooks/useNowPlayingFetchers.test.ts +++ b/src/hooks/useNowPlayingFetchers.test.ts @@ -18,8 +18,8 @@ vi.mock('../api/subsonicLibrary'); vi.mock('../api/bandsintown'); vi.mock('../api/lastfm'); -import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists'; -import { getAlbum, getSong } from '../api/subsonicLibrary'; +import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists'; +import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary'; import { fetchBandsintownEvents } from '../api/bandsintown'; import { lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured } from '../api/lastfm'; import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlayingFetchers'; @@ -28,8 +28,8 @@ import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlay // the hook treats `null` as the "no info available" case and stores it as // such in its tuple. The tests mirror that — cast to a nullable-returning // shape so we can mock the empty case without `as any` at every site. -const mockArtistInfo = vi.mocked(getArtistInfo) as unknown as { - mockImplementation: (impl: (id: string) => Promise) => void; +const mockArtistInfo = vi.mocked(getArtistInfoForServer) as unknown as { + mockImplementation: (impl: (serverId: string, id: string) => Promise) => void; mockResolvedValue: (v: SubsonicArtistInfo | null) => void; }; @@ -47,8 +47,8 @@ const baseDeps: NowPlayingFetchersDeps = { }; beforeEach(() => { - vi.mocked(getTopSongs).mockResolvedValue([]); - vi.mocked(getArtist).mockResolvedValue({ albums: [] } as any); + vi.mocked(getTopSongsForServer).mockResolvedValue([]); + vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as any); vi.mocked(fetchBandsintownEvents).mockResolvedValue([]); vi.mocked(lastfmIsConfigured).mockReturnValue(false); vi.mocked(lastfmGetTrackInfo).mockResolvedValue(null); @@ -71,7 +71,7 @@ describe('useNowPlayingFetchers — id-gated artistInfo', () => { it('returns null artistInfo while the previously-resolved info belongs to a different artistId', async () => { const a = deferred(); const b = deferred(); - mockArtistInfo.mockImplementation(async (id) => { + mockArtistInfo.mockImplementation(async (_sid, id) => { if (id === 'art-A') return a.promise; if (id === 'art-B') return b.promise; return null; @@ -108,7 +108,7 @@ describe('useNowPlayingFetchers — id-gated artistInfo', () => { it('does not leak a late-arriving resolve for a stale artistId', async () => { // Race: artist A's fetch resolves AFTER the consumer switched to B. const a = deferred(); - mockArtistInfo.mockImplementation(async (id) => { + mockArtistInfo.mockImplementation(async (_sid, id) => { if (id === 'art-A') return a.promise; if (id === 'art-B') return { largeImageUrl: 'B.jpg' } as SubsonicArtistInfo; return null; @@ -136,7 +136,7 @@ describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography' it('gates songMeta on songId match', async () => { const s1 = deferred(); const s2 = deferred(); - vi.mocked(getSong).mockImplementation(async (id) => { + vi.mocked(getSongForServer).mockImplementation(async (_sid, id) => { if (id === 's1') return s1.promise; if (id === 's2') return s2.promise; return null; @@ -161,7 +161,7 @@ describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography' it('gates albumData on albumId match', async () => { const al1 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>(); const al2 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>(); - vi.mocked(getAlbum).mockImplementation(async (id) => { + vi.mocked(getAlbumForServer).mockImplementation(async (_sid, id) => { if (id === 'alb1') return al1.promise as any; if (id === 'alb2') return al2.promise as any; return null as any; @@ -186,7 +186,7 @@ describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography' it('gates discography on artistId match (empty fallback while gated)', async () => { const d1 = deferred<{ artist: any; albums: SubsonicAlbum[] }>(); const d2 = deferred<{ artist: any; albums: SubsonicAlbum[] }>(); - vi.mocked(getArtist).mockImplementation(async (id) => { + vi.mocked(getArtistForServer).mockImplementation(async (_sid, id) => { if (id === 'art-D1') return d1.promise as any; if (id === 'art-D2') return d2.promise as any; return { albums: [] } as any; diff --git a/src/hooks/useNowPlayingFetchers.ts b/src/hooks/useNowPlayingFetchers.ts index d6f7f4e7..0451ad07 100644 --- a/src/hooks/useNowPlayingFetchers.ts +++ b/src/hooks/useNowPlayingFetchers.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists'; -import { getAlbum, getSong } from '../api/subsonicLibrary'; +import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists'; +import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary'; import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown'; import { @@ -30,7 +30,7 @@ export interface NowPlayingFetchersDeps { currentTrack: { artist: string; title: string } | null; /** Subsonic server for API calls — must match the playing queue server. */ subsonicServerId: string; - /** False while switching active server to the queue server. */ + /** When false, skip network fetches (e.g. no server id). */ fetchEnabled?: boolean; } @@ -96,7 +96,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF if (cached !== undefined) { setSongMetaEntry({ id: songId, value: cached }); return; } setSongMetaEntry(null); let cancelled = false; - getSong(songId) + getSongForServer(subsonicServerId, songId) .then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMetaEntry({ id: songId, value: v ?? null }); } }) .catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMetaEntry({ id: songId, value: null }); } }); return () => { cancelled = true; }; @@ -109,7 +109,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF if (cached !== undefined) { setArtistInfoEntry({ id: artistId, value: cached }); return; } setArtistInfoEntry(null); let cancelled = false; - getArtistInfo(artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined }) + getArtistInfoForServer(subsonicServerId, artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined }) .then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfoEntry({ id: artistId, value: v ?? null }); } }) .catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, value: null }); } }); return () => { cancelled = true; }; @@ -122,7 +122,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF if (cached !== undefined) { setAlbumDataEntry({ id: albumId, value: cached }); return; } setAlbumDataEntry(null); let cancelled = false; - getAlbum(albumId) + getAlbumForServer(subsonicServerId, albumId) .then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumDataEntry({ id: albumId, value: v }); } }) .catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumDataEntry({ id: albumId, value: null }); } }); return () => { cancelled = true; }; @@ -134,7 +134,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF const cached = topSongsCache.get(cacheKey); if (cached !== undefined) { setTopSongs(cached); return; } let cancelled = false; - getTopSongs(artistName) + getTopSongsForServer(subsonicServerId, artistName) .then(v => { if (!cancelled) { topSongsCache.set(cacheKey, v); setTopSongs(v); } }) .catch(() => { if (!cancelled) { topSongsCache.set(cacheKey, []); setTopSongs([]); } }); return () => { cancelled = true; }; @@ -160,7 +160,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF if (cached !== undefined) { setDiscographyEntry({ id: artistId, value: cached }); return; } setDiscographyEntry(null); let cancelled = false; - getArtist(artistId) + getArtistForServer(subsonicServerId, artistId) .then(v => { if (!cancelled) { discographyCache.set(cacheKey, v.albums); setDiscographyEntry({ id: artistId, value: v.albums }); } }) .catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscographyEntry({ id: artistId, value: [] }); } }); return () => { cancelled = true; }; diff --git a/src/hooks/usePlaybackServerId.test.ts b/src/hooks/usePlaybackServerId.test.ts new file mode 100644 index 00000000..73d45a16 --- /dev/null +++ b/src/hooks/usePlaybackServerId.test.ts @@ -0,0 +1,42 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import { usePlaybackServerId } from './usePlaybackServerId'; + +vi.mock('../utils/server/switchActiveServer', () => ({ + switchActiveServer: vi.fn(async () => true), +})); + +describe('usePlaybackServerId', () => { + beforeEach(() => { + useAuthStore.setState({ + servers: [ + { id: 'a', name: 'A', url: 'http://a.test', username: 'u', password: 'p' }, + { id: 'b', name: 'B', url: 'http://b.test', username: 'u', password: 'p' }, + ], + activeServerId: 'a', + isLoggedIn: true, + }); + usePlayerStore.setState({ + queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], + queueServerId: 'a', + queueIndex: 0, + }); + }); + + it('returns queue server while playback queue is non-empty', () => { + useAuthStore.setState({ activeServerId: 'b' }); + const { result } = renderHook(() => usePlaybackServerId()); + expect(result.current).toBe('a'); + }); + + it('does not call switchActiveServer when browsed server changes', async () => { + const { switchActiveServer } = await import('../utils/server/switchActiveServer'); + vi.mocked(switchActiveServer).mockClear(); + const { rerender } = renderHook(() => usePlaybackServerId()); + useAuthStore.setState({ activeServerId: 'b' }); + rerender(); + expect(switchActiveServer).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/usePlaybackServerId.ts b/src/hooks/usePlaybackServerId.ts new file mode 100644 index 00000000..ebf4fa19 --- /dev/null +++ b/src/hooks/usePlaybackServerId.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; + +/** + * Subsonic server that owns the current queue / stream (may differ from the browsed + * server). Use for Now Playing metadata without calling `ensurePlaybackServerActive`. + */ +export function usePlaybackServerId(): string { + const queueServerId = usePlayerStore(s => s.queueServerId); + const queueLength = usePlayerStore(s => s.queue.length); + const activeServerId = useAuthStore(s => s.activeServerId); + return useMemo( + () => getPlaybackServerId(), + [queueServerId, queueLength, activeServerId], + ); +} diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index c8a1c57e..b11db779 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -3,7 +3,7 @@ import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; -import { useEnsurePlaybackServerOnMount } from '../hooks/useEnsurePlaybackServerOnMount'; +import { usePlaybackServerId } from '../hooks/usePlaybackServerId'; import { useTranslation } from 'react-i18next'; import { Music, ExternalLink, Cast, Users, Radio, Clock, SkipForward, Info, Calendar, Disc3, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react'; import { open as shellOpen } from '@tauri-apps/plugin-shell'; @@ -46,8 +46,11 @@ import { useNowPlayingStarLove } from '../hooks/useNowPlayingStarLove'; export default function NowPlaying() { const { t } = useTranslation(); const stableNavigate = usePlaybackLibraryNavigate(); - const subsonicReady = useEnsurePlaybackServerOnMount(); - const activeServerId = useAuthStore(s => s.activeServerId ?? ''); + const playbackServerId = usePlaybackServerId(); + const audiomuseNavidromeByServer = useAuthStore(s => s.audiomuseNavidromeByServer); + const audiomuseNavidromeEnabled = Boolean( + playbackServerId && audiomuseNavidromeByServer[playbackServerId], + ); const currentTrack = usePlayerStore(s => s.currentTrack); const currentRadio = usePlayerStore(s => s.currentRadio); @@ -56,9 +59,6 @@ export default function NowPlaying() { const activeTab = useLyricsStore(s => s.activeTab); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const toggleQueue = usePlayerStore(s => s.toggleQueue); - const audiomuseNavidromeEnabled = useAuthStore( - s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]), - ); const enableBandsintown = useAuthStore(s => s.enableBandsintown); const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown); const lastfmUsername = useAuthStore(s => s.lastfmUsername); @@ -81,8 +81,8 @@ export default function NowPlaying() { songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, lastfmUsername, currentTrack, - subsonicServerId: activeServerId, - fetchEnabled: subsonicReady, + subsonicServerId: playbackServerId, + fetchEnabled: Boolean(playbackServerId), }); // Star + Last.fm love + their toggle callbacks diff --git a/src/utils/mix/luckyMix.ts b/src/utils/mix/luckyMix.ts index 996fd78c..daac7d0a 100644 --- a/src/utils/mix/luckyMix.ts +++ b/src/utils/mix/luckyMix.ts @@ -11,6 +11,10 @@ import { usePlayerStore } from '../../store/playerStore'; import { useLuckyMixStore } from '../../store/luckyMixStore'; import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable'; import { showToast } from '../ui/toast'; +import { + playbackServerDiffersFromActive, + prepareActiveServerForNewMix, +} from '../playback/playbackServer'; import { filterSongsForLuckyMixRatings, filterTopArtistsForMixRatings, @@ -74,6 +78,7 @@ export async function buildAndPlayLuckyMix(): Promise { showLuckyMixMenu: auth.showLuckyMixMenu, libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all', mixRatingFilter: mixRatingCfg, + crossServerPlayback: playbackServerDiffersFromActive(), }); if (!available) { logStep('abort_unavailable'); @@ -96,9 +101,16 @@ export async function buildAndPlayLuckyMix(): Promise { let unsubPlayer: (() => void) | null = null; try { - // Drop the old "upcoming" tail immediately so the queue UI does not show stale - // next tracks while the mix is still building (first playTrack may be delayed). - usePlayerStore.getState().pruneUpcomingToCurrent(true); + // Browsed server ≠ queue server: stop A's stream so Now Playing does not call + // ensurePlaybackServerActive() and revert the UI mid-build. + if (playbackServerDiffersFromActive()) { + prepareActiveServerForNewMix(); + logStep('cross_server_handoff', { activeServerId }); + } else { + // Drop the old "upcoming" tail so the queue UI does not show stale next + // tracks while the mix is still building (first playTrack may be delayed). + usePlayerStore.getState().pruneUpcomingToCurrent(true); + } lucky.start(); let startedPlayback = false; diff --git a/src/utils/playback/playbackServer.test.ts b/src/utils/playback/playbackServer.test.ts index eb25868e..e857d91b 100644 --- a/src/utils/playback/playbackServer.test.ts +++ b/src/utils/playback/playbackServer.test.ts @@ -8,8 +8,10 @@ import { getPlaybackServerId, playbackCoverArtForId, playbackServerDiffersFromActive, + prepareActiveServerForNewMix, shouldBindQueueServerForPlay, } from './playbackServer'; +import { invoke } from '@tauri-apps/api/core'; import { vi } from 'vitest'; vi.mock('../server/switchActiveServer', () => ({ @@ -58,6 +60,24 @@ describe('playbackServer', () => { expect(playbackServerDiffersFromActive()).toBe(false); }); + it('prepareActiveServerForNewMix clears queue and pins browsed server', async () => { + vi.mocked(invoke).mockResolvedValue(undefined); + useAuthStore.setState({ activeServerId: 'b' }); + prepareActiveServerForNewMix(); + const s = usePlayerStore.getState(); + expect(s.queue).toEqual([]); + expect(s.currentTrack).toBeNull(); + expect(s.queueServerId).toBe('b'); + expect(playbackServerDiffersFromActive()).toBe(false); + }); + + it('prepareActiveServerForNewMix is a no-op when queue already matches active', () => { + useAuthStore.setState({ activeServerId: 'a' }); + prepareActiveServerForNewMix(); + expect(usePlayerStore.getState().queue).toHaveLength(1); + expect(usePlayerStore.getState().queueServerId).toBe('a'); + }); + it('ensurePlaybackServerActive calls switch when servers differ', async () => { const { switchActiveServer } = await import('../server/switchActiveServer'); useAuthStore.setState({ activeServerId: 'b' }); diff --git a/src/utils/playback/playbackServer.ts b/src/utils/playback/playbackServer.ts index 22f37ef9..d6d6e988 100644 --- a/src/utils/playback/playbackServer.ts +++ b/src/utils/playback/playbackServer.ts @@ -34,6 +34,16 @@ export function playbackServerDiffersFromActive(): boolean { return !!activeSid && queueServerId !== activeSid; } +/** + * Stop playback owned by another server so a new mix on the browsed server + * can replace the queue (Lucky Mix / similar flows after ConnectionIndicator switch). + */ +export function prepareActiveServerForNewMix(): void { + if (!playbackServerDiffersFromActive()) return; + usePlayerStore.getState().clearQueue(); + bindQueueServerForPlayback(); +} + /** Switch the browsed server to the queue server when they differ (e.g. artist/album links). */ export async function ensurePlaybackServerActive(): Promise { if (!playbackServerDiffersFromActive()) return true;