fix(playback): cross-server browse, Lucky Mix, and Now Playing (#768)

* fix(playback): keep browsed server when queue plays elsewhere

Lucky Mix on a non-playback server now clears the old queue and pins
the active server before building. Now Playing metadata uses
apiForServer against the queue server instead of forcing
ensurePlaybackServerActive on every activeServerId change.

* docs: note PR #768 in CHANGELOG and settings credits

* fix(now-playing): gate AudioMuse similar artists on playback server

Match getArtistInfoForServer credentials: when browsing another server
while the queue plays elsewhere, similar-artist count follows the queue
server's AudioMuse flag, not the browsed server.
This commit is contained in:
cucadmuh
2026-05-18 11:40:46 +03:00
committed by GitHub
parent 6e0f076f43
commit db98d30a78
16 changed files with 253 additions and 91 deletions
+7
View File
@@ -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)**
+39 -5
View File
@@ -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<SubsonicArtistInfo> {
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<SubsonicArtistInfo> {
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<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return [];
return getTopSongsForServer(activeServerId, artist);
}
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
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 [];
+7 -3
View File
@@ -83,9 +83,13 @@ export async function api<T>(endpoint: string, extra: Record<string, unknown> =
/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */
export function libraryFilterParams(): Record<string, string | number> {
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<string, string | number> {
const f = useAuthStore.getState().musicLibraryFilterByServer[serverId];
if (f === undefined || f === 'all') return {};
return { musicFolderId: f };
}
+60 -10
View File
@@ -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<string>;
} | null = null;
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId];
async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | 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<Set<string> | 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<Set<string> | 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<Set<string> | null> {
return ids;
}
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
const allowed = await albumIdsInActiveLibraryScope();
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const { activeServerId } = useAuthStore.getState();
return activeServerId ? albumIdsInLibraryScope(activeServerId) : null;
}
export async function filterSongsToServerLibrary(
songs: SubsonicSong[],
serverId: string,
): Promise<SubsonicSong[]> {
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<SubsonicSong[]> {
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<string, unknown> = {},
): Promise<SubsonicAlbum[]> {
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<SubsonicSong | null> {
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
@@ -177,8 +209,26 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
}
}
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
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 ?? [] };
}
-3
View File
@@ -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;
+7 -7
View File
@@ -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; };
+1
View File
@@ -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)',
],
},
{
@@ -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;
}
+11 -11
View File
@@ -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<SubsonicArtistInfo | null>) => void;
const mockArtistInfo = vi.mocked(getArtistInfoForServer) as unknown as {
mockImplementation: (impl: (serverId: string, id: string) => Promise<SubsonicArtistInfo | null>) => 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<SubsonicArtistInfo | null>();
const b = deferred<SubsonicArtistInfo | null>();
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<SubsonicArtistInfo | null>();
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<SubsonicSong | null>();
const s2 = deferred<SubsonicSong | null>();
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;
+8 -8
View File
@@ -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; };
+42
View File
@@ -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();
});
});
+18
View File
@@ -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],
);
}
+8 -8
View File
@@ -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
+15 -3
View File
@@ -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<void> {
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<void> {
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;
+20
View File
@@ -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' });
+10
View File
@@ -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<boolean> {
if (!playbackServerDiffersFromActive()) return true;