diff --git a/CHANGELOG.md b/CHANGELOG.md index ed750233..14d59aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -488,6 +488,16 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * Album detail **header** shows **multiple album artists** when the server sends OpenSubsonic **`albumArtists`** on the album or on child songs — each name links to its artist page instead of only the first id (issue [#552](https://github.com/Psychotoxical/psysonic/issues/552)). * **Player bar**, **mobile now playing**, and **mini player** copy **`artists`** through **`songToTrack`** so multi-performer tracks get **per-artist** links like the album tracklist column. +### Multi-server — queue playback stays on the source server when browsing another library + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#717](https://github.com/Psychotoxical/psysonic/pull/717)** + +* With a non-empty queue from server **A**, switching the active server to **B** no longer breaks playback: streams, hot-cache prefetch, seek/resume, and MPRIS cover art use **`queueServerId`** (persisted with the queue). +* **Cover art** in the queue panel, player bar, mini/fullscreen/mobile players, and Now Playing loads from the queue server when it differs from the browsed server. +* **Artist/album links** from the player, queue, Now Playing, and mini player switch back to the queue server before navigating; **queue** and **player-bar album** context menus pin API actions to that server as well. +* Opening **Now Playing** (sidebar, mobile route, or queue info panel) switches to the queue server before Subsonic metadata loads, with per-server fetch caches so artist/album cards do not show the wrong library. +* **Scrobble**, **now-playing report**, and **savePlayQueue** sync use the queue server as well; removing that server profile clears `queueServerId`; the mini-player bridge receives `queueServerId`; enqueue/play-next from another browsed server is blocked with a toast. + ## [1.45.0] - 2026-05-04 ## Added diff --git a/src/api/subsonicClient.ts b/src/api/subsonicClient.ts index 7b000ced..13e81547 100644 --- a/src/api/subsonicClient.ts +++ b/src/api/subsonicClient.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import md5 from 'md5'; import { version } from '../../package.json'; import { useAuthStore } from '../store/authStore'; +import type { ServerProfile } from '../store/authStoreTypes'; export const SUBSONIC_CLIENT = `psysonic/${version}`; @@ -51,6 +52,22 @@ export function getClient() { return { baseUrl: `${baseUrl}/rest`, params }; } +export function getServerById(serverId: string): ServerProfile | undefined { + return useAuthStore.getState().servers.find(s => s.id === serverId); +} + +/** Subsonic REST call against an explicit saved server (not necessarily the active one). */ +export async function apiForServer( + serverId: string, + endpoint: string, + extra: Record = {}, + timeout = 15000, +): Promise { + const server = getServerById(serverId); + if (!server) throw new Error(`Unknown server: ${serverId}`); + return apiWithCredentials(server.url, server.username, server.password, endpoint, extra, timeout); +} + export async function api(endpoint: string, extra: Record = {}, timeout = 15000): Promise { const { baseUrl, params } = getClient(); const resp = await axios.get(`${baseUrl}/${endpoint}`, { diff --git a/src/api/subsonicPlayQueue.ts b/src/api/subsonicPlayQueue.ts index e241aa2e..7c9aaf9a 100644 --- a/src/api/subsonicPlayQueue.ts +++ b/src/api/subsonicPlayQueue.ts @@ -1,4 +1,4 @@ -import { api } from './subsonicClient'; +import { api, apiForServer } from './subsonicClient'; import type { SubsonicSong } from './subsonicTypes'; export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> { @@ -11,10 +11,16 @@ export async function getPlayQueue(): Promise<{ current?: string; position?: num } } -export async function savePlayQueue(songIds: string[], current?: string, position?: number): Promise { +export async function savePlayQueue( + songIds: string[], + current: string | undefined, + position: number | undefined, + serverId: string, +): Promise { + if (!serverId) return; const params: Record = {}; if (songIds.length > 0) params.id = songIds; if (current !== undefined) params.current = current; if (position !== undefined) params.position = position; - await api('savePlayQueue.view', params); + await apiForServer(serverId, 'savePlayQueue.view', params); } diff --git a/src/api/subsonicScrobble.test.ts b/src/api/subsonicScrobble.test.ts new file mode 100644 index 00000000..e28841e1 --- /dev/null +++ b/src/api/subsonicScrobble.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import { scrobbleSong } from './subsonicScrobble'; + +const { apiForServerMock } = vi.hoisted(() => ({ + apiForServerMock: vi.fn(async () => ({})), +})); + +vi.mock('./subsonicClient', () => ({ + api: vi.fn(), + apiForServer: apiForServerMock, +})); + +describe('subsonicScrobble', () => { + beforeEach(() => { + apiForServerMock.mockClear(); + 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: 'b', + isLoggedIn: true, + }); + usePlayerStore.setState({ + queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], + queueServerId: 'a', + queueIndex: 0, + }); + }); + + it('scrobbleSong targets the queue server when active server differs', async () => { + await scrobbleSong('t1', 1_700_000_000_000, 'a'); + expect(apiForServerMock).toHaveBeenCalledWith( + 'a', + 'scrobble.view', + expect.objectContaining({ id: 't1', submission: true, time: 1_700_000_000_000 }), + ); + }); +}); diff --git a/src/api/subsonicScrobble.ts b/src/api/subsonicScrobble.ts index a05ed49b..830bc2df 100644 --- a/src/api/subsonicScrobble.ts +++ b/src/api/subsonicScrobble.ts @@ -1,17 +1,30 @@ -import { api } from './subsonicClient'; +import { api, apiForServer } from './subsonicClient'; import type { SubsonicNowPlaying } from './subsonicTypes'; -export async function scrobbleSong(id: string, time: number): Promise { +async function scrobbleOnServer( + serverId: string, + id: string, + submission: boolean, + time?: number, +): Promise { + const params: Record = { id, submission }; + if (time !== undefined) params.time = time; + await apiForServer(serverId, 'scrobble.view', params); +} + +export async function scrobbleSong(id: string, time: number, serverId: string): Promise { + if (!serverId) return; try { - await api('scrobble.view', { id, time, submission: true }); + await scrobbleOnServer(serverId, id, true, time); } catch { // best effort } } -export async function reportNowPlaying(id: string): Promise { +export async function reportNowPlaying(id: string, serverId: string): Promise { + if (!serverId) return; try { - await api('scrobble.view', { id, submission: false }); + await scrobbleOnServer(serverId, id, false); } catch { // best effort } diff --git a/src/api/subsonicStreamUrl.ts b/src/api/subsonicStreamUrl.ts index c64c1bf7..9de87e05 100644 --- a/src/api/subsonicStreamUrl.ts +++ b/src/api/subsonicStreamUrl.ts @@ -17,18 +17,39 @@ function coverArtQueryParams(username: string, password: string, id: string, siz }); } +function streamUrlFromProfile( + serverUrl: string, + username: string, + password: string, + id: string, +): string { + const baseUrl = restBaseFromUrl(serverUrl); + const salt = secureRandomSalt(); + const token = md5(password + salt); + const p = new URLSearchParams({ + id, + u: username, + t: token, + s: salt, + v: '1.16.1', + c: SUBSONIC_CLIENT, + f: 'json', + }); + return `${baseUrl}/stream.view?${p.toString()}`; +} + +export function buildStreamUrlForServer(serverId: string, id: string): string { + const server = useAuthStore.getState().servers.find(s => s.id === serverId); + if (!server) return buildStreamUrl(id); + return streamUrlFromProfile(server.url, server.username, server.password, id); +} + export function buildStreamUrl(id: string): string { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); const baseUrl = getBaseUrl(); - const salt = secureRandomSalt(); - const token = md5((server?.password ?? '') + salt); - const p = new URLSearchParams({ - id, - u: server?.username ?? '', - t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json', - }); - return `${baseUrl}/rest/stream.view?${p.toString()}`; + if (!server || !baseUrl) return streamUrlFromProfile('', '', '', id); + return streamUrlFromProfile(server.url, server.username, server.password, id); } /** Stable cache key for cover art — does not include ephemeral auth params. */ diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 58d1fff0..1e8fd9c6 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -1,5 +1,6 @@ import React, { Suspense, useCallback, useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { ensurePlaybackServerActive } from '../utils/playback/playbackServer'; import { invoke } from '@tauri-apps/api/core'; import { PanelRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -97,7 +98,10 @@ export function AppShell() { useEffect(() => { const onPsyNavigate = (e: Event) => { const detail = (e as CustomEvent).detail; - if (detail?.to) navigate(detail.to); + if (!detail?.to) return; + void ensurePlaybackServerActive().then(ok => { + if (ok) navigate(detail.to); + }); }; window.addEventListener('psy:navigate', onPsyNavigate); return () => window.removeEventListener('psy:navigate', onPsyNavigate); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 9bec356e..98de99e7 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -15,6 +15,8 @@ import { } from '../utils/componentHelpers/contextMenuActions'; import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav'; import { useContextMenuRating } from '../hooks/useContextMenuRating'; +import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; +import { useNavigate } from 'react-router-dom'; import ContextMenuItems from './contextMenu/ContextMenuItems'; export { AddToPlaylistSubmenu }; @@ -22,6 +24,8 @@ export { AddToPlaylistSubmenu }; export default function ContextMenu() { const { t } = useTranslation(); + const navigate = useNavigate(); + const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); const orbitRole = useOrbitStore(s => s.role); const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( useShallow(s => ({ @@ -125,7 +129,18 @@ export default function ContextMenu() { }, [contextMenu.isOpen, closeContextMenu, cancelPlaylistSubmenuCloseTimer]); - const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride } = contextMenu; + const { + type, + item, + queueIndex, + playlistId, + playlistSongIndex, + shareKindOverride, + pinToPlaybackServer = false, + } = contextMenu; + const navigateLibrary = pinToPlaybackServer + ? navigatePlaybackLibrary + : (path: string) => { navigate(path); }; const isStarred = (id: string, itemStarred?: string) => id in starredOverrides ? starredOverrides[id] : !!itemStarred; @@ -216,6 +231,8 @@ export default function ContextMenu() { downloadAlbum={downloadAlbum} copyShareLink={copyShareLink} isStarred={isStarred} + pinToPlaybackServer={pinToPlaybackServer} + navigateLibrary={navigateLibrary} /> diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 9a353569..d7b4299b 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,5 +1,6 @@ import { star, unstar } from '../api/subsonicStarRating'; -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; +import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; +import { playbackCoverArtForId } from '../utils/playback/playbackServer'; import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import { SkipBack, SkipForward, @@ -56,12 +57,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const duration = currentTrack?.duration ?? 0; - // buildCoverArtUrl generates a new salt on every call — must be memoized. // 300px for the small art box; 500px for the right-side portrait fallback. - const artUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]); - const artKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]); - const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]); - const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]); + const artCover = usePlaybackCoverArt(currentTrack?.coverArt, 300); + const artUrl = artCover.src; + const artKey = artCover.cacheKey; + const portraitCover = usePlaybackCoverArt(currentTrack?.coverArt, 500); + const coverUrl = portraitCover.src; + const coverKey = portraitCover.cacheKey; // `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl). const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false); @@ -86,12 +88,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const idx = s.queueIndex; return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null; }); + const queueServerId = usePlayerStore(s => s.queueServerId); + const activeServerId = useAuthStore(s => s.activeServerId); useEffect(() => { if (!nextCoverArt) return; - const url = buildCoverArtUrl(nextCoverArt, 300); - const key = coverArtCacheKey(nextCoverArt, 300); + const { src: url, cacheKey: key } = playbackCoverArtForId(nextCoverArt, 300); getCachedBlob(url, key).catch(() => {}); - }, [nextCoverArt]); + }, [nextCoverArt, queueServerId, activeServerId]); // Lyrics settings popover state const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false); diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index e2fe1b9a..5d01a574 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -1,8 +1,9 @@ -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; +import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import { useEffect, useMemo, useRef, useState } from 'react'; import { emit } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { useTranslation } from 'react-i18next'; +import { usePlayerStore } from '../store/playerStore'; import { registerQueueDragHitTest } from '../contexts/DragDropContext'; import MiniContextMenu from './MiniContextMenu'; import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge'; @@ -63,6 +64,7 @@ export default function MiniPlayer() { useMiniSync({ onSync: (payload) => { setState(payload); + usePlayerStore.setState({ queueServerId: payload.queueServerId ?? null }); if (payload.track?.duration) setDuration(payload.track.duration); if (typeof payload.volume === 'number') setVolumeState(payload.volume); }, @@ -138,14 +140,7 @@ export default function MiniPlayer() { }, [queueOpen, state.queueIndex]); const { track, isPlaying } = state; - const miniCoverSrc = useMemo( - () => (track?.coverArt ? buildCoverArtUrl(track.coverArt, 300) : ''), - [track?.coverArt], - ); - const miniCoverKey = useMemo( - () => (track?.coverArt ? coverArtCacheKey(track.coverArt, 300) : ''), - [track?.coverArt], - ); + const { src: miniCoverSrc, cacheKey: miniCoverKey } = usePlaybackCoverArt(track?.coverArt, 300); const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; return ( diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index 241c8c85..5c1c386c 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -1,9 +1,11 @@ import { star, unstar } from '../api/subsonicStarRating'; -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; +import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { Track } from '../store/playerStoreTypes'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; 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, @@ -152,6 +154,8 @@ function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentT export default function MobilePlayerView() { const { t } = useTranslation(); const navigate = useNavigate(); + const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); + useEnsurePlaybackServerOnMount(); // Lock body scroll while full-screen player is mounted useEffect(() => { @@ -185,12 +189,7 @@ export default function MobilePlayerView() { const duration = currentTrack?.duration ?? 0; - // Cover art - const coverFetchUrl = useMemo( - () => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', - [currentTrack?.coverArt] - ); - const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; + const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(currentTrack?.coverArt, 800); const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); // Dynamic background color extracted from cover art @@ -332,7 +331,7 @@ export default function MobilePlayerView() { navigate(`/artist/${id}`)} + onGoArtist={id => { void navigatePlaybackLibrary(`/artist/${id}`); }} as="none" linkTag="span" linkClassName="mp-artist-link" @@ -341,12 +340,12 @@ export default function MobilePlayerView() { currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} + onClick={() => currentTrack.artistId && void navigatePlaybackLibrary(`/artist/${currentTrack.artistId}`)} onKeyDown={e => { if (!currentTrack.artistId) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - navigate(`/artist/${currentTrack.artistId}`); + void navigatePlaybackLibrary(`/artist/${currentTrack.artistId}`); } }} style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }} diff --git a/src/components/NowPlayingInfo.tsx b/src/components/NowPlayingInfo.tsx index 601e1ae4..3afba970 100644 --- a/src/components/NowPlayingInfo.tsx +++ b/src/components/NowPlayingInfo.tsx @@ -7,6 +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 { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown'; import CachedImage from './CachedImage'; import OverlayScrollArea from './OverlayScrollArea'; @@ -72,21 +73,31 @@ function buildContributorRows( return out; } +function queuePanelCacheKey(serverId: string, id: string): string { + return serverId ? `${serverId}:${id}` : id; +} + export default function NowPlayingInfo() { const { t } = useTranslation(); 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 artistName = currentTrack?.artist || ''; const artistId = currentTrack?.artistId || ''; const songId = currentTrack?.id || ''; const [artistInfo, setArtistInfo] = useState( - artistId ? artistInfoCache.get(artistId) ?? null : null, + artistId && subsonicServerId + ? artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId)) ?? null + : null, ); const [songDetail, setSongDetail] = useState( - songId ? songDetailCache.get(songId) ?? null : null, + songId && subsonicServerId + ? songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId)) ?? null + : null, ); const [tourEvents, setTourEvents] = useState([]); const [tourLoading, setTourLoading] = useState(false); @@ -101,27 +112,29 @@ export default function NowPlayingInfo() { // Artist bio + image useEffect(() => { - if (!artistId) { setArtistInfo(null); return; } - const cached = artistInfoCache.get(artistId); + if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfo(null); return; } + const cacheKey = queuePanelCacheKey(subsonicServerId, artistId); + const cached = artistInfoCache.get(cacheKey); if (cached !== undefined) { setArtistInfo(cached); return; } let cancelled = false; getArtistInfo(artistId) - .then(info => { if (!cancelled) { artistInfoCache.set(artistId, info ?? null); setArtistInfo(info ?? null); } }) - .catch(() => { if (!cancelled) { artistInfoCache.set(artistId, null); setArtistInfo(null); } }); + .then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfo(info ?? null); } }) + .catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfo(null); } }); return () => { cancelled = true; }; - }, [artistId]); + }, [subsonicReady, subsonicServerId, artistId]); // Song detail (for OpenSubsonic contributors[]) useEffect(() => { - if (!songId) { setSongDetail(null); return; } - const cached = songDetailCache.get(songId); + if (!subsonicReady || !subsonicServerId || !songId) { setSongDetail(null); return; } + const cacheKey = queuePanelCacheKey(subsonicServerId, songId); + const cached = songDetailCache.get(cacheKey); if (cached !== undefined) { setSongDetail(cached); return; } let cancelled = false; getSong(songId) - .then(song => { if (!cancelled) { songDetailCache.set(songId, song ?? null); setSongDetail(song ?? null); } }) - .catch(() => { if (!cancelled) { songDetailCache.set(songId, null); setSongDetail(null); } }); + .then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetail(song ?? null); } }) + .catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetail(null); } }); return () => { cancelled = true; }; - }, [songId]); + }, [subsonicReady, subsonicServerId, songId]); // Bandsintown — only when opt-in toggle is on useEffect(() => { diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index ab9a6999..24e83e16 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -1,5 +1,6 @@ import { star, unstar } from '../api/subsonicStarRating'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; +import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -17,7 +18,7 @@ import WaveformSeek from './WaveformSeek'; import Equalizer from './Equalizer'; import StarRating from './StarRating'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useLyricsStore } from '../store/lyricsStore'; import MarqueeText from './MarqueeText'; import LastfmIcon from './LastfmIcon'; @@ -40,7 +41,7 @@ import { useUtilityOverflowMenu } from '../hooks/useUtilityOverflowMenu'; export default function PlayerBar() { const { t } = useTranslation(); - const navigate = useNavigate(); + const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); const [eqOpen, setEqOpen] = useState(false); const [showVolPct, setShowVolPct] = useState(false); const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime); @@ -156,8 +157,14 @@ export default function PlayerBar() { ? currentTrack.artists : undefined; - const coverSrc = useMemo(() => displayCoverArt ? buildCoverArtUrl(displayCoverArt, 128) : '', [displayCoverArt]); - const coverKey = displayCoverArt ? coverArtCacheKey(displayCoverArt, 128) : ''; + const previewCover = useMemo(() => { + if (!showPreviewMeta || !previewingTrack?.coverArt) return { src: '', cacheKey: '' }; + const id = previewingTrack.coverArt; + return { src: buildCoverArtUrl(id, 128), cacheKey: coverArtCacheKey(id, 128) }; + }, [showPreviewMeta, previewingTrack?.coverArt]); + const queueCover = usePlaybackCoverArt(showPreviewMeta ? undefined : displayCoverArt, 128); + const coverSrc = showPreviewMeta ? previewCover.src : queueCover.src; + const coverKey = showPreviewMeta ? previewCover.cacheKey : queueCover.cacheKey; const handleVolume = useCallback((e: React.ChangeEvent) => { setVolume(parseFloat(e.target.value)); @@ -220,7 +227,7 @@ export default function PlayerBar() { userRatingOverrides={userRatingOverrides} setUserRatingOverride={setUserRatingOverride} toggleFullscreen={toggleFullscreen} - navigate={navigate} + navigate={navigatePlaybackLibrary} openContextMenu={openContextMenu} t={t} /> diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 790ec26b..327d07a8 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,5 +1,4 @@ import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { songToTrack } from '../utils/playback/songToTrack'; import type { Track } from '../store/playerStoreTypes'; import { useState, useRef, useMemo } from 'react'; @@ -11,7 +10,7 @@ import HostApprovalQueue from './HostApprovalQueue'; import { usePlaylistStore } from '../store/playlistStore'; import { useCachedUrl } from './CachedImage'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useAuthStore } from '../store/authStore'; import { encodeSharePayload } from '../utils/share/shareLink'; import { copyTextToClipboard } from '../utils/server/serverMagicString'; @@ -34,6 +33,7 @@ import { QueueToolbar } from './queuePanel/QueueToolbar'; import { QueueList } from './queuePanel/QueueList'; import { QueueTabBar } from './queuePanel/QueueTabBar'; import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll'; +import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; export default function QueuePanel() { const orbitRole = useOrbitStore(s => s.role); @@ -49,7 +49,7 @@ export default function QueuePanel() { function QueuePanelHostOrSolo() { const { t } = useTranslation(); - const navigate = useNavigate(); + const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); const orbitRole = useOrbitStore(s => s.role); const orbitState = useOrbitStore(s => s.state); /** trackId → addedBy (host username or guest username) — only populated while @@ -78,13 +78,9 @@ function QueuePanelHostOrSolo() { const queueIndex = usePlayerStore(s => s.queueIndex); const currentTrack = usePlayerStore(s => s.currentTrack); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); - const currentCoverFetchUrl = useMemo( - () => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', - [currentTrack?.coverArt] - ); - const currentCoverCacheKey = useMemo( - () => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '', - [currentTrack?.coverArt] + const { src: currentCoverFetchUrl, cacheKey: currentCoverCacheKey } = usePlaybackCoverArt( + currentTrack?.coverArt, + 128, ); const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); @@ -265,7 +261,7 @@ function QueuePanelHostOrSolo() { currentCoverSrc={currentCoverSrc} userRatingOverrides={userRatingOverrides} orbitAttributionLabel={orbitAttributionLabel} - navigate={navigate} + navigate={navigatePlaybackLibrary} playbackSource={playbackSource} normalizationEngine={normalizationEngine} normalizationEngineLive={normalizationEngineLive} diff --git a/src/components/contextMenu/AlbumContextItems.tsx b/src/components/contextMenu/AlbumContextItems.tsx index 541011bb..605fc636 100644 --- a/src/components/contextMenu/AlbumContextItems.tsx +++ b/src/components/contextMenu/AlbumContextItems.tsx @@ -22,10 +22,12 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + pinToPlaybackServer, navigateLibrary, } = props; const { t } = useTranslation(); const auth = useAuthStore(); const navigate = useNavigate(); + const goLibrary = pinToPlaybackServer ? navigateLibrary : (path: string) => { navigate(path); }; return ( <> @@ -34,7 +36,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { const albumRatingDisabled = entityRatingSupport === 'track_only'; return ( <> -
handleAction(() => navigate(`/album/${album.id}`))}> +
handleAction(() => goLibrary(`/album/${album.id}`))}> {t('contextMenu.openAlbum')}
handleAction(async () => { @@ -52,7 +54,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { {t('contextMenu.enqueueAlbum')}
-
handleAction(() => navigate(`/artist/${album.artistId}`))}> +
handleAction(() => goLibrary(`/artist/${album.artistId}`))}> {t('contextMenu.goToArtist')}
handleAction(() => { diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx index ec2817aa..4706fed5 100644 --- a/src/components/contextMenu/QueueItemContextItems.tsx +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -1,6 +1,5 @@ import { useTranslation } from 'react-i18next'; import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; import { star, unstar } from '../../api/subsonicStarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; import type { Track } from '../../store/playerStoreTypes'; @@ -21,10 +20,10 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) { orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + navigateLibrary, } = props; const { t } = useTranslation(); const auth = useAuthStore(); - const navigate = useNavigate(); return ( <> @@ -54,12 +53,12 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
{song.albumId && ( -
handleAction(() => navigate(`/album/${song.albumId}`))}> +
handleAction(() => navigateLibrary(`/album/${song.albumId}`))}> {t('contextMenu.openAlbum')}
)} {song.artistId && ( -
handleAction(() => navigate(`/artist/${song.artistId}`))}> +
handleAction(() => navigateLibrary(`/artist/${song.artistId}`))}> {t('contextMenu.goToArtist')}
)} diff --git a/src/components/contextMenu/contextMenuItemTypes.ts b/src/components/contextMenu/contextMenuItemTypes.ts index 16fa52a8..6c229772 100644 --- a/src/components/contextMenu/contextMenuItemTypes.ts +++ b/src/components/contextMenu/contextMenuItemTypes.ts @@ -51,4 +51,7 @@ export interface ContextMenuItemsProps { downloadAlbum: (albumName: string, albumId: string) => Promise; copyShareLink: (kind: EntityShareKind, id: string) => void; isStarred: (id: string, itemStarred?: string) => boolean; + /** When true, album/artist links switch to the queue server before routing. */ + pinToPlaybackServer: boolean; + navigateLibrary: (path: string) => void | Promise; } diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx index de2deee8..72f0739b 100644 --- a/src/components/playerBar/PlayerTrackInfo.tsx +++ b/src/components/playerBar/PlayerTrackInfo.tsx @@ -35,7 +35,7 @@ interface Props { userRatingOverrides: Record; setUserRatingOverride: (id: string, r: number) => void; toggleFullscreen: () => void; - navigate: (to: string) => void; + navigate: (to: string) => void | Promise; openContextMenu: PlayerState['openContextMenu']; t: TFunction; } @@ -115,7 +115,7 @@ export function PlayerTrackInfo({ songCount: 0, duration: 0, }; - openContextMenu(e.clientX, e.clientY, album, 'album'); + openContextMenu(e.clientX, e.clientY, album, 'album', undefined, undefined, undefined, undefined, true); } : undefined} /> diff --git a/src/components/queuePanel/QueueCurrentTrack.tsx b/src/components/queuePanel/QueueCurrentTrack.tsx index 15824876..d6552a5b 100644 --- a/src/components/queuePanel/QueueCurrentTrack.tsx +++ b/src/components/queuePanel/QueueCurrentTrack.tsx @@ -17,7 +17,7 @@ interface Props { currentCoverSrc: string; userRatingOverrides: Record; orbitAttributionLabel: (trackId: string) => string | null; - navigate: (to: string) => void; + navigate: (to: string) => void | Promise; playbackSource: PlaybackSourceKind | null; normalizationEngine: NormalizationEngine; normalizationEngineLive: 'off' | 'replaygain' | 'loudness'; diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index fc4bca77..d0c4c56d 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -112,6 +112,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Internet Radio: portal add/edit station modal to document.body — fixes clipped modal on empty library (report: voidboywannabe on Psysonic Discord) (PR #699)', 'Now Playing: composite list keys on similar artists, album-card tracklist, and top songs — avoids duplicate React keys when Subsonic repeats ids (PR #703)', 'Search: share links in live/mobile search + queue preview modal (PR #716)', + 'Multi-server: pin queue streams, cover art, links, context menu, and Now Playing to queue server (PR #717)', ], }, { diff --git a/src/hooks/useEnsurePlaybackServerOnMount.ts b/src/hooks/useEnsurePlaybackServerOnMount.ts new file mode 100644 index 00000000..5c13e8f0 --- /dev/null +++ b/src/hooks/useEnsurePlaybackServerOnMount.ts @@ -0,0 +1,33 @@ +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.ts b/src/hooks/useNowPlayingFetchers.ts index 148dc457..b362f038 100644 --- a/src/hooks/useNowPlayingFetchers.ts +++ b/src/hooks/useNowPlayingFetchers.ts @@ -28,6 +28,10 @@ export interface NowPlayingFetchersDeps { audiomuseNavidromeEnabled: boolean; lastfmUsername: string; 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. */ + fetchEnabled?: boolean; } export interface NowPlayingFetchersResult { @@ -42,64 +46,80 @@ export interface NowPlayingFetchersResult { lfmArtist: LastfmArtistStats | null; } +function subsonicCacheKey(serverId: string, id: string): string { + return serverId ? `${serverId}:${id}` : id; +} + export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult { - const { songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, lastfmUsername, currentTrack } = deps; + const { + songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, + lastfmUsername, currentTrack, subsonicServerId, fetchEnabled = true, + } = deps; // Entity state, seeded from TTL cache so same-artist song switches are instant - const [songMeta, setSongMeta] = useState(() => songId ? songMetaCache.get(songId) ?? null : null); - const [artistInfo, setArtistInfo] = useState(() => artistId ? artistInfoCache.get(artistId) ?? null : null); - const [albumData, setAlbumData] = useState<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>(() => albumId ? albumCache.get(albumId) ?? null : null); - const [topSongs, setTopSongs] = useState(() => artistName ? topSongsCache.get(artistName) ?? [] : []); + const [songMeta, setSongMeta] = useState(() => + songId && subsonicServerId ? songMetaCache.get(subsonicCacheKey(subsonicServerId, songId)) ?? null : null); + const [artistInfo, setArtistInfo] = useState(() => + artistId && subsonicServerId ? artistInfoCache.get(subsonicCacheKey(subsonicServerId, artistId)) ?? null : null); + const [albumData, setAlbumData] = useState<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>(() => + albumId && subsonicServerId ? albumCache.get(subsonicCacheKey(subsonicServerId, albumId)) ?? null : null); + const [topSongs, setTopSongs] = useState(() => + artistName && subsonicServerId ? topSongsCache.get(subsonicCacheKey(subsonicServerId, artistName)) ?? [] : []); const [tourEvents, setTourEvents] = useState(() => artistName ? tourCache.get(artistName) ?? [] : []); const [tourLoading, setTourLoading] = useState(false); - const [discography, setDiscography] = useState(() => artistId ? discographyCache.get(artistId) ?? [] : []); + const [discography, setDiscography] = useState(() => + artistId && subsonicServerId ? discographyCache.get(subsonicCacheKey(subsonicServerId, artistId)) ?? [] : []); const [lfmTrack, setLfmTrack] = useState(null); const [lfmArtist, setLfmArtist] = useState(null); // Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches) useEffect(() => { - if (!songId) { setSongMeta(null); return; } - const cached = songMetaCache.get(songId); + if (!fetchEnabled || !subsonicServerId || !songId) { setSongMeta(null); return; } + const cacheKey = subsonicCacheKey(subsonicServerId, songId); + const cached = songMetaCache.get(cacheKey); if (cached !== undefined) { setSongMeta(cached); return; } let cancelled = false; getSong(songId) - .then(v => { if (!cancelled) { songMetaCache.set(songId, v ?? null); setSongMeta(v ?? null); } }) - .catch(() => { if (!cancelled) { songMetaCache.set(songId, null); setSongMeta(null); } }); + .then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMeta(v ?? null); } }) + .catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMeta(null); } }); return () => { cancelled = true; }; - }, [songId]); + }, [fetchEnabled, subsonicServerId, songId]); useEffect(() => { - if (!artistId) { setArtistInfo(null); return; } - const cached = artistInfoCache.get(artistId); + if (!fetchEnabled || !subsonicServerId || !artistId) { setArtistInfo(null); return; } + const cacheKey = subsonicCacheKey(subsonicServerId, artistId); + const cached = artistInfoCache.get(cacheKey); if (cached !== undefined) { setArtistInfo(cached); return; } let cancelled = false; getArtistInfo(artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined }) - .then(v => { if (!cancelled) { artistInfoCache.set(artistId, v ?? null); setArtistInfo(v ?? null); } }) - .catch(() => { if (!cancelled) { artistInfoCache.set(artistId, null); setArtistInfo(null); } }); + .then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfo(v ?? null); } }) + .catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfo(null); } }); return () => { cancelled = true; }; - }, [artistId, audiomuseNavidromeEnabled]); + }, [fetchEnabled, subsonicServerId, artistId, audiomuseNavidromeEnabled]); useEffect(() => { - if (!albumId) { setAlbumData(null); return; } - const cached = albumCache.get(albumId); + if (!fetchEnabled || !subsonicServerId || !albumId) { setAlbumData(null); return; } + const cacheKey = subsonicCacheKey(subsonicServerId, albumId); + const cached = albumCache.get(cacheKey); if (cached !== undefined) { setAlbumData(cached); return; } let cancelled = false; getAlbum(albumId) - .then(v => { if (!cancelled) { albumCache.set(albumId, v); setAlbumData(v); } }) - .catch(() => { if (!cancelled) { albumCache.set(albumId, null); setAlbumData(null); } }); + .then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumData(v); } }) + .catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumData(null); } }); return () => { cancelled = true; }; - }, [albumId]); + }, [fetchEnabled, subsonicServerId, albumId]); useEffect(() => { - if (!artistName) { setTopSongs([]); return; } - const cached = topSongsCache.get(artistName); + if (!fetchEnabled || !subsonicServerId || !artistName) { setTopSongs([]); return; } + const cacheKey = subsonicCacheKey(subsonicServerId, artistName); + const cached = topSongsCache.get(cacheKey); if (cached !== undefined) { setTopSongs(cached); return; } let cancelled = false; getTopSongs(artistName) - .then(v => { if (!cancelled) { topSongsCache.set(artistName, v); setTopSongs(v); } }) - .catch(() => { if (!cancelled) { topSongsCache.set(artistName, []); setTopSongs([]); } }); + .then(v => { if (!cancelled) { topSongsCache.set(cacheKey, v); setTopSongs(v); } }) + .catch(() => { if (!cancelled) { topSongsCache.set(cacheKey, []); setTopSongs([]); } }); return () => { cancelled = true; }; - }, [artistName]); + }, [fetchEnabled, subsonicServerId, artistName]); useEffect(() => { if (!enableBandsintown || !artistName) { setTourEvents([]); return; } @@ -115,15 +135,16 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF // Discography via getArtist useEffect(() => { - if (!artistId) { setDiscography([]); return; } - const cached = discographyCache.get(artistId); + if (!fetchEnabled || !subsonicServerId || !artistId) { setDiscography([]); return; } + const cacheKey = subsonicCacheKey(subsonicServerId, artistId); + const cached = discographyCache.get(cacheKey); if (cached !== undefined) { setDiscography(cached); return; } let cancelled = false; getArtist(artistId) - .then(v => { if (!cancelled) { discographyCache.set(artistId, v.albums); setDiscography(v.albums); } }) - .catch(() => { if (!cancelled) { discographyCache.set(artistId, []); setDiscography([]); } }); + .then(v => { if (!cancelled) { discographyCache.set(cacheKey, v.albums); setDiscography(v.albums); } }) + .catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscography([]); } }); return () => { cancelled = true; }; - }, [artistId]); + }, [fetchEnabled, subsonicServerId, artistId]); // Last.fm track info (per-track) const lfmTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}` : ''; diff --git a/src/hooks/usePlaybackCoverArt.ts b/src/hooks/usePlaybackCoverArt.ts new file mode 100644 index 00000000..f82b5a8c --- /dev/null +++ b/src/hooks/usePlaybackCoverArt.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import { playbackCoverArtForId } from '../utils/playback/playbackServer'; + +/** Cover art for the playing queue — uses {@link queueServerId} when it differs from the browsed server. */ +export function usePlaybackCoverArt(coverId: string | undefined, size: number) { + const queueServerId = usePlayerStore(s => s.queueServerId); + const queueLength = usePlayerStore(s => s.queue.length); + const activeServerId = useAuthStore(s => s.activeServerId); + const servers = useAuthStore(s => s.servers); + + return useMemo(() => { + if (!coverId) return { src: '', cacheKey: '' }; + return playbackCoverArtForId(coverId, size); + }, [coverId, size, queueServerId, queueLength, activeServerId, servers]); +} diff --git a/src/hooks/usePlaybackLibraryNavigate.ts b/src/hooks/usePlaybackLibraryNavigate.ts new file mode 100644 index 00000000..a09c9b88 --- /dev/null +++ b/src/hooks/usePlaybackLibraryNavigate.ts @@ -0,0 +1,12 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ensurePlaybackServerActive } from '../utils/playback/playbackServer'; + +/** Navigate to library routes for the playing queue — switches to {@link queueServerId} when needed. */ +export function usePlaybackLibraryNavigate() { + const navigate = useNavigate(); + return useCallback(async (path: string) => { + await ensurePlaybackServerActive(); + navigate(path); + }, [navigate]); +} diff --git a/src/hotCachePrefetch.ts b/src/hotCachePrefetch.ts index b5e27951..2cc2bb8c 100644 --- a/src/hotCachePrefetch.ts +++ b/src/hotCachePrefetch.ts @@ -1,4 +1,5 @@ -import { buildStreamUrl } from './api/subsonicStreamUrl'; +import { buildStreamUrlForServer } from './api/subsonicStreamUrl'; +import { getPlaybackServerId } from './utils/playback/playbackServer'; import type { Track } from './store/playerStoreTypes'; import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from './store/authStore'; @@ -73,7 +74,8 @@ async function runWorker() { try { while (pendingQueue.length > 0) { const auth = useAuthStore.getState(); - if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) { + const playbackSid = getPlaybackServerId(); + if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) { hotCacheFrontendDebug({ event: 'prefetch-worker-stop', reason: 'auth-disabled-or-logged-out', @@ -153,7 +155,7 @@ async function runWorker() { continue; } - const url = buildStreamUrl(job.trackId); + const url = buildStreamUrlForServer(job.serverId, job.trackId); try { const customDir = auth.hotCacheDownloadDir || null; hotCacheFrontendDebug({ event: 'prefetch-invoke', trackId: job.trackId }); @@ -173,7 +175,7 @@ async function runWorker() { fresh.queue, fresh.queueIndex, maxAfter, - authAfter.activeServerId ?? '', + getPlaybackServerId(), authAfter.hotCacheDownloadDir || null, ); } catch (e: unknown) { @@ -188,7 +190,8 @@ async function runWorker() { function scheduleReplan() { const auth = useAuthStore.getState(); - if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) { + const playbackSid = getPlaybackServerId(); + if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) { if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; @@ -206,9 +209,10 @@ function scheduleReplan() { async function replanNow() { const auth = useAuthStore.getState(); - if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) return; + const playbackSid = getPlaybackServerId(); + if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) return; - const serverId = auth.activeServerId; + const serverId = playbackSid; const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024; const customDir = auth.hotCacheDownloadDir || null; if (maxBytes <= 0) return; @@ -286,8 +290,9 @@ export function initHotCachePrefetch(): () => void { if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) { const left = (prevQ as Track[])[prevIdx]; const a = useAuthStore.getState(); - if (left && a.activeServerId) { - bumpHotCachePreviousTrackGrace(left.id, a.activeServerId, a.hotCacheDebounceSec); + const graceSid = getPlaybackServerId(); + if (left && graceSid) { + bumpHotCachePreviousTrackGrace(left.id, graceSid, a.hotCacheDebounceSec); scheduleEvictAfterPreviousGrace(); } } diff --git a/src/locales/de/queue.ts b/src/locales/de/queue.ts index 7a121b56..28d6834c 100644 --- a/src/locales/de/queue.ts +++ b/src/locales/de/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: 'Warteschlangen-Link kopieren', shareQueueEmpty: 'Die Warteschlange ist leer — nichts zu teilen.', emptyQueue: 'Die Warteschlange ist leer.', + crossServerEnqueueBlocked: 'Titel von einem anderen Server können der aktuellen Warteschlange nicht hinzugefügt werden. Beende oder leere die Warteschlange zuerst.', trackSingular: 'Titel', trackPlural: 'Titel', showRemaining: 'Restzeit anzeigen', diff --git a/src/locales/en/queue.ts b/src/locales/en/queue.ts index 7a4506a1..05cf57b8 100644 --- a/src/locales/en/queue.ts +++ b/src/locales/en/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: 'Copy queue share link', shareQueueEmpty: 'The queue is empty — nothing to share.', emptyQueue: 'The queue is empty.', + crossServerEnqueueBlocked: 'Tracks from another server cannot be added to the current queue. Finish or clear the queue first.', trackSingular: 'track', trackPlural: 'tracks', showRemaining: 'Show remaining time', diff --git a/src/locales/es/queue.ts b/src/locales/es/queue.ts index 89a3a578..453517ac 100644 --- a/src/locales/es/queue.ts +++ b/src/locales/es/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: 'Copiar enlace de la cola', shareQueueEmpty: 'La cola está vacía — no hay nada que compartir.', emptyQueue: 'La cola está vacía.', + crossServerEnqueueBlocked: 'No se pueden añadir pistas de otro servidor a la cola actual. Termina o vacía la cola primero.', trackSingular: 'pista', trackPlural: 'pistas', showRemaining: 'Mostrar tiempo restante', diff --git a/src/locales/fr/queue.ts b/src/locales/fr/queue.ts index be36e752..8440ca3b 100644 --- a/src/locales/fr/queue.ts +++ b/src/locales/fr/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: 'Copier le lien de la file d’attente', shareQueueEmpty: 'La file d’attente est vide — rien à partager.', emptyQueue: 'La file d\'attente est vide.', + crossServerEnqueueBlocked: 'Les titres d\'un autre serveur ne peuvent pas être ajoutés à la file en cours. Terminez ou videz la file d\'abord.', trackSingular: 'piste', trackPlural: 'pistes', showRemaining: 'Afficher le temps restant', diff --git a/src/locales/nb/queue.ts b/src/locales/nb/queue.ts index 35a3edd7..b4398d79 100644 --- a/src/locales/nb/queue.ts +++ b/src/locales/nb/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: 'Kopiér lenke til kø', shareQueueEmpty: 'Køen er tom — ingenting å dele.', emptyQueue: 'Køen er tom.', + crossServerEnqueueBlocked: 'Spor fra en annen server kan ikke legges til i den nåværende køen. Tøm eller avslutt køen først.', trackSingular: 'spor', trackPlural: 'spor', showRemaining: 'Vis gjenværende tid', diff --git a/src/locales/nl/queue.ts b/src/locales/nl/queue.ts index c2377534..d7592024 100644 --- a/src/locales/nl/queue.ts +++ b/src/locales/nl/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: 'Wachtrij-deellink kopiëren', shareQueueEmpty: 'De wachtrij is leeg — niets om te delen.', emptyQueue: 'De wachtrij is leeg.', + crossServerEnqueueBlocked: 'Tracks van een andere server kunnen niet aan de huidige wachtrij worden toegevoegd. Maak de wachtrij eerst leeg of stop afspelen.', trackSingular: 'nummer', trackPlural: 'nummers', showRemaining: 'Resterende tijd tonen', diff --git a/src/locales/ro/queue.ts b/src/locales/ro/queue.ts index 88ef57fb..7f773e52 100644 --- a/src/locales/ro/queue.ts +++ b/src/locales/ro/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: 'Copiază link-ul distribuirii cozii', shareQueueEmpty: 'Coada este goală — nimic de distribuit.', emptyQueue: 'Coada este goală.', + crossServerEnqueueBlocked: 'Piesele de pe alt server nu pot fi adăugate în coada curentă. Golește sau oprește coada mai întâi.', trackSingular: 'piesă', trackPlural: 'piese', showRemaining: 'Arată timpul rămas', diff --git a/src/locales/ru/queue.ts b/src/locales/ru/queue.ts index 2caf22ce..0744b91c 100644 --- a/src/locales/ru/queue.ts +++ b/src/locales/ru/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: 'Копировать ссылку на очередь', shareQueueEmpty: 'Очередь пуста — нечем поделиться.', emptyQueue: 'Очередь пуста.', + crossServerEnqueueBlocked: 'Треки с другого сервера нельзя добавить в текущую очередь. Завершите или очистите очередь.', trackSingular: 'трек', trackPlural: 'треков', showRemaining: 'Осталось', diff --git a/src/locales/zh/queue.ts b/src/locales/zh/queue.ts index ad71d98b..ac48e914 100644 --- a/src/locales/zh/queue.ts +++ b/src/locales/zh/queue.ts @@ -28,6 +28,7 @@ export const queue = { shareQueue: '复制队列分享链接', shareQueueEmpty: '队列为空,无法分享。', emptyQueue: '队列为空。', + crossServerEnqueueBlocked: '无法将其他服务器的曲目加入当前播放队列。请先清空或结束当前队列。', trackSingular: '首曲目', trackPlural: '首曲目', showRemaining: '显示剩余时间', diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index feeb5110..c8a1c57e 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -1,7 +1,9 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; +import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; +import { useEnsurePlaybackServerOnMount } from '../hooks/useEnsurePlaybackServerOnMount'; 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'; @@ -43,8 +45,9 @@ import { useNowPlayingStarLove } from '../hooks/useNowPlayingStarLove'; export default function NowPlaying() { const { t } = useTranslation(); - const navigate = useNavigate(); - const stableNavigate = useCallback((path: string) => navigate(path), [navigate]); + const stableNavigate = usePlaybackLibraryNavigate(); + const subsonicReady = useEnsurePlaybackServerOnMount(); + const activeServerId = useAuthStore(s => s.activeServerId ?? ''); const currentTrack = usePlayerStore(s => s.currentTrack); const currentRadio = usePlayerStore(s => s.currentRadio); @@ -78,6 +81,8 @@ export default function NowPlaying() { songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled, lastfmUsername, currentTrack, + subsonicServerId: activeServerId, + fetchEnabled: subsonicReady, }); // Star + Last.fm love + their toggle callbacks @@ -91,10 +96,8 @@ export default function NowPlaying() { showLyrics(); }, [isQueueVisible, toggleQueue, showLyrics]); - // Cover - const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; - const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; - const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); + const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(currentTrack?.coverArt, 800); + const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : ''; const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : ''; diff --git a/src/store/applyQueueHistorySnapshot.ts b/src/store/applyQueueHistorySnapshot.ts index a14fe222..3eeb8c65 100644 --- a/src/store/applyQueueHistorySnapshot.ts +++ b/src/store/applyQueueHistorySnapshot.ts @@ -1,5 +1,6 @@ import { reportNowPlaying } from '../api/subsonicScrobble'; import { invoke } from '@tauri-apps/api/core'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { getPlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl'; import { useAuthStore } from './authStore'; import { @@ -129,9 +130,9 @@ export function applyQueueHistorySnapshot( PlayerState, 'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive' >); - const authSnap = useAuthStore.getState(); + const playbackSid = getPlaybackServerId(); const playbackSourceUndo = nextTrack - ? getPlaybackSourceKind(nextTrack.id, authSnap.activeServerId ?? '', null) + ? getPlaybackSourceKind(nextTrack.id, playbackSid, null) : null; const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null ? prior.currentPlaybackSource @@ -186,7 +187,7 @@ export function applyQueueHistorySnapshot( if (!keepPlaybackFromPrior) { const { nowPlayingEnabled: npUndo } = useAuthStore.getState(); - if (npUndo) reportNowPlaying(nextTrack.id); + if (npUndo) reportNowPlaying(nextTrack.id, getPlaybackServerId()); queueUndoRestoreAudioEngine({ generation: gen, diff --git a/src/store/audioEventHandlers.ts b/src/store/audioEventHandlers.ts index 91353de7..dcbd52d7 100644 --- a/src/store/audioEventHandlers.ts +++ b/src/store/audioEventHandlers.ts @@ -5,6 +5,7 @@ import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../ import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; import { getPerfProbeFlags } from '../utils/perf/perfFlags'; import { bumpPerfCounter } from '../utils/perf/perfTelemetry'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; import { showToast } from '../utils/ui/toast'; @@ -160,7 +161,7 @@ export function handleAudioProgress(current_time: number, duration: number): voi // Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played) if (progress >= 0.5 && !store.scrobbled) { usePlayerStore.setState({ scrobbled: true }); - scrobbleSong(track.id, Date.now()); + scrobbleSong(track.id, Date.now(), getPlaybackServerId()); const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); if (scrobblingEnabled && lastfmSessionKey) { lastfmScrobble(track, Date.now(), lastfmSessionKey); @@ -236,7 +237,7 @@ export function handleAudioProgress(current_time: number, duration: number): voi const shouldBytePreloadForGaplessBackup = gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0; - const serverId = useAuthStore.getState().activeServerId ?? ''; + const serverId = getPlaybackServerId(); const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId); // Byte pre-download — runs early so bytes are cached by chain time. @@ -322,7 +323,7 @@ export function handleAudioEnded(): void { void (async () => { if (repeatMode === 'one' && currentTrack) { const authState = useAuthStore.getState(); - const repeatPromoteSid = authState.activeServerId; + const repeatPromoteSid = getPlaybackServerId(); if (authState.hotCacheEnabled && repeatPromoteSid) { // Same-track repeat never hit `playTrack`'s prev→promote path; flush // Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local. @@ -373,7 +374,7 @@ export function handleAudioTrackSwitched(_duration: number): void { if (!nextTrack) return; - const switchServerId = useAuthStore.getState().activeServerId ?? ''; + const switchServerId = getPlaybackServerId(); const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId); const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl); @@ -403,7 +404,7 @@ export function handleAudioTrackSwitched(_duration: number): void { // Report Now Playing to Navidrome + Last.fm const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); - if (nowPlayingEnabled) reportNowPlaying(nextTrack.id); + if (nowPlayingEnabled) reportNowPlaying(nextTrack.id, getPlaybackServerId()); if (lastfmSessionKey) { if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey); lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => { @@ -415,7 +416,7 @@ export function handleAudioTrackSwitched(_duration: number): void { }); } syncQueueToServer(queue, nextTrack, 0); - touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? ''); + touchHotCacheOnPlayback(nextTrack.id, getPlaybackServerId()); } export function handleAudioError(message: string): void { diff --git a/src/store/audioListenerSetup/mprisSync.ts b/src/store/audioListenerSetup/mprisSync.ts index dd1b7a8a..11086a70 100644 --- a/src/store/audioListenerSetup/mprisSync.ts +++ b/src/store/audioListenerSetup/mprisSync.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import { buildCoverArtUrl } from '../../api/subsonicStreamUrl'; +import { playbackCoverArtForId } from '../../utils/playback/playbackServer'; import { usePlayerStore } from '../playerStore'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../playbackProgress'; @@ -22,7 +22,7 @@ export function setupMprisSync(): () => void { prevTrackId = currentTrack.id; prevRadioId = null; const coverUrl = currentTrack.coverArt - ? buildCoverArtUrl(currentTrack.coverArt, 512) + ? playbackCoverArtForId(currentTrack.coverArt, 512).src : undefined; invoke('mpris_set_metadata', { title: currentTrack.title, diff --git a/src/store/authServerProfileActions.ts b/src/store/authServerProfileActions.ts index 353b0bd9..9f4467ca 100644 --- a/src/store/authServerProfileActions.ts +++ b/src/store/authServerProfileActions.ts @@ -1,5 +1,7 @@ import type { AuthState } from './authStoreTypes'; import { generateId } from './authStoreHelpers'; +import { usePlayerStore } from './playerStore'; +import { clearQueueServerForPlayback } from '../utils/playback/playbackServer'; type SetState = ( partial: Partial | ((state: AuthState) => Partial), @@ -38,6 +40,9 @@ export function createServerProfileActions(set: SetState): Pick< }, removeServer: (id) => { + if (usePlayerStore.getState().queueServerId === id) { + clearQueueServerForPlayback(); + } set(s => { const newServers = s.servers.filter(srv => srv.id !== id); const switchedAway = s.activeServerId === id; diff --git a/src/store/authStore.servers.test.ts b/src/store/authStore.servers.test.ts index 889d4499..2a592c05 100644 --- a/src/store/authStore.servers.test.ts +++ b/src/store/authStore.servers.test.ts @@ -12,7 +12,9 @@ */ import { beforeEach, describe, expect, it } from 'vitest'; import { useAuthStore } from './authStore'; +import { usePlayerStore } from './playerStore'; import { resetAuthStore } from '@/test/helpers/storeReset'; +import { resetPlayerStore } from '@/test/helpers/storeReset'; import { makeServer } from '@/test/helpers/factories'; function addThree(): { a: string; b: string; c: string } { @@ -24,6 +26,7 @@ function addThree(): { a: string; b: string; c: string } { beforeEach(() => { resetAuthStore(); + resetPlayerStore(); }); describe('addServer / updateServer', () => { @@ -122,6 +125,18 @@ describe('removeServer', () => { useAuthStore.getState().removeServer(a); expect(useAuthStore.getState().activeServerId).toBe(b); }); + + it('clears queueServerId when the removed server owned the playback queue', () => { + const { a, b } = addThree(); + useAuthStore.getState().setActiveServer(b); + usePlayerStore.setState({ + queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }], + queueServerId: a, + queueIndex: 0, + }); + useAuthStore.getState().removeServer(a); + expect(usePlayerStore.getState().queueServerId).toBeNull(); + }); }); describe('selectors — getBaseUrl / getActiveServer', () => { diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index 6385b306..c61b9488 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -4,6 +4,11 @@ import { lastfmGetTrackLoved, lastfmUpdateNowPlaying } from '../api/lastfm'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; import { orbitBulkGuard } from '../utils/orbitBulkGuard'; import { sameQueueTrackId } from '../utils/playback/queueIdentity'; +import { + bindQueueServerForPlayback, + getPlaybackServerId, + shouldBindQueueServerForPlay, +} from '../utils/playback/playbackServer'; import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; import { useAuthStore } from './authStore'; @@ -173,6 +178,9 @@ export function runPlayTrack( setSeekFallbackVisualTarget(null); } const newQueue = queue ?? state.queue; + if (shouldBindQueueServerForPlay(state.queue, newQueue, queue)) { + bindQueueServerForPlayback(); + } // Prefer an explicit target index from the caller (next/previous/queue-row // click already know the exact slot). `findIndex` returns the *first* // matching id, which jumps backwards when the queue contains the same @@ -207,18 +215,19 @@ export function runPlayTrack( prevTrack && sameQueueTrackId(prevTrack.id, track.id) && authState.hotCacheEnabled - && authState.activeServerId, + && getPlaybackServerId(), ); const runPlayTrackBody = () => { const authStateNow = useAuthStore.getState(); - const url = resolvePlaybackUrl(track.id, authStateNow.activeServerId ?? ''); + const playbackSid = getPlaybackServerId(); + const url = resolvePlaybackUrl(track.id, playbackSid); recordEnginePlayUrl(track.id, url); const preloadedTrackId = get().enginePreloadedTrackId; const keepPreloadHint = preloadedTrackId === track.id; const playbackSourceHint = playbackSourceHintForResolvedUrl( track.id, - authStateNow.activeServerId ?? '', + playbackSid, url, ); if (import.meta.env.DEV) { @@ -255,7 +264,7 @@ export function runPlayTrack( && !sameQueueTrackId(prevTrack.id, track.id) && authStateNow.hotCacheEnabled ) { - const prevPromoteSid = authStateNow.activeServerId; + const prevPromoteSid = getPlaybackServerId(); if (prevPromoteSid) { void promoteCompletedStreamToHotCache( prevTrack, @@ -326,7 +335,7 @@ export function runPlayTrack( // Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState(); - if (npEnabled) reportNowPlaying(track.id); + if (npEnabled) reportNowPlaying(track.id, getPlaybackServerId()); if (lfmKey) { if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey); lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => { @@ -338,10 +347,10 @@ export function runPlayTrack( }); } syncQueueToServer(newQueue, track, initialTime); - touchHotCacheOnPlayback(track.id, authStateNow.activeServerId ?? ''); + touchHotCacheOnPlayback(track.id, playbackSid); }; - const hotPromoteSid = authState.activeServerId; + const hotPromoteSid = getPlaybackServerId(); if (needSameTrackHotPromote && hotPromoteSid) { void promoteCompletedStreamToHotCache( track, diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts index 9cb0b36b..6e65a077 100644 --- a/src/store/playerStore.persistence.test.ts +++ b/src/store/playerStore.persistence.test.ts @@ -44,6 +44,16 @@ vi.mock('@/api/subsonicScrobble', () => ({ reportNowPlaying: vi.fn(async () => undefined), scrobbleSong: vi.fn(async () => undefined), })); +vi.mock('@/utils/playback/playbackServer', () => ({ + getPlaybackServerId: () => 'srv-test', + bindQueueServerForPlayback: vi.fn(), + clearQueueServerForPlayback: vi.fn(), + playbackServerDiffersFromActive: () => false, + playbackCoverArtForId: (id: string, size: number) => ({ + src: `https://mock/cover/${id}?size=${size}`, + cacheKey: `mock:cover:${id}:${size}`, + }), +})); vi.mock('@/api/subsonicStarRating', () => ({ setRating: vi.fn(async () => undefined), probeEntityRatingSupport: vi.fn(async () => 'track_only'), @@ -116,6 +126,7 @@ describe('flushPlayQueuePosition', () => { [t1.id, t2.id, t3.id], t2.id, 12345, // Math.floor(12.345 * 1000) + 'srv-test', ); }); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 560f9052..ece2963f 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -36,6 +36,7 @@ export const usePlayerStore = create()( currentPlaybackSource: null, enginePreloadedTrackId: null, queue: [], + queueServerId: null, queueIndex: 0, isPlaying: false, progress: 0, @@ -81,6 +82,7 @@ export const usePlayerStore = create()( repeatMode: state.repeatMode, currentTrack: state.currentTrack, queue: state.queue, + queueServerId: state.queueServerId, queueIndex: state.queueIndex, isQueueVisible: state.isQueueVisible, // currentTime is intentionally NOT persisted here. diff --git a/src/store/playerStoreTypes.ts b/src/store/playerStoreTypes.ts index bef77c83..5498bd58 100644 --- a/src/store/playerStoreTypes.ts +++ b/src/store/playerStoreTypes.ts @@ -55,6 +55,8 @@ export interface PlayerState { */ enginePreloadedTrackId: string | null; queue: Track[]; + /** Saved server for stream/hot-cache/offline resolution while this queue plays. */ + queueServerId: string | null; queueIndex: number; isPlaying: boolean; progress: number; // 0–1 @@ -171,8 +173,20 @@ export interface PlayerState { * list/grid to copy a `composer` link from the otherwise artist-typed * context menu, so paste lands on /composer/:id instead of /artist/:id. */ shareKindOverride?: 'track' | 'album' | 'artist' | 'composer'; + /** Menu actions target {@link queueServerId} (set for queue-item and player-sourced album menus). */ + pinToPlaybackServer?: boolean; }; - openContextMenu: (x: number, y: number, item: any, type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number, shareKindOverride?: 'track' | 'album' | 'artist' | 'composer') => void; + openContextMenu: ( + x: number, + y: number, + item: any, + type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', + queueIndex?: number, + playlistId?: string, + playlistSongIndex?: number, + shareKindOverride?: 'track' | 'album' | 'artist' | 'composer', + pinToPlaybackServer?: boolean, + ) => void; closeContextMenu: () => void; songInfoModal: { isOpen: boolean; songId: string | null }; diff --git a/src/store/promoteStreamCache.test.ts b/src/store/promoteStreamCache.test.ts index ab043459..b38efe6b 100644 --- a/src/store/promoteStreamCache.test.ts +++ b/src/store/promoteStreamCache.test.ts @@ -13,7 +13,10 @@ const { invokeMock, setEntryMock, buildStreamUrlMock } = vi.hoisted(() => ({ })); vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })); -vi.mock('../api/subsonicStreamUrl', () => ({ buildStreamUrl: buildStreamUrlMock })); +vi.mock('../api/subsonicStreamUrl', () => ({ + buildStreamUrl: buildStreamUrlMock, + buildStreamUrlForServer: (_serverId: string, id: string) => buildStreamUrlMock(id), +})); vi.mock('./hotCacheStore', () => ({ useHotCacheStore: { getState: () => ({ setEntry: setEntryMock }) }, })); diff --git a/src/store/promoteStreamCache.ts b/src/store/promoteStreamCache.ts index f6d389be..7eaf5289 100644 --- a/src/store/promoteStreamCache.ts +++ b/src/store/promoteStreamCache.ts @@ -1,4 +1,4 @@ -import { buildStreamUrl } from '../api/subsonicStreamUrl'; +import { buildStreamUrlForServer } from '../api/subsonicStreamUrl'; import type { Track } from './playerStoreTypes'; import { invoke } from '@tauri-apps/api/core'; import { useHotCacheStore } from './hotCacheStore'; @@ -25,7 +25,7 @@ export async function promoteCompletedStreamToHotCache( { trackId: track.id, serverId, - url: buildStreamUrl(track.id), + url: buildStreamUrlForServer(serverId, track.id), suffix: track.suffix || 'mp3', customDir, }, diff --git a/src/store/queueMutationActions.ts b/src/store/queueMutationActions.ts index 4c16d25c..b3447b2e 100644 --- a/src/store/queueMutationActions.ts +++ b/src/store/queueMutationActions.ts @@ -17,12 +17,21 @@ import { import { clearSeekDebounce } from './seekDebounce'; import { clearSeekFallbackRetry } from './seekFallbackState'; import { clearSeekTarget } from './seekTargetState'; +import i18n from '../i18n'; +import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer'; +import { showToast } from '../utils/ui/toast'; type SetState = ( partial: Partial | ((state: PlayerState) => Partial), ) => void; type GetState = () => PlayerState; +function blockCrossServerEnqueue(): boolean { + if (!playbackServerDiffersFromActive()) return false; + showToast(i18n.t('queue.crossServerEnqueueBlocked'), 4500, 'error'); + return true; +} + /** * Eleven queue-mutation actions. Shared invariant: every action except * `setRadioArtistId` pushes a queue-undo snapshot and calls @@ -44,6 +53,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< > { return { enqueue: (tracks, _orbitConfirmed = false) => { + if (blockCrossServerEnqueue()) return; if (!_orbitConfirmed && tracks.length > 1) { void orbitBulkGuard(tracks.length).then(ok => { if (ok) get().enqueue(tracks, true); @@ -129,6 +139,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< }, enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => { + if (blockCrossServerEnqueue()) return; if (!_orbitConfirmed && tracks.length > 1) { void orbitBulkGuard(tracks.length).then(ok => { if (ok) get().enqueueAt(tracks, insertIndex, true); @@ -154,6 +165,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< playNext: (tracks) => { if (tracks.length === 0) return; + if (blockCrossServerEnqueue()) return; const state = get(); const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const })); if (!state.currentTrack) { @@ -197,6 +209,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< clearSeekDebounce(); clearSeekTarget(); clearRadioSessionSeenIds(); setCurrentRadioArtistId(null); + clearQueueServerForPlayback(); set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); syncQueueToServer([], null, 0); }, diff --git a/src/store/queueSync.test.ts b/src/store/queueSync.test.ts index 4ddd7564..77b9c72c 100644 --- a/src/store/queueSync.test.ts +++ b/src/store/queueSync.test.ts @@ -8,7 +8,7 @@ import type { Track } from './playerStoreTypes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({ - savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number) => undefined), + savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined), playerState: { queue: [] as Track[], currentTrack: null as Track | null, @@ -18,6 +18,9 @@ const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({ })); vi.mock('../api/subsonicPlayQueue', () => ({ savePlayQueue: savePlayQueueMock })); +vi.mock('../utils/playback/playbackServer', () => ({ + getPlaybackServerId: () => 'srv-a', +})); vi.mock('./playerStore', () => ({ usePlayerStore: { getState: () => playerState }, })); @@ -65,7 +68,7 @@ describe('syncQueueToServer (debounced)', () => { it('fires once after 5 s with id list + current id + position in ms', () => { syncQueueToServer(queue, queue[0], 30); vi.advanceTimersByTime(5000); - expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000); + expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 30000, 'srv-a'); }); it('cancels the previous timer when called again before fire', () => { @@ -74,7 +77,7 @@ describe('syncQueueToServer (debounced)', () => { syncQueueToServer([...queue, track('c')], queue[0], 20); vi.advanceTimersByTime(5000); expect(savePlayQueueMock).toHaveBeenCalledTimes(1); - expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000); + expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b', 'c'], 'a', 20000, 'srv-a'); }); it('caps the queue at 1000 ids', () => { @@ -91,7 +94,7 @@ describe('syncQueueToServer (debounced)', () => { describe('flushQueueSyncToServer (immediate)', () => { it('fires synchronously with no debounce', async () => { await flushQueueSyncToServer([track('a')], track('a'), 12); - expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000); + expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a'); }); it('cancels a pending debounced sync first', async () => { @@ -126,7 +129,7 @@ describe('flushPlayQueuePosition', () => { playerState.currentTrack = playerState.queue[0]; progressSnapshot.currentTime = 42; await flushPlayQueuePosition(); - expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000); + expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a'); }); it('is a no-op when a radio session is active', async () => { diff --git a/src/store/queueSync.ts b/src/store/queueSync.ts index f7710507..2d53ce65 100644 --- a/src/store/queueSync.ts +++ b/src/store/queueSync.ts @@ -1,5 +1,6 @@ import { savePlayQueue } from '../api/subsonicPlayQueue'; import type { Track } from './playerStoreTypes'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { getPlaybackProgressSnapshot } from './playbackProgress'; import { usePlayerStore } from './playerStore'; /** @@ -31,7 +32,8 @@ export function syncQueueToServer(queue: Track[], currentTrack: Track | null, cu syncTimeout = null; const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id); const pos = Math.floor(currentTime * 1000); - savePlayQueue(ids, currentTrack?.id, pos).catch(err => { + const serverId = getPlaybackServerId(); + savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => { console.error('Failed to sync play queue to server', err); }); }, SYNC_DEBOUNCE_MS); @@ -46,7 +48,8 @@ export function flushQueueSyncToServer(queue: Track[], currentTrack: Track | nul lastQueueHeartbeatAt = Date.now(); const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id); const pos = Math.floor(currentTime * 1000); - return savePlayQueue(ids, currentTrack.id, pos).catch(err => { + const serverId = getPlaybackServerId(); + return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => { console.error('Failed to flush play queue to server', err); }); } diff --git a/src/store/queueUndoAudioRestore.ts b/src/store/queueUndoAudioRestore.ts index db494f71..2666612b 100644 --- a/src/store/queueUndoAudioRestore.ts +++ b/src/store/queueUndoAudioRestore.ts @@ -1,6 +1,7 @@ import type { Track } from './playerStoreTypes'; import { invoke } from '@tauri-apps/api/core'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; import { useAuthStore } from './authStore'; @@ -38,10 +39,11 @@ export function queueUndoRestoreAudioEngine(opts: { isReplayGainActive(), authState.replayGainMode, ); const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null; - const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? ''); + const playbackSid = getPlaybackServerId(); + const url = resolvePlaybackUrl(track.id, playbackSid); recordEnginePlayUrl(track.id, url); usePlayerStore.setState({ - currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, authState.activeServerId ?? '', url), + currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackSid, url), }); const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id; setDeferHotCachePrefetch(true); @@ -91,5 +93,5 @@ export function queueUndoRestoreAudioEngine(opts: { .finally(() => { setDeferHotCachePrefetch(false); }); - touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); + touchHotCacheOnPlayback(track.id, getPlaybackServerId()); } diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts index bff7711d..c7c0d51b 100644 --- a/src/store/resumeAction.ts +++ b/src/store/resumeAction.ts @@ -2,6 +2,7 @@ import { getSong } from '../api/subsonicLibrary'; import { invoke } from '@tauri-apps/api/core'; import { estimateLivePosition } from '../api/orbit'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl'; import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb'; import { songToTrack } from '../utils/playback/songToTrack'; @@ -117,7 +118,7 @@ export function runResume(set: SetState, get: GetState): void { invoke('audio_resume').catch(console.error); setIsAudioPaused(false); set({ isPlaying: true }); - touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? ''); + touchHotCacheOnPlayback(currentTrack.id, getPlaybackServerId()); } else { // Engine has no loaded paused stream (app relaunch, or track ended and user // hits play — `isAudioPaused` is false after `audio:ended`). Flush any @@ -128,7 +129,7 @@ export function runResume(set: SetState, get: GetState): void { void (async () => { const authHot = useAuthStore.getState(); - const resumePromoteSid = authHot.activeServerId; + const resumePromoteSid = getPlaybackServerId(); if (authHot.hotCacheEnabled && resumePromoteSid) { await promoteCompletedStreamToHotCache( currentTrack, @@ -150,7 +151,7 @@ export function runResume(set: SetState, get: GetState): void { isReplayGainActive(), authStateCold.replayGainMode, ); const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null; - const coldServerId = useAuthStore.getState().activeServerId ?? ''; + const coldServerId = getPlaybackServerId(); setDeferHotCachePrefetch(true); const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId); set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) }); @@ -189,7 +190,7 @@ export function runResume(set: SetState, get: GetState): void { isReplayGainActive(), authStateCold.replayGainMode, ); const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null; - const coldServerId = useAuthStore.getState().activeServerId ?? ''; + const coldServerId = getPlaybackServerId(); setDeferHotCachePrefetch(true); const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId); set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) }); diff --git a/src/store/seekAction.ts b/src/store/seekAction.ts index 0e1c143c..fa120471 100644 --- a/src/store/seekAction.ts +++ b/src/store/seekAction.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { isRecoverableSeekError } from '../utils/audio/seekErrors'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { useAuthStore } from './authStore'; import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting'; import type { PlayerState } from './playerStoreTypes'; @@ -47,8 +48,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void { armSeekDebounce(100, () => { const s0 = get(); if (!s0.currentTrack) return; - const authSeek = useAuthStore.getState(); - const sidSeek = authSeek.activeServerId ?? ''; + const sidSeek = getPlaybackServerId(); if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) { setSeekFallbackVisualTarget({ trackId: s0.currentTrack.id, diff --git a/src/store/uiStateActions.ts b/src/store/uiStateActions.ts index a14995dd..e0b63499 100644 --- a/src/store/uiStateActions.ts +++ b/src/store/uiStateActions.ts @@ -2,6 +2,10 @@ import { persistQueueVisibility, } from './queueVisibilityStorage'; import type { PlayerState } from './playerStoreTypes'; +import { + ensurePlaybackServerActive, + playbackServerDiffersFromActive, +} from '../utils/playback/playbackServer'; type SetState = ( partial: Partial | ((state: PlayerState) => Partial), @@ -41,10 +45,31 @@ export function createUiStateActions(set: SetState): Pick< }; }), - openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride) => - set({ - contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride }, - }), + openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride, pinToPlaybackServer) => { + const pin = pinToPlaybackServer ?? type === 'queue-item'; + const open = () => + set({ + contextMenu: { + isOpen: true, + x, + y, + item, + type, + queueIndex, + playlistId, + playlistSongIndex, + shareKindOverride, + pinToPlaybackServer: pin, + }, + }); + if (pin && playbackServerDiffersFromActive()) { + void ensurePlaybackServerActive().then(ok => { + if (ok) open(); + }); + return; + } + open(); + }, closeContextMenu: () => set(state => ({ diff --git a/src/utils/componentHelpers/miniPlayerHelpers.ts b/src/utils/componentHelpers/miniPlayerHelpers.ts index 7ff10ece..227943a1 100644 --- a/src/utils/componentHelpers/miniPlayerHelpers.ts +++ b/src/utils/componentHelpers/miniPlayerHelpers.ts @@ -58,6 +58,7 @@ export function initialSnapshot(): MiniSyncPayload { track: s.currentTrack ? toMini(s.currentTrack) : null, queue: (s.queue ?? []).map(toMini), queueIndex: s.queueIndex ?? 0, + queueServerId: s.queueServerId ?? null, isPlaying: s.isPlaying, volume: s.volume ?? 1, gaplessEnabled: false, @@ -67,7 +68,7 @@ export function initialSnapshot(): MiniSyncPayload { }; } catch { return { - track: null, queue: [], queueIndex: 0, isPlaying: false, + track: null, queue: [], queueIndex: 0, queueServerId: null, isPlaying: false, volume: 1, gaplessEnabled: false, crossfadeEnabled: false, infiniteQueueEnabled: false, isMobile: false, }; diff --git a/src/utils/miniPlayerBridge.ts b/src/utils/miniPlayerBridge.ts index 018087c2..228cf4c3 100644 --- a/src/utils/miniPlayerBridge.ts +++ b/src/utils/miniPlayerBridge.ts @@ -25,6 +25,7 @@ export interface MiniSyncPayload { track: MiniTrackInfo | null; queue: MiniTrackInfo[]; queueIndex: number; + queueServerId: string | null; isPlaying: boolean; volume: number; gaplessEnabled: boolean; @@ -62,6 +63,7 @@ function snapshot(): MiniSyncPayload { track: s.currentTrack ? toMini(s.currentTrack) : null, queue: (s.queue ?? []).map(toMini), queueIndex: s.queueIndex ?? 0, + queueServerId: s.queueServerId ?? null, isPlaying: s.isPlaying, volume: s.volume, gaplessEnabled: !!a.gaplessEnabled, @@ -93,6 +95,7 @@ export function initMiniPlayerBridgeOnMain(): () => void { payload.track?.starred ?? '', (payload.track?.artists ?? []).map((a: SubsonicOpenArtistRef) => a.id ?? a.name).join('|'), payload.queueIndex, + payload.queueServerId ?? '', payload.volume, payload.gaplessEnabled, payload.crossfadeEnabled, @@ -110,6 +113,7 @@ export function initMiniPlayerBridgeOnMain(): () => void { || state.currentTrack?.starred !== prev.currentTrack?.starred || state.queueIndex !== prev.queueIndex || state.queue !== prev.queue + || state.queueServerId !== prev.queueServerId || state.volume !== prev.volume) { push(); } diff --git a/src/utils/playback/playbackServer.test.ts b/src/utils/playback/playbackServer.test.ts new file mode 100644 index 00000000..eb25868e --- /dev/null +++ b/src/utils/playback/playbackServer.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { useAuthStore } from '../../store/authStore'; +import { usePlayerStore } from '../../store/playerStore'; +import { + bindQueueServerForPlayback, + clearQueueServerForPlayback, + ensurePlaybackServerActive, + getPlaybackServerId, + playbackCoverArtForId, + playbackServerDiffersFromActive, + shouldBindQueueServerForPlay, +} from './playbackServer'; +import { vi } from 'vitest'; + +vi.mock('../server/switchActiveServer', () => ({ + switchActiveServer: vi.fn(async () => true), +})); + +describe('playbackServer', () => { + 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('getPlaybackServerId returns queue server while queue is non-empty', () => { + useAuthStore.setState({ activeServerId: 'b' }); + expect(getPlaybackServerId()).toBe('a'); + }); + + it('getPlaybackServerId falls back to active when queue is empty', () => { + clearQueueServerForPlayback(); + usePlayerStore.setState({ queue: [] }); + useAuthStore.setState({ activeServerId: 'b' }); + expect(getPlaybackServerId()).toBe('b'); + }); + + it('bindQueueServerForPlayback pins active server', () => { + useAuthStore.setState({ activeServerId: 'b' }); + bindQueueServerForPlayback(); + expect(usePlayerStore.getState().queueServerId).toBe('b'); + }); + + it('playbackServerDiffersFromActive when queue server != active', () => { + useAuthStore.setState({ activeServerId: 'b' }); + expect(playbackServerDiffersFromActive()).toBe(true); + usePlayerStore.setState({ queue: [] }); + expect(playbackServerDiffersFromActive()).toBe(false); + }); + + it('ensurePlaybackServerActive calls switch when servers differ', async () => { + const { switchActiveServer } = await import('../server/switchActiveServer'); + useAuthStore.setState({ activeServerId: 'b' }); + await ensurePlaybackServerActive(); + expect(switchActiveServer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'a' }), + ); + }); + + it('playbackCoverArtForId uses queue server credentials when browsing another server', () => { + useAuthStore.setState({ activeServerId: 'b' }); + const { src, cacheKey } = playbackCoverArtForId('cov1', 128); + expect(src).toContain('a.test'); + expect(cacheKey).toBe('a:cover:cov1:128'); + }); + + it('shouldBindQueueServerForPlay detects queue replacement', () => { + const prev = [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }]; + const next = [ + { id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }, + { id: 't2', title: 'T2', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }, + ]; + expect(shouldBindQueueServerForPlay(prev, next, next)).toBe(true); + expect(shouldBindQueueServerForPlay(prev, prev, undefined)).toBe(false); + }); +}); diff --git a/src/utils/playback/playbackServer.ts b/src/utils/playback/playbackServer.ts new file mode 100644 index 00000000..22f37ef9 --- /dev/null +++ b/src/utils/playback/playbackServer.ts @@ -0,0 +1,75 @@ +import { + buildCoverArtUrl, + buildCoverArtUrlForServer, + coverArtCacheKey, + coverArtCacheKeyForServer, +} from '../../api/subsonicStreamUrl'; +import { useAuthStore } from '../../store/authStore'; +import { usePlayerStore } from '../../store/playerStore'; +import { switchActiveServer } from '../server/switchActiveServer'; +import { sameQueueTrackId } from './queueIdentity'; +import type { Track } from '../../store/playerStoreTypes'; + +/** Server that owns the current queue / stream URLs (may differ from the browsed server). */ +export function getPlaybackServerId(): string { + const { queueServerId, queue } = usePlayerStore.getState(); + if ((queue?.length ?? 0) > 0 && queueServerId) return queueServerId; + return useAuthStore.getState().activeServerId ?? ''; +} + +export function bindQueueServerForPlayback(): void { + const sid = useAuthStore.getState().activeServerId; + if (!sid) return; + usePlayerStore.setState({ queueServerId: sid }); +} + +export function clearQueueServerForPlayback(): void { + usePlayerStore.setState({ queueServerId: null }); +} + +export function playbackServerDiffersFromActive(): boolean { + const { queueServerId, queue } = usePlayerStore.getState(); + if ((queue?.length ?? 0) === 0 || !queueServerId) return false; + const activeSid = useAuthStore.getState().activeServerId; + return !!activeSid && queueServerId !== activeSid; +} + +/** 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; + const playbackSid = getPlaybackServerId(); + const server = useAuthStore.getState().servers.find(s => s.id === playbackSid); + if (!server) return false; + return switchActiveServer(server); +} + +/** Cover URLs for queue / player UI when playback uses a non-active saved server. */ +export function playbackCoverArtForId(coverId: string, size: number): { src: string; cacheKey: string } { + const playbackSid = getPlaybackServerId(); + const activeSid = useAuthStore.getState().activeServerId; + if (playbackSid && activeSid && playbackSid !== activeSid) { + const server = useAuthStore.getState().servers.find(s => s.id === playbackSid); + if (server) { + return { + src: buildCoverArtUrlForServer(server.url, server.username, server.password, coverId, size), + cacheKey: coverArtCacheKeyForServer(server.id, coverId, size), + }; + } + } + return { + src: buildCoverArtUrl(coverId, size), + cacheKey: coverArtCacheKey(coverId, size), + }; +} + +export function shouldBindQueueServerForPlay( + prevQueue: Track[], + newQueue: Track[], + explicitQueueArg: Track[] | undefined, +): boolean { + if (newQueue.length === 0) return false; + if (prevQueue.length === 0) return true; + if (explicitQueueArg === undefined) return false; + if (explicitQueueArg.length !== prevQueue.length) return true; + return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.id, t.id)); +} diff --git a/src/utils/playback/resolvePlaybackUrl.ts b/src/utils/playback/resolvePlaybackUrl.ts index 0f7f1084..826115bf 100644 --- a/src/utils/playback/resolvePlaybackUrl.ts +++ b/src/utils/playback/resolvePlaybackUrl.ts @@ -1,6 +1,7 @@ -import { buildStreamUrl } from '../../api/subsonicStreamUrl'; +import { buildStreamUrlForServer } from '../../api/subsonicStreamUrl'; import { useOfflineStore } from '../../store/offlineStore'; import { useHotCacheStore } from '../../store/hotCacheStore'; +import { getPlaybackServerId } from './playbackServer'; /** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */ export type PlaybackSourceKind = 'offline' | 'hot' | 'stream'; @@ -54,10 +55,11 @@ export function getPlaybackSourceKind( } /** Offline library → hot playback cache → HTTP stream. */ -export function resolvePlaybackUrl(trackId: string, serverId: string): string { - const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId); +export function resolvePlaybackUrl(trackId: string, serverId?: string): string { + const sid = serverId && serverId.length > 0 ? serverId : getPlaybackServerId(); + const offline = useOfflineStore.getState().getLocalUrl(trackId, sid); if (offline) return offline; - const hot = useHotCacheStore.getState().getLocalUrl(trackId, serverId); + const hot = useHotCacheStore.getState().getLocalUrl(trackId, sid); if (hot) return hot; - return buildStreamUrl(trackId); + return buildStreamUrlForServer(sid, trackId); }