mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
fix(playback): pin queue playback to source server when browsing another library (#717)
* fix(playback): pin queue streams, cover art, and library links to queue server When the active server changes while a queue from another server is playing, keep streams and UI on queueServerId; switch back for artist/album links and queue or player-bar context menus. * fix(playback): switch to queue server when opening Now Playing Ensure active server matches queueServerId before Subsonic fetches on the Now Playing page, mobile player route, and queue info panel; scope caches by server id. * docs(credits): mention Now Playing in PR #717 contribution line * fix(playback): route scrobble and queue sync to queue server Address PR review: apiForServer for scrobble/now-playing/savePlayQueue, clear queueServerId on server removal, mini-player queueServerId sync, block cross-server enqueue with toast, and regression tests.
This commit is contained in:
@@ -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)).
|
* 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.
|
* **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
|
## [1.45.0] - 2026-05-04
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
|||||||
import md5 from 'md5';
|
import md5 from 'md5';
|
||||||
import { version } from '../../package.json';
|
import { version } from '../../package.json';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import type { ServerProfile } from '../store/authStoreTypes';
|
||||||
|
|
||||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||||
|
|
||||||
@@ -51,6 +52,22 @@ export function getClient() {
|
|||||||
return { baseUrl: `${baseUrl}/rest`, params };
|
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<T>(
|
||||||
|
serverId: string,
|
||||||
|
endpoint: string,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
timeout = 15000,
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>(endpoint: string, extra: Record<string, unknown> = {}, timeout = 15000): Promise<T> {
|
export async function api<T>(endpoint: string, extra: Record<string, unknown> = {}, timeout = 15000): Promise<T> {
|
||||||
const { baseUrl, params } = getClient();
|
const { baseUrl, params } = getClient();
|
||||||
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from './subsonicClient';
|
import { api, apiForServer } from './subsonicClient';
|
||||||
import type { SubsonicSong } from './subsonicTypes';
|
import type { SubsonicSong } from './subsonicTypes';
|
||||||
|
|
||||||
export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> {
|
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<void> {
|
export async function savePlayQueue(
|
||||||
|
songIds: string[],
|
||||||
|
current: string | undefined,
|
||||||
|
position: number | undefined,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!serverId) return;
|
||||||
const params: Record<string, unknown> = {};
|
const params: Record<string, unknown> = {};
|
||||||
if (songIds.length > 0) params.id = songIds;
|
if (songIds.length > 0) params.id = songIds;
|
||||||
if (current !== undefined) params.current = current;
|
if (current !== undefined) params.current = current;
|
||||||
if (position !== undefined) params.position = position;
|
if (position !== undefined) params.position = position;
|
||||||
await api('savePlayQueue.view', params);
|
await apiForServer(serverId, 'savePlayQueue.view', params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,30 @@
|
|||||||
import { api } from './subsonicClient';
|
import { api, apiForServer } from './subsonicClient';
|
||||||
import type { SubsonicNowPlaying } from './subsonicTypes';
|
import type { SubsonicNowPlaying } from './subsonicTypes';
|
||||||
|
|
||||||
export async function scrobbleSong(id: string, time: number): Promise<void> {
|
async function scrobbleOnServer(
|
||||||
|
serverId: string,
|
||||||
|
id: string,
|
||||||
|
submission: boolean,
|
||||||
|
time?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const params: Record<string, unknown> = { 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<void> {
|
||||||
|
if (!serverId) return;
|
||||||
try {
|
try {
|
||||||
await api('scrobble.view', { id, time, submission: true });
|
await scrobbleOnServer(serverId, id, true, time);
|
||||||
} catch {
|
} catch {
|
||||||
// best effort
|
// best effort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reportNowPlaying(id: string): Promise<void> {
|
export async function reportNowPlaying(id: string, serverId: string): Promise<void> {
|
||||||
|
if (!serverId) return;
|
||||||
try {
|
try {
|
||||||
await api('scrobble.view', { id, submission: false });
|
await scrobbleOnServer(serverId, id, false);
|
||||||
} catch {
|
} catch {
|
||||||
// best effort
|
// best effort
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export function buildStreamUrl(id: string): string {
|
||||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||||
const server = getActiveServer();
|
const server = getActiveServer();
|
||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
const salt = secureRandomSalt();
|
if (!server || !baseUrl) return streamUrlFromProfile('', '', '', id);
|
||||||
const token = md5((server?.password ?? '') + salt);
|
return streamUrlFromProfile(server.url, server.username, server.password, id);
|
||||||
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()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable cache key for cover art — does not include ephemeral auth params. */
|
/** Stable cache key for cover art — does not include ephemeral auth params. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { Suspense, useCallback, useEffect, useState } from 'react';
|
import React, { Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { ensurePlaybackServerActive } from '../utils/playback/playbackServer';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { PanelRight } from 'lucide-react';
|
import { PanelRight } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -97,7 +98,10 @@ export function AppShell() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPsyNavigate = (e: Event) => {
|
const onPsyNavigate = (e: Event) => {
|
||||||
const detail = (e as CustomEvent).detail;
|
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);
|
window.addEventListener('psy:navigate', onPsyNavigate);
|
||||||
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
|
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from '../utils/componentHelpers/contextMenuActions';
|
} from '../utils/componentHelpers/contextMenuActions';
|
||||||
import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
|
import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
|
||||||
import { useContextMenuRating } from '../hooks/useContextMenuRating';
|
import { useContextMenuRating } from '../hooks/useContextMenuRating';
|
||||||
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ContextMenuItems from './contextMenu/ContextMenuItems';
|
import ContextMenuItems from './contextMenu/ContextMenuItems';
|
||||||
|
|
||||||
export { AddToPlaylistSubmenu };
|
export { AddToPlaylistSubmenu };
|
||||||
@@ -22,6 +24,8 @@ export { AddToPlaylistSubmenu };
|
|||||||
|
|
||||||
export default function ContextMenu() {
|
export default function ContextMenu() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||||
const orbitRole = useOrbitStore(s => s.role);
|
const orbitRole = useOrbitStore(s => s.role);
|
||||||
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
@@ -125,7 +129,18 @@ export default function ContextMenu() {
|
|||||||
}, [contextMenu.isOpen, closeContextMenu, cancelPlaylistSubmenuCloseTimer]);
|
}, [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) =>
|
const isStarred = (id: string, itemStarred?: string) =>
|
||||||
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
|
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
|
||||||
@@ -216,6 +231,8 @@ export default function ContextMenu() {
|
|||||||
downloadAlbum={downloadAlbum}
|
downloadAlbum={downloadAlbum}
|
||||||
copyShareLink={copyShareLink}
|
copyShareLink={copyShareLink}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
|
pinToPlaybackServer={pinToPlaybackServer}
|
||||||
|
navigateLibrary={navigateLibrary}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { star, unstar } from '../api/subsonicStarRating';
|
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 React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
SkipBack, SkipForward,
|
SkipBack, SkipForward,
|
||||||
@@ -56,12 +57,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
|
|
||||||
const duration = currentTrack?.duration ?? 0;
|
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.
|
// 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 artCover = usePlaybackCoverArt(currentTrack?.coverArt, 300);
|
||||||
const artKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 300) : '', [currentTrack?.coverArt]);
|
const artUrl = artCover.src;
|
||||||
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
|
const artKey = artCover.cacheKey;
|
||||||
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
|
const portraitCover = usePlaybackCoverArt(currentTrack?.coverArt, 500);
|
||||||
|
const coverUrl = portraitCover.src;
|
||||||
|
const coverKey = portraitCover.cacheKey;
|
||||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||||
|
|
||||||
@@ -86,12 +88,13 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
const idx = s.queueIndex;
|
const idx = s.queueIndex;
|
||||||
return (idx >= 0 && idx + 1 < q.length) ? (q[idx + 1]?.coverArt ?? null) : null;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!nextCoverArt) return;
|
if (!nextCoverArt) return;
|
||||||
const url = buildCoverArtUrl(nextCoverArt, 300);
|
const { src: url, cacheKey: key } = playbackCoverArtForId(nextCoverArt, 300);
|
||||||
const key = coverArtCacheKey(nextCoverArt, 300);
|
|
||||||
getCachedBlob(url, key).catch(() => {});
|
getCachedBlob(url, key).catch(() => {});
|
||||||
}, [nextCoverArt]);
|
}, [nextCoverArt, queueServerId, activeServerId]);
|
||||||
|
|
||||||
// Lyrics settings popover state
|
// Lyrics settings popover state
|
||||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { emit } from '@tauri-apps/api/event';
|
import { emit } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { registerQueueDragHitTest } from '../contexts/DragDropContext';
|
import { registerQueueDragHitTest } from '../contexts/DragDropContext';
|
||||||
import MiniContextMenu from './MiniContextMenu';
|
import MiniContextMenu from './MiniContextMenu';
|
||||||
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
|
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
|
||||||
@@ -63,6 +64,7 @@ export default function MiniPlayer() {
|
|||||||
useMiniSync({
|
useMiniSync({
|
||||||
onSync: (payload) => {
|
onSync: (payload) => {
|
||||||
setState(payload);
|
setState(payload);
|
||||||
|
usePlayerStore.setState({ queueServerId: payload.queueServerId ?? null });
|
||||||
if (payload.track?.duration) setDuration(payload.track.duration);
|
if (payload.track?.duration) setDuration(payload.track.duration);
|
||||||
if (typeof payload.volume === 'number') setVolumeState(payload.volume);
|
if (typeof payload.volume === 'number') setVolumeState(payload.volume);
|
||||||
},
|
},
|
||||||
@@ -138,14 +140,7 @@ export default function MiniPlayer() {
|
|||||||
}, [queueOpen, state.queueIndex]);
|
}, [queueOpen, state.queueIndex]);
|
||||||
|
|
||||||
const { track, isPlaying } = state;
|
const { track, isPlaying } = state;
|
||||||
const miniCoverSrc = useMemo(
|
const { src: miniCoverSrc, cacheKey: miniCoverKey } = usePlaybackCoverArt(track?.coverArt, 300);
|
||||||
() => (track?.coverArt ? buildCoverArtUrl(track.coverArt, 300) : ''),
|
|
||||||
[track?.coverArt],
|
|
||||||
);
|
|
||||||
const miniCoverKey = useMemo(
|
|
||||||
() => (track?.coverArt ? coverArtCacheKey(track.coverArt, 300) : ''),
|
|
||||||
[track?.coverArt],
|
|
||||||
);
|
|
||||||
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { star, unstar } from '../api/subsonicStarRating';
|
import { star, unstar } from '../api/subsonicStarRating';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
||||||
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
|
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||||
|
import { useEnsurePlaybackServerOnMount } from '../hooks/useEnsurePlaybackServerOnMount';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||||||
@@ -152,6 +154,8 @@ function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentT
|
|||||||
export default function MobilePlayerView() {
|
export default function MobilePlayerView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||||
|
useEnsurePlaybackServerOnMount();
|
||||||
|
|
||||||
// Lock body scroll while full-screen player is mounted
|
// Lock body scroll while full-screen player is mounted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -185,12 +189,7 @@ export default function MobilePlayerView() {
|
|||||||
|
|
||||||
const duration = currentTrack?.duration ?? 0;
|
const duration = currentTrack?.duration ?? 0;
|
||||||
|
|
||||||
// Cover art
|
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(currentTrack?.coverArt, 800);
|
||||||
const coverFetchUrl = useMemo(
|
|
||||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '',
|
|
||||||
[currentTrack?.coverArt]
|
|
||||||
);
|
|
||||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
|
||||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||||
|
|
||||||
// Dynamic background color extracted from cover art
|
// Dynamic background color extracted from cover art
|
||||||
@@ -332,7 +331,7 @@ export default function MobilePlayerView() {
|
|||||||
<OpenArtistRefInline
|
<OpenArtistRefInline
|
||||||
refs={currentTrack.artists}
|
refs={currentTrack.artists}
|
||||||
fallbackName={currentTrack.artist}
|
fallbackName={currentTrack.artist}
|
||||||
onGoArtist={id => navigate(`/artist/${id}`)}
|
onGoArtist={id => { void navigatePlaybackLibrary(`/artist/${id}`); }}
|
||||||
as="none"
|
as="none"
|
||||||
linkTag="span"
|
linkTag="span"
|
||||||
linkClassName="mp-artist-link"
|
linkClassName="mp-artist-link"
|
||||||
@@ -341,12 +340,12 @@ export default function MobilePlayerView() {
|
|||||||
<span
|
<span
|
||||||
role={currentTrack.artistId ? 'link' : undefined}
|
role={currentTrack.artistId ? 'link' : undefined}
|
||||||
tabIndex={currentTrack.artistId ? 0 : undefined}
|
tabIndex={currentTrack.artistId ? 0 : undefined}
|
||||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
onClick={() => currentTrack.artistId && void navigatePlaybackLibrary(`/artist/${currentTrack.artistId}`)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (!currentTrack.artistId) return;
|
if (!currentTrack.artistId) return;
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(`/artist/${currentTrack.artistId}`);
|
void navigatePlaybackLibrary(`/artist/${currentTrack.artistId}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Info } from 'lucide-react';
|
|||||||
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useEnsurePlaybackServerOnMount } from '../hooks/useEnsurePlaybackServerOnMount';
|
||||||
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import OverlayScrollArea from './OverlayScrollArea';
|
import OverlayScrollArea from './OverlayScrollArea';
|
||||||
@@ -72,21 +73,31 @@ function buildContributorRows(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queuePanelCacheKey(serverId: string, id: string): string {
|
||||||
|
return serverId ? `${serverId}:${id}` : id;
|
||||||
|
}
|
||||||
|
|
||||||
export default function NowPlayingInfo() {
|
export default function NowPlayingInfo() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
|
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
|
||||||
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
|
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
|
||||||
|
const subsonicReady = useEnsurePlaybackServerOnMount();
|
||||||
|
const subsonicServerId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
|
|
||||||
const artistName = currentTrack?.artist || '';
|
const artistName = currentTrack?.artist || '';
|
||||||
const artistId = currentTrack?.artistId || '';
|
const artistId = currentTrack?.artistId || '';
|
||||||
const songId = currentTrack?.id || '';
|
const songId = currentTrack?.id || '';
|
||||||
|
|
||||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(
|
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(
|
||||||
artistId ? artistInfoCache.get(artistId) ?? null : null,
|
artistId && subsonicServerId
|
||||||
|
? artistInfoCache.get(queuePanelCacheKey(subsonicServerId, artistId)) ?? null
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
const [songDetail, setSongDetail] = useState<SubsonicSong | null>(
|
const [songDetail, setSongDetail] = useState<SubsonicSong | null>(
|
||||||
songId ? songDetailCache.get(songId) ?? null : null,
|
songId && subsonicServerId
|
||||||
|
? songDetailCache.get(queuePanelCacheKey(subsonicServerId, songId)) ?? null
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
|
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
|
||||||
const [tourLoading, setTourLoading] = useState(false);
|
const [tourLoading, setTourLoading] = useState(false);
|
||||||
@@ -101,27 +112,29 @@ export default function NowPlayingInfo() {
|
|||||||
|
|
||||||
// Artist bio + image
|
// Artist bio + image
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artistId) { setArtistInfo(null); return; }
|
if (!subsonicReady || !subsonicServerId || !artistId) { setArtistInfo(null); return; }
|
||||||
const cached = artistInfoCache.get(artistId);
|
const cacheKey = queuePanelCacheKey(subsonicServerId, artistId);
|
||||||
|
const cached = artistInfoCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setArtistInfo(cached); return; }
|
if (cached !== undefined) { setArtistInfo(cached); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getArtistInfo(artistId)
|
getArtistInfo(artistId)
|
||||||
.then(info => { if (!cancelled) { artistInfoCache.set(artistId, info ?? null); setArtistInfo(info ?? null); } })
|
.then(info => { if (!cancelled) { artistInfoCache.set(cacheKey, info ?? null); setArtistInfo(info ?? null); } })
|
||||||
.catch(() => { if (!cancelled) { artistInfoCache.set(artistId, null); setArtistInfo(null); } });
|
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfo(null); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [artistId]);
|
}, [subsonicReady, subsonicServerId, artistId]);
|
||||||
|
|
||||||
// Song detail (for OpenSubsonic contributors[])
|
// Song detail (for OpenSubsonic contributors[])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!songId) { setSongDetail(null); return; }
|
if (!subsonicReady || !subsonicServerId || !songId) { setSongDetail(null); return; }
|
||||||
const cached = songDetailCache.get(songId);
|
const cacheKey = queuePanelCacheKey(subsonicServerId, songId);
|
||||||
|
const cached = songDetailCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setSongDetail(cached); return; }
|
if (cached !== undefined) { setSongDetail(cached); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getSong(songId)
|
getSong(songId)
|
||||||
.then(song => { if (!cancelled) { songDetailCache.set(songId, song ?? null); setSongDetail(song ?? null); } })
|
.then(song => { if (!cancelled) { songDetailCache.set(cacheKey, song ?? null); setSongDetail(song ?? null); } })
|
||||||
.catch(() => { if (!cancelled) { songDetailCache.set(songId, null); setSongDetail(null); } });
|
.catch(() => { if (!cancelled) { songDetailCache.set(cacheKey, null); setSongDetail(null); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [songId]);
|
}, [subsonicReady, subsonicServerId, songId]);
|
||||||
|
|
||||||
// Bandsintown — only when opt-in toggle is on
|
// Bandsintown — only when opt-in toggle is on
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { star, unstar } from '../api/subsonicStarRating';
|
import { star, unstar } from '../api/subsonicStarRating';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||||
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -17,7 +18,7 @@ import WaveformSeek from './WaveformSeek';
|
|||||||
import Equalizer from './Equalizer';
|
import Equalizer from './Equalizer';
|
||||||
import StarRating from './StarRating';
|
import StarRating from './StarRating';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import MarqueeText from './MarqueeText';
|
import MarqueeText from './MarqueeText';
|
||||||
import LastfmIcon from './LastfmIcon';
|
import LastfmIcon from './LastfmIcon';
|
||||||
@@ -40,7 +41,7 @@ import { useUtilityOverflowMenu } from '../hooks/useUtilityOverflowMenu';
|
|||||||
|
|
||||||
export default function PlayerBar() {
|
export default function PlayerBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||||
const [eqOpen, setEqOpen] = useState(false);
|
const [eqOpen, setEqOpen] = useState(false);
|
||||||
const [showVolPct, setShowVolPct] = useState(false);
|
const [showVolPct, setShowVolPct] = useState(false);
|
||||||
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
|
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
|
||||||
@@ -156,8 +157,14 @@ export default function PlayerBar() {
|
|||||||
? currentTrack.artists
|
? currentTrack.artists
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const coverSrc = useMemo(() => displayCoverArt ? buildCoverArtUrl(displayCoverArt, 128) : '', [displayCoverArt]);
|
const previewCover = useMemo(() => {
|
||||||
const coverKey = displayCoverArt ? coverArtCacheKey(displayCoverArt, 128) : '';
|
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<HTMLInputElement>) => {
|
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setVolume(parseFloat(e.target.value));
|
setVolume(parseFloat(e.target.value));
|
||||||
@@ -220,7 +227,7 @@ export default function PlayerBar() {
|
|||||||
userRatingOverrides={userRatingOverrides}
|
userRatingOverrides={userRatingOverrides}
|
||||||
setUserRatingOverride={setUserRatingOverride}
|
setUserRatingOverride={setUserRatingOverride}
|
||||||
toggleFullscreen={toggleFullscreen}
|
toggleFullscreen={toggleFullscreen}
|
||||||
navigate={navigate}
|
navigate={navigatePlaybackLibrary}
|
||||||
openContextMenu={openContextMenu}
|
openContextMenu={openContextMenu}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
|
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { useState, useRef, useMemo } from 'react';
|
import { useState, useRef, useMemo } from 'react';
|
||||||
@@ -11,7 +10,7 @@ import HostApprovalQueue from './HostApprovalQueue';
|
|||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { usePlaylistStore } from '../store/playlistStore';
|
||||||
import { useCachedUrl } from './CachedImage';
|
import { useCachedUrl } from './CachedImage';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { encodeSharePayload } from '../utils/share/shareLink';
|
import { encodeSharePayload } from '../utils/share/shareLink';
|
||||||
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
||||||
@@ -34,6 +33,7 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
|
|||||||
import { QueueList } from './queuePanel/QueueList';
|
import { QueueList } from './queuePanel/QueueList';
|
||||||
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
||||||
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
||||||
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||||
|
|
||||||
export default function QueuePanel() {
|
export default function QueuePanel() {
|
||||||
const orbitRole = useOrbitStore(s => s.role);
|
const orbitRole = useOrbitStore(s => s.role);
|
||||||
@@ -49,7 +49,7 @@ export default function QueuePanel() {
|
|||||||
|
|
||||||
function QueuePanelHostOrSolo() {
|
function QueuePanelHostOrSolo() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||||
const orbitRole = useOrbitStore(s => s.role);
|
const orbitRole = useOrbitStore(s => s.role);
|
||||||
const orbitState = useOrbitStore(s => s.state);
|
const orbitState = useOrbitStore(s => s.state);
|
||||||
/** trackId → addedBy (host username or guest username) — only populated while
|
/** trackId → addedBy (host username or guest username) — only populated while
|
||||||
@@ -78,13 +78,9 @@ function QueuePanelHostOrSolo() {
|
|||||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
const currentCoverFetchUrl = useMemo(
|
const { src: currentCoverFetchUrl, cacheKey: currentCoverCacheKey } = usePlaybackCoverArt(
|
||||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
|
currentTrack?.coverArt,
|
||||||
[currentTrack?.coverArt]
|
128,
|
||||||
);
|
|
||||||
const currentCoverCacheKey = useMemo(
|
|
||||||
() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '',
|
|
||||||
[currentTrack?.coverArt]
|
|
||||||
);
|
);
|
||||||
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
|
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
|
||||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||||
@@ -265,7 +261,7 @@ function QueuePanelHostOrSolo() {
|
|||||||
currentCoverSrc={currentCoverSrc}
|
currentCoverSrc={currentCoverSrc}
|
||||||
userRatingOverrides={userRatingOverrides}
|
userRatingOverrides={userRatingOverrides}
|
||||||
orbitAttributionLabel={orbitAttributionLabel}
|
orbitAttributionLabel={orbitAttributionLabel}
|
||||||
navigate={navigate}
|
navigate={navigatePlaybackLibrary}
|
||||||
playbackSource={playbackSource}
|
playbackSource={playbackSource}
|
||||||
normalizationEngine={normalizationEngine}
|
normalizationEngine={normalizationEngine}
|
||||||
normalizationEngineLive={normalizationEngineLive}
|
normalizationEngineLive={normalizationEngineLive}
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
|||||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||||
applySongRating, applyAlbumRating, applyArtistRating,
|
applySongRating, applyAlbumRating, applyArtistRating,
|
||||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||||
|
pinToPlaybackServer, navigateLibrary,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const goLibrary = pinToPlaybackServer ? navigateLibrary : (path: string) => { navigate(path); };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -34,7 +36,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
|||||||
const albumRatingDisabled = entityRatingSupport === 'track_only';
|
const albumRatingDisabled = entityRatingSupport === 'track_only';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/album/${album.id}`))}>
|
||||||
<Play size={14} /> {t('contextMenu.openAlbum')}
|
<Play size={14} /> {t('contextMenu.openAlbum')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||||
@@ -52,7 +54,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
|||||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/artist/${album.artistId}`))}>
|
||||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react';
|
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 { star, unstar } from '../../api/subsonicStarRating';
|
||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
@@ -21,10 +20,10 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
|||||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||||
applySongRating, applyAlbumRating, applyArtistRating,
|
applySongRating, applyAlbumRating, applyArtistRating,
|
||||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||||
|
navigateLibrary,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -54,12 +53,12 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
{song.albumId && (
|
{song.albumId && (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => navigateLibrary(`/album/${song.albumId}`))}>
|
||||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{song.artistId && (
|
{song.artistId && (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${song.artistId}`))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => navigateLibrary(`/artist/${song.artistId}`))}>
|
||||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,4 +51,7 @@ export interface ContextMenuItemsProps {
|
|||||||
downloadAlbum: (albumName: string, albumId: string) => Promise<void>;
|
downloadAlbum: (albumName: string, albumId: string) => Promise<void>;
|
||||||
copyShareLink: (kind: EntityShareKind, id: string) => void;
|
copyShareLink: (kind: EntityShareKind, id: string) => void;
|
||||||
isStarred: (id: string, itemStarred?: string) => boolean;
|
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<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ interface Props {
|
|||||||
userRatingOverrides: Record<string, number>;
|
userRatingOverrides: Record<string, number>;
|
||||||
setUserRatingOverride: (id: string, r: number) => void;
|
setUserRatingOverride: (id: string, r: number) => void;
|
||||||
toggleFullscreen: () => void;
|
toggleFullscreen: () => void;
|
||||||
navigate: (to: string) => void;
|
navigate: (to: string) => void | Promise<void>;
|
||||||
openContextMenu: PlayerState['openContextMenu'];
|
openContextMenu: PlayerState['openContextMenu'];
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ export function PlayerTrackInfo({
|
|||||||
songCount: 0,
|
songCount: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
};
|
};
|
||||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
openContextMenu(e.clientX, e.clientY, album, 'album', undefined, undefined, undefined, undefined, true);
|
||||||
}
|
}
|
||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface Props {
|
|||||||
currentCoverSrc: string;
|
currentCoverSrc: string;
|
||||||
userRatingOverrides: Record<string, number>;
|
userRatingOverrides: Record<string, number>;
|
||||||
orbitAttributionLabel: (trackId: string) => string | null;
|
orbitAttributionLabel: (trackId: string) => string | null;
|
||||||
navigate: (to: string) => void;
|
navigate: (to: string) => void | Promise<void>;
|
||||||
playbackSource: PlaybackSourceKind | null;
|
playbackSource: PlaybackSourceKind | null;
|
||||||
normalizationEngine: NormalizationEngine;
|
normalizationEngine: NormalizationEngine;
|
||||||
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
|
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
|
||||||
|
|||||||
@@ -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)',
|
'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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -28,6 +28,10 @@ export interface NowPlayingFetchersDeps {
|
|||||||
audiomuseNavidromeEnabled: boolean;
|
audiomuseNavidromeEnabled: boolean;
|
||||||
lastfmUsername: string;
|
lastfmUsername: string;
|
||||||
currentTrack: { artist: string; title: string } | null;
|
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 {
|
export interface NowPlayingFetchersResult {
|
||||||
@@ -42,64 +46,80 @@ export interface NowPlayingFetchersResult {
|
|||||||
lfmArtist: LastfmArtistStats | null;
|
lfmArtist: LastfmArtistStats | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subsonicCacheKey(serverId: string, id: string): string {
|
||||||
|
return serverId ? `${serverId}:${id}` : id;
|
||||||
|
}
|
||||||
|
|
||||||
export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult {
|
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
|
// Entity state, seeded from TTL cache so same-artist song switches are instant
|
||||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(() => songId ? songMetaCache.get(songId) ?? null : null);
|
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(() =>
|
||||||
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(() => artistId ? artistInfoCache.get(artistId) ?? null : null);
|
songId && subsonicServerId ? songMetaCache.get(subsonicCacheKey(subsonicServerId, songId)) ?? null : null);
|
||||||
const [albumData, setAlbumData] = useState<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>(() => albumId ? albumCache.get(albumId) ?? null : null);
|
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(() =>
|
||||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>(() => artistName ? topSongsCache.get(artistName) ?? [] : []);
|
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<SubsonicSong[]>(() =>
|
||||||
|
artistName && subsonicServerId ? topSongsCache.get(subsonicCacheKey(subsonicServerId, artistName)) ?? [] : []);
|
||||||
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>(() => artistName ? tourCache.get(artistName) ?? [] : []);
|
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>(() => artistName ? tourCache.get(artistName) ?? [] : []);
|
||||||
const [tourLoading, setTourLoading] = useState(false);
|
const [tourLoading, setTourLoading] = useState(false);
|
||||||
const [discography, setDiscography] = useState<SubsonicAlbum[]>(() => artistId ? discographyCache.get(artistId) ?? [] : []);
|
const [discography, setDiscography] = useState<SubsonicAlbum[]>(() =>
|
||||||
|
artistId && subsonicServerId ? discographyCache.get(subsonicCacheKey(subsonicServerId, artistId)) ?? [] : []);
|
||||||
const [lfmTrack, setLfmTrack] = useState<LastfmTrackInfo | null>(null);
|
const [lfmTrack, setLfmTrack] = useState<LastfmTrackInfo | null>(null);
|
||||||
const [lfmArtist, setLfmArtist] = useState<LastfmArtistStats | null>(null);
|
const [lfmArtist, setLfmArtist] = useState<LastfmArtistStats | null>(null);
|
||||||
|
|
||||||
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
|
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!songId) { setSongMeta(null); return; }
|
if (!fetchEnabled || !subsonicServerId || !songId) { setSongMeta(null); return; }
|
||||||
const cached = songMetaCache.get(songId);
|
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
||||||
|
const cached = songMetaCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setSongMeta(cached); return; }
|
if (cached !== undefined) { setSongMeta(cached); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getSong(songId)
|
getSong(songId)
|
||||||
.then(v => { if (!cancelled) { songMetaCache.set(songId, v ?? null); setSongMeta(v ?? null); } })
|
.then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMeta(v ?? null); } })
|
||||||
.catch(() => { if (!cancelled) { songMetaCache.set(songId, null); setSongMeta(null); } });
|
.catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMeta(null); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [songId]);
|
}, [fetchEnabled, subsonicServerId, songId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artistId) { setArtistInfo(null); return; }
|
if (!fetchEnabled || !subsonicServerId || !artistId) { setArtistInfo(null); return; }
|
||||||
const cached = artistInfoCache.get(artistId);
|
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||||
|
const cached = artistInfoCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setArtistInfo(cached); return; }
|
if (cached !== undefined) { setArtistInfo(cached); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getArtistInfo(artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
getArtistInfo(artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||||
.then(v => { if (!cancelled) { artistInfoCache.set(artistId, v ?? null); setArtistInfo(v ?? null); } })
|
.then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfo(v ?? null); } })
|
||||||
.catch(() => { if (!cancelled) { artistInfoCache.set(artistId, null); setArtistInfo(null); } });
|
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfo(null); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [artistId, audiomuseNavidromeEnabled]);
|
}, [fetchEnabled, subsonicServerId, artistId, audiomuseNavidromeEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!albumId) { setAlbumData(null); return; }
|
if (!fetchEnabled || !subsonicServerId || !albumId) { setAlbumData(null); return; }
|
||||||
const cached = albumCache.get(albumId);
|
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
||||||
|
const cached = albumCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setAlbumData(cached); return; }
|
if (cached !== undefined) { setAlbumData(cached); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getAlbum(albumId)
|
getAlbum(albumId)
|
||||||
.then(v => { if (!cancelled) { albumCache.set(albumId, v); setAlbumData(v); } })
|
.then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumData(v); } })
|
||||||
.catch(() => { if (!cancelled) { albumCache.set(albumId, null); setAlbumData(null); } });
|
.catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumData(null); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [albumId]);
|
}, [fetchEnabled, subsonicServerId, albumId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artistName) { setTopSongs([]); return; }
|
if (!fetchEnabled || !subsonicServerId || !artistName) { setTopSongs([]); return; }
|
||||||
const cached = topSongsCache.get(artistName);
|
const cacheKey = subsonicCacheKey(subsonicServerId, artistName);
|
||||||
|
const cached = topSongsCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setTopSongs(cached); return; }
|
if (cached !== undefined) { setTopSongs(cached); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getTopSongs(artistName)
|
getTopSongs(artistName)
|
||||||
.then(v => { if (!cancelled) { topSongsCache.set(artistName, v); setTopSongs(v); } })
|
.then(v => { if (!cancelled) { topSongsCache.set(cacheKey, v); setTopSongs(v); } })
|
||||||
.catch(() => { if (!cancelled) { topSongsCache.set(artistName, []); setTopSongs([]); } });
|
.catch(() => { if (!cancelled) { topSongsCache.set(cacheKey, []); setTopSongs([]); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [artistName]);
|
}, [fetchEnabled, subsonicServerId, artistName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
|
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
|
||||||
@@ -115,15 +135,16 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
|||||||
|
|
||||||
// Discography via getArtist
|
// Discography via getArtist
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artistId) { setDiscography([]); return; }
|
if (!fetchEnabled || !subsonicServerId || !artistId) { setDiscography([]); return; }
|
||||||
const cached = discographyCache.get(artistId);
|
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||||
|
const cached = discographyCache.get(cacheKey);
|
||||||
if (cached !== undefined) { setDiscography(cached); return; }
|
if (cached !== undefined) { setDiscography(cached); return; }
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getArtist(artistId)
|
getArtist(artistId)
|
||||||
.then(v => { if (!cancelled) { discographyCache.set(artistId, v.albums); setDiscography(v.albums); } })
|
.then(v => { if (!cancelled) { discographyCache.set(cacheKey, v.albums); setDiscography(v.albums); } })
|
||||||
.catch(() => { if (!cancelled) { discographyCache.set(artistId, []); setDiscography([]); } });
|
.catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscography([]); } });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [artistId]);
|
}, [fetchEnabled, subsonicServerId, artistId]);
|
||||||
|
|
||||||
// Last.fm track info (per-track)
|
// Last.fm track info (per-track)
|
||||||
const lfmTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}` : '';
|
const lfmTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}` : '';
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
+14
-9
@@ -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 type { Track } from './store/playerStoreTypes';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
@@ -73,7 +74,8 @@ async function runWorker() {
|
|||||||
try {
|
try {
|
||||||
while (pendingQueue.length > 0) {
|
while (pendingQueue.length > 0) {
|
||||||
const auth = useAuthStore.getState();
|
const auth = useAuthStore.getState();
|
||||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
const playbackSid = getPlaybackServerId();
|
||||||
|
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) {
|
||||||
hotCacheFrontendDebug({
|
hotCacheFrontendDebug({
|
||||||
event: 'prefetch-worker-stop',
|
event: 'prefetch-worker-stop',
|
||||||
reason: 'auth-disabled-or-logged-out',
|
reason: 'auth-disabled-or-logged-out',
|
||||||
@@ -153,7 +155,7 @@ async function runWorker() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildStreamUrl(job.trackId);
|
const url = buildStreamUrlForServer(job.serverId, job.trackId);
|
||||||
try {
|
try {
|
||||||
const customDir = auth.hotCacheDownloadDir || null;
|
const customDir = auth.hotCacheDownloadDir || null;
|
||||||
hotCacheFrontendDebug({ event: 'prefetch-invoke', trackId: job.trackId });
|
hotCacheFrontendDebug({ event: 'prefetch-invoke', trackId: job.trackId });
|
||||||
@@ -173,7 +175,7 @@ async function runWorker() {
|
|||||||
fresh.queue,
|
fresh.queue,
|
||||||
fresh.queueIndex,
|
fresh.queueIndex,
|
||||||
maxAfter,
|
maxAfter,
|
||||||
authAfter.activeServerId ?? '',
|
getPlaybackServerId(),
|
||||||
authAfter.hotCacheDownloadDir || null,
|
authAfter.hotCacheDownloadDir || null,
|
||||||
);
|
);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -188,7 +190,8 @@ async function runWorker() {
|
|||||||
|
|
||||||
function scheduleReplan() {
|
function scheduleReplan() {
|
||||||
const auth = useAuthStore.getState();
|
const auth = useAuthStore.getState();
|
||||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
const playbackSid = getPlaybackServerId();
|
||||||
|
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) {
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = null;
|
debounceTimer = null;
|
||||||
@@ -206,9 +209,10 @@ function scheduleReplan() {
|
|||||||
|
|
||||||
async function replanNow() {
|
async function replanNow() {
|
||||||
const auth = useAuthStore.getState();
|
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 maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
|
||||||
const customDir = auth.hotCacheDownloadDir || null;
|
const customDir = auth.hotCacheDownloadDir || null;
|
||||||
if (maxBytes <= 0) return;
|
if (maxBytes <= 0) return;
|
||||||
@@ -286,8 +290,9 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) {
|
if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) {
|
||||||
const left = (prevQ as Track[])[prevIdx];
|
const left = (prevQ as Track[])[prevIdx];
|
||||||
const a = useAuthStore.getState();
|
const a = useAuthStore.getState();
|
||||||
if (left && a.activeServerId) {
|
const graceSid = getPlaybackServerId();
|
||||||
bumpHotCachePreviousTrackGrace(left.id, a.activeServerId, a.hotCacheDebounceSec);
|
if (left && graceSid) {
|
||||||
|
bumpHotCachePreviousTrackGrace(left.id, graceSid, a.hotCacheDebounceSec);
|
||||||
scheduleEvictAfterPreviousGrace();
|
scheduleEvictAfterPreviousGrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: 'Warteschlangen-Link kopieren',
|
shareQueue: 'Warteschlangen-Link kopieren',
|
||||||
shareQueueEmpty: 'Die Warteschlange ist leer — nichts zu teilen.',
|
shareQueueEmpty: 'Die Warteschlange ist leer — nichts zu teilen.',
|
||||||
emptyQueue: 'Die Warteschlange ist leer.',
|
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',
|
trackSingular: 'Titel',
|
||||||
trackPlural: 'Titel',
|
trackPlural: 'Titel',
|
||||||
showRemaining: 'Restzeit anzeigen',
|
showRemaining: 'Restzeit anzeigen',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: 'Copy queue share link',
|
shareQueue: 'Copy queue share link',
|
||||||
shareQueueEmpty: 'The queue is empty — nothing to share.',
|
shareQueueEmpty: 'The queue is empty — nothing to share.',
|
||||||
emptyQueue: 'The queue is empty.',
|
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',
|
trackSingular: 'track',
|
||||||
trackPlural: 'tracks',
|
trackPlural: 'tracks',
|
||||||
showRemaining: 'Show remaining time',
|
showRemaining: 'Show remaining time',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: 'Copiar enlace de la cola',
|
shareQueue: 'Copiar enlace de la cola',
|
||||||
shareQueueEmpty: 'La cola está vacía — no hay nada que compartir.',
|
shareQueueEmpty: 'La cola está vacía — no hay nada que compartir.',
|
||||||
emptyQueue: 'La cola está vacía.',
|
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',
|
trackSingular: 'pista',
|
||||||
trackPlural: 'pistas',
|
trackPlural: 'pistas',
|
||||||
showRemaining: 'Mostrar tiempo restante',
|
showRemaining: 'Mostrar tiempo restante',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: 'Copier le lien de la file d’attente',
|
shareQueue: 'Copier le lien de la file d’attente',
|
||||||
shareQueueEmpty: 'La file d’attente est vide — rien à partager.',
|
shareQueueEmpty: 'La file d’attente est vide — rien à partager.',
|
||||||
emptyQueue: 'La file d\'attente est vide.',
|
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',
|
trackSingular: 'piste',
|
||||||
trackPlural: 'pistes',
|
trackPlural: 'pistes',
|
||||||
showRemaining: 'Afficher le temps restant',
|
showRemaining: 'Afficher le temps restant',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: 'Kopiér lenke til kø',
|
shareQueue: 'Kopiér lenke til kø',
|
||||||
shareQueueEmpty: 'Køen er tom — ingenting å dele.',
|
shareQueueEmpty: 'Køen er tom — ingenting å dele.',
|
||||||
emptyQueue: 'Køen er tom.',
|
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',
|
trackSingular: 'spor',
|
||||||
trackPlural: 'spor',
|
trackPlural: 'spor',
|
||||||
showRemaining: 'Vis gjenværende tid',
|
showRemaining: 'Vis gjenværende tid',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: 'Wachtrij-deellink kopiëren',
|
shareQueue: 'Wachtrij-deellink kopiëren',
|
||||||
shareQueueEmpty: 'De wachtrij is leeg — niets om te delen.',
|
shareQueueEmpty: 'De wachtrij is leeg — niets om te delen.',
|
||||||
emptyQueue: 'De wachtrij is leeg.',
|
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',
|
trackSingular: 'nummer',
|
||||||
trackPlural: 'nummers',
|
trackPlural: 'nummers',
|
||||||
showRemaining: 'Resterende tijd tonen',
|
showRemaining: 'Resterende tijd tonen',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: 'Copiază link-ul distribuirii cozii',
|
shareQueue: 'Copiază link-ul distribuirii cozii',
|
||||||
shareQueueEmpty: 'Coada este goală — nimic de distribuit.',
|
shareQueueEmpty: 'Coada este goală — nimic de distribuit.',
|
||||||
emptyQueue: 'Coada este goală.',
|
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ă',
|
trackSingular: 'piesă',
|
||||||
trackPlural: 'piese',
|
trackPlural: 'piese',
|
||||||
showRemaining: 'Arată timpul rămas',
|
showRemaining: 'Arată timpul rămas',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: 'Копировать ссылку на очередь',
|
shareQueue: 'Копировать ссылку на очередь',
|
||||||
shareQueueEmpty: 'Очередь пуста — нечем поделиться.',
|
shareQueueEmpty: 'Очередь пуста — нечем поделиться.',
|
||||||
emptyQueue: 'Очередь пуста.',
|
emptyQueue: 'Очередь пуста.',
|
||||||
|
crossServerEnqueueBlocked: 'Треки с другого сервера нельзя добавить в текущую очередь. Завершите или очистите очередь.',
|
||||||
trackSingular: 'трек',
|
trackSingular: 'трек',
|
||||||
trackPlural: 'треков',
|
trackPlural: 'треков',
|
||||||
showRemaining: 'Осталось',
|
showRemaining: 'Осталось',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const queue = {
|
|||||||
shareQueue: '复制队列分享链接',
|
shareQueue: '复制队列分享链接',
|
||||||
shareQueueEmpty: '队列为空,无法分享。',
|
shareQueueEmpty: '队列为空,无法分享。',
|
||||||
emptyQueue: '队列为空。',
|
emptyQueue: '队列为空。',
|
||||||
|
crossServerEnqueueBlocked: '无法将其他服务器的曲目加入当前播放队列。请先清空或结束当前队列。',
|
||||||
trackSingular: '首曲目',
|
trackSingular: '首曲目',
|
||||||
trackPlural: '首曲目',
|
trackPlural: '首曲目',
|
||||||
showRemaining: '显示剩余时间',
|
showRemaining: '显示剩余时间',
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||||
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||||
import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Music, ExternalLink, Cast, Users, Radio, Clock, SkipForward, Info, Calendar, Disc3, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
|
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';
|
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
||||||
@@ -43,8 +45,9 @@ import { useNowPlayingStarLove } from '../hooks/useNowPlayingStarLove';
|
|||||||
|
|
||||||
export default function NowPlaying() {
|
export default function NowPlaying() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const stableNavigate = usePlaybackLibraryNavigate();
|
||||||
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
const subsonicReady = useEnsurePlaybackServerOnMount();
|
||||||
|
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
|
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||||
@@ -78,6 +81,8 @@ export default function NowPlaying() {
|
|||||||
songId, artistId, albumId, artistName,
|
songId, artistId, albumId, artistName,
|
||||||
enableBandsintown, audiomuseNavidromeEnabled,
|
enableBandsintown, audiomuseNavidromeEnabled,
|
||||||
lastfmUsername, currentTrack,
|
lastfmUsername, currentTrack,
|
||||||
|
subsonicServerId: activeServerId,
|
||||||
|
fetchEnabled: subsonicReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Star + Last.fm love + their toggle callbacks
|
// Star + Last.fm love + their toggle callbacks
|
||||||
@@ -91,9 +96,7 @@ export default function NowPlaying() {
|
|||||||
showLyrics();
|
showLyrics();
|
||||||
}, [isQueueVisible, toggleQueue, showLyrics]);
|
}, [isQueueVisible, toggleQueue, showLyrics]);
|
||||||
|
|
||||||
// Cover
|
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(currentTrack?.coverArt, 800);
|
||||||
const coverFetchUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
|
||||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
|
||||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||||
|
|
||||||
const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : '';
|
const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : '';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { reportNowPlaying } from '../api/subsonicScrobble';
|
import { reportNowPlaying } from '../api/subsonicScrobble';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { getPlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl';
|
import { getPlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import {
|
import {
|
||||||
@@ -129,9 +130,9 @@ export function applyQueueHistorySnapshot(
|
|||||||
PlayerState,
|
PlayerState,
|
||||||
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
|
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
|
||||||
>);
|
>);
|
||||||
const authSnap = useAuthStore.getState();
|
const playbackSid = getPlaybackServerId();
|
||||||
const playbackSourceUndo = nextTrack
|
const playbackSourceUndo = nextTrack
|
||||||
? getPlaybackSourceKind(nextTrack.id, authSnap.activeServerId ?? '', null)
|
? getPlaybackSourceKind(nextTrack.id, playbackSid, null)
|
||||||
: null;
|
: null;
|
||||||
const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null
|
const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null
|
||||||
? prior.currentPlaybackSource
|
? prior.currentPlaybackSource
|
||||||
@@ -186,7 +187,7 @@ export function applyQueueHistorySnapshot(
|
|||||||
|
|
||||||
if (!keepPlaybackFromPrior) {
|
if (!keepPlaybackFromPrior) {
|
||||||
const { nowPlayingEnabled: npUndo } = useAuthStore.getState();
|
const { nowPlayingEnabled: npUndo } = useAuthStore.getState();
|
||||||
if (npUndo) reportNowPlaying(nextTrack.id);
|
if (npUndo) reportNowPlaying(nextTrack.id, getPlaybackServerId());
|
||||||
|
|
||||||
queueUndoRestoreAudioEngine({
|
queueUndoRestoreAudioEngine({
|
||||||
generation: gen,
|
generation: gen,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../
|
|||||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||||
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
|
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
|
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
|
||||||
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||||
import { showToast } from '../utils/ui/toast';
|
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)
|
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||||
if (progress >= 0.5 && !store.scrobbled) {
|
if (progress >= 0.5 && !store.scrobbled) {
|
||||||
usePlayerStore.setState({ scrobbled: true });
|
usePlayerStore.setState({ scrobbled: true });
|
||||||
scrobbleSong(track.id, Date.now());
|
scrobbleSong(track.id, Date.now(), getPlaybackServerId());
|
||||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||||
if (scrobblingEnabled && lastfmSessionKey) {
|
if (scrobblingEnabled && lastfmSessionKey) {
|
||||||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||||||
@@ -236,7 +237,7 @@ export function handleAudioProgress(current_time: number, duration: number): voi
|
|||||||
const shouldBytePreloadForGaplessBackup =
|
const shouldBytePreloadForGaplessBackup =
|
||||||
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||||||
|
|
||||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
const serverId = getPlaybackServerId();
|
||||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||||
|
|
||||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||||
@@ -322,7 +323,7 @@ export function handleAudioEnded(): void {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
if (repeatMode === 'one' && currentTrack) {
|
if (repeatMode === 'one' && currentTrack) {
|
||||||
const authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
const repeatPromoteSid = authState.activeServerId;
|
const repeatPromoteSid = getPlaybackServerId();
|
||||||
if (authState.hotCacheEnabled && repeatPromoteSid) {
|
if (authState.hotCacheEnabled && repeatPromoteSid) {
|
||||||
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
|
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
|
||||||
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
|
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
|
||||||
@@ -373,7 +374,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
|||||||
|
|
||||||
if (!nextTrack) return;
|
if (!nextTrack) return;
|
||||||
|
|
||||||
const switchServerId = useAuthStore.getState().activeServerId ?? '';
|
const switchServerId = getPlaybackServerId();
|
||||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||||
|
|
||||||
@@ -403,7 +404,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
|||||||
|
|
||||||
// Report Now Playing to Navidrome + Last.fm
|
// Report Now Playing to Navidrome + Last.fm
|
||||||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||||
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id);
|
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id, getPlaybackServerId());
|
||||||
if (lastfmSessionKey) {
|
if (lastfmSessionKey) {
|
||||||
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
||||||
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
||||||
@@ -415,7 +416,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
syncQueueToServer(queue, nextTrack, 0);
|
syncQueueToServer(queue, nextTrack, 0);
|
||||||
touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? '');
|
touchHotCacheOnPlayback(nextTrack.id, getPlaybackServerId());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleAudioError(message: string): void {
|
export function handleAudioError(message: string): void {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { buildCoverArtUrl } from '../../api/subsonicStreamUrl';
|
import { playbackCoverArtForId } from '../../utils/playback/playbackServer';
|
||||||
import { usePlayerStore } from '../playerStore';
|
import { usePlayerStore } from '../playerStore';
|
||||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../playbackProgress';
|
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../playbackProgress';
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function setupMprisSync(): () => void {
|
|||||||
prevTrackId = currentTrack.id;
|
prevTrackId = currentTrack.id;
|
||||||
prevRadioId = null;
|
prevRadioId = null;
|
||||||
const coverUrl = currentTrack.coverArt
|
const coverUrl = currentTrack.coverArt
|
||||||
? buildCoverArtUrl(currentTrack.coverArt, 512)
|
? playbackCoverArtForId(currentTrack.coverArt, 512).src
|
||||||
: undefined;
|
: undefined;
|
||||||
invoke('mpris_set_metadata', {
|
invoke('mpris_set_metadata', {
|
||||||
title: currentTrack.title,
|
title: currentTrack.title,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { AuthState } from './authStoreTypes';
|
import type { AuthState } from './authStoreTypes';
|
||||||
import { generateId } from './authStoreHelpers';
|
import { generateId } from './authStoreHelpers';
|
||||||
|
import { usePlayerStore } from './playerStore';
|
||||||
|
import { clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
||||||
|
|
||||||
type SetState = (
|
type SetState = (
|
||||||
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
|
||||||
@@ -38,6 +40,9 @@ export function createServerProfileActions(set: SetState): Pick<
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeServer: (id) => {
|
removeServer: (id) => {
|
||||||
|
if (usePlayerStore.getState().queueServerId === id) {
|
||||||
|
clearQueueServerForPlayback();
|
||||||
|
}
|
||||||
set(s => {
|
set(s => {
|
||||||
const newServers = s.servers.filter(srv => srv.id !== id);
|
const newServers = s.servers.filter(srv => srv.id !== id);
|
||||||
const switchedAway = s.activeServerId === id;
|
const switchedAway = s.activeServerId === id;
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
*/
|
*/
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
import { usePlayerStore } from './playerStore';
|
||||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||||
|
import { resetPlayerStore } from '@/test/helpers/storeReset';
|
||||||
import { makeServer } from '@/test/helpers/factories';
|
import { makeServer } from '@/test/helpers/factories';
|
||||||
|
|
||||||
function addThree(): { a: string; b: string; c: string } {
|
function addThree(): { a: string; b: string; c: string } {
|
||||||
@@ -24,6 +26,7 @@ function addThree(): { a: string; b: string; c: string } {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAuthStore();
|
resetAuthStore();
|
||||||
|
resetPlayerStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addServer / updateServer', () => {
|
describe('addServer / updateServer', () => {
|
||||||
@@ -122,6 +125,18 @@ describe('removeServer', () => {
|
|||||||
useAuthStore.getState().removeServer(a);
|
useAuthStore.getState().removeServer(a);
|
||||||
expect(useAuthStore.getState().activeServerId).toBe(b);
|
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', () => {
|
describe('selectors — getBaseUrl / getActiveServer', () => {
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { lastfmGetTrackLoved, lastfmUpdateNowPlaying } from '../api/lastfm';
|
|||||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||||
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
||||||
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
|
||||||
|
import {
|
||||||
|
bindQueueServerForPlayback,
|
||||||
|
getPlaybackServerId,
|
||||||
|
shouldBindQueueServerForPlay,
|
||||||
|
} from '../utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
@@ -173,6 +178,9 @@ export function runPlayTrack(
|
|||||||
setSeekFallbackVisualTarget(null);
|
setSeekFallbackVisualTarget(null);
|
||||||
}
|
}
|
||||||
const newQueue = queue ?? state.queue;
|
const newQueue = queue ?? state.queue;
|
||||||
|
if (shouldBindQueueServerForPlay(state.queue, newQueue, queue)) {
|
||||||
|
bindQueueServerForPlayback();
|
||||||
|
}
|
||||||
// Prefer an explicit target index from the caller (next/previous/queue-row
|
// Prefer an explicit target index from the caller (next/previous/queue-row
|
||||||
// click already know the exact slot). `findIndex` returns the *first*
|
// click already know the exact slot). `findIndex` returns the *first*
|
||||||
// matching id, which jumps backwards when the queue contains the same
|
// matching id, which jumps backwards when the queue contains the same
|
||||||
@@ -207,18 +215,19 @@ export function runPlayTrack(
|
|||||||
prevTrack
|
prevTrack
|
||||||
&& sameQueueTrackId(prevTrack.id, track.id)
|
&& sameQueueTrackId(prevTrack.id, track.id)
|
||||||
&& authState.hotCacheEnabled
|
&& authState.hotCacheEnabled
|
||||||
&& authState.activeServerId,
|
&& getPlaybackServerId(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const runPlayTrackBody = () => {
|
const runPlayTrackBody = () => {
|
||||||
const authStateNow = useAuthStore.getState();
|
const authStateNow = useAuthStore.getState();
|
||||||
const url = resolvePlaybackUrl(track.id, authStateNow.activeServerId ?? '');
|
const playbackSid = getPlaybackServerId();
|
||||||
|
const url = resolvePlaybackUrl(track.id, playbackSid);
|
||||||
recordEnginePlayUrl(track.id, url);
|
recordEnginePlayUrl(track.id, url);
|
||||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||||
const keepPreloadHint = preloadedTrackId === track.id;
|
const keepPreloadHint = preloadedTrackId === track.id;
|
||||||
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
||||||
track.id,
|
track.id,
|
||||||
authStateNow.activeServerId ?? '',
|
playbackSid,
|
||||||
url,
|
url,
|
||||||
);
|
);
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
@@ -255,7 +264,7 @@ export function runPlayTrack(
|
|||||||
&& !sameQueueTrackId(prevTrack.id, track.id)
|
&& !sameQueueTrackId(prevTrack.id, track.id)
|
||||||
&& authStateNow.hotCacheEnabled
|
&& authStateNow.hotCacheEnabled
|
||||||
) {
|
) {
|
||||||
const prevPromoteSid = authStateNow.activeServerId;
|
const prevPromoteSid = getPlaybackServerId();
|
||||||
if (prevPromoteSid) {
|
if (prevPromoteSid) {
|
||||||
void promoteCompletedStreamToHotCache(
|
void promoteCompletedStreamToHotCache(
|
||||||
prevTrack,
|
prevTrack,
|
||||||
@@ -326,7 +335,7 @@ export function runPlayTrack(
|
|||||||
|
|
||||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||||
if (npEnabled) reportNowPlaying(track.id);
|
if (npEnabled) reportNowPlaying(track.id, getPlaybackServerId());
|
||||||
if (lfmKey) {
|
if (lfmKey) {
|
||||||
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
||||||
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
||||||
@@ -338,10 +347,10 @@ export function runPlayTrack(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
syncQueueToServer(newQueue, track, initialTime);
|
syncQueueToServer(newQueue, track, initialTime);
|
||||||
touchHotCacheOnPlayback(track.id, authStateNow.activeServerId ?? '');
|
touchHotCacheOnPlayback(track.id, playbackSid);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hotPromoteSid = authState.activeServerId;
|
const hotPromoteSid = getPlaybackServerId();
|
||||||
if (needSameTrackHotPromote && hotPromoteSid) {
|
if (needSameTrackHotPromote && hotPromoteSid) {
|
||||||
void promoteCompletedStreamToHotCache(
|
void promoteCompletedStreamToHotCache(
|
||||||
track,
|
track,
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ vi.mock('@/api/subsonicScrobble', () => ({
|
|||||||
reportNowPlaying: vi.fn(async () => undefined),
|
reportNowPlaying: vi.fn(async () => undefined),
|
||||||
scrobbleSong: 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', () => ({
|
vi.mock('@/api/subsonicStarRating', () => ({
|
||||||
setRating: vi.fn(async () => undefined),
|
setRating: vi.fn(async () => undefined),
|
||||||
probeEntityRatingSupport: vi.fn(async () => 'track_only'),
|
probeEntityRatingSupport: vi.fn(async () => 'track_only'),
|
||||||
@@ -116,6 +126,7 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
[t1.id, t2.id, t3.id],
|
[t1.id, t2.id, t3.id],
|
||||||
t2.id,
|
t2.id,
|
||||||
12345, // Math.floor(12.345 * 1000)
|
12345, // Math.floor(12.345 * 1000)
|
||||||
|
'srv-test',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
currentPlaybackSource: null,
|
currentPlaybackSource: null,
|
||||||
enginePreloadedTrackId: null,
|
enginePreloadedTrackId: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
queueServerId: null,
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -81,6 +82,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
repeatMode: state.repeatMode,
|
repeatMode: state.repeatMode,
|
||||||
currentTrack: state.currentTrack,
|
currentTrack: state.currentTrack,
|
||||||
queue: state.queue,
|
queue: state.queue,
|
||||||
|
queueServerId: state.queueServerId,
|
||||||
queueIndex: state.queueIndex,
|
queueIndex: state.queueIndex,
|
||||||
isQueueVisible: state.isQueueVisible,
|
isQueueVisible: state.isQueueVisible,
|
||||||
// currentTime is intentionally NOT persisted here.
|
// currentTime is intentionally NOT persisted here.
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface PlayerState {
|
|||||||
*/
|
*/
|
||||||
enginePreloadedTrackId: string | null;
|
enginePreloadedTrackId: string | null;
|
||||||
queue: Track[];
|
queue: Track[];
|
||||||
|
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */
|
||||||
|
queueServerId: string | null;
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
progress: number; // 0–1
|
progress: number; // 0–1
|
||||||
@@ -171,8 +173,20 @@ export interface PlayerState {
|
|||||||
* list/grid to copy a `composer` link from the otherwise artist-typed
|
* list/grid to copy a `composer` link from the otherwise artist-typed
|
||||||
* context menu, so paste lands on /composer/:id instead of /artist/:id. */
|
* context menu, so paste lands on /composer/:id instead of /artist/:id. */
|
||||||
shareKindOverride?: 'track' | 'album' | 'artist' | 'composer';
|
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;
|
closeContextMenu: () => void;
|
||||||
|
|
||||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ const { invokeMock, setEntryMock, buildStreamUrlMock } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
|
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', () => ({
|
vi.mock('./hotCacheStore', () => ({
|
||||||
useHotCacheStore: { getState: () => ({ setEntry: setEntryMock }) },
|
useHotCacheStore: { getState: () => ({ setEntry: setEntryMock }) },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { buildStreamUrl } from '../api/subsonicStreamUrl';
|
import { buildStreamUrlForServer } from '../api/subsonicStreamUrl';
|
||||||
import type { Track } from './playerStoreTypes';
|
import type { Track } from './playerStoreTypes';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useHotCacheStore } from './hotCacheStore';
|
import { useHotCacheStore } from './hotCacheStore';
|
||||||
@@ -25,7 +25,7 @@ export async function promoteCompletedStreamToHotCache(
|
|||||||
{
|
{
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
serverId,
|
serverId,
|
||||||
url: buildStreamUrl(track.id),
|
url: buildStreamUrlForServer(serverId, track.id),
|
||||||
suffix: track.suffix || 'mp3',
|
suffix: track.suffix || 'mp3',
|
||||||
customDir,
|
customDir,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,12 +17,21 @@ import {
|
|||||||
import { clearSeekDebounce } from './seekDebounce';
|
import { clearSeekDebounce } from './seekDebounce';
|
||||||
import { clearSeekFallbackRetry } from './seekFallbackState';
|
import { clearSeekFallbackRetry } from './seekFallbackState';
|
||||||
import { clearSeekTarget } from './seekTargetState';
|
import { clearSeekTarget } from './seekTargetState';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
||||||
|
import { showToast } from '../utils/ui/toast';
|
||||||
|
|
||||||
type SetState = (
|
type SetState = (
|
||||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||||
) => void;
|
) => void;
|
||||||
type GetState = () => PlayerState;
|
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
|
* Eleven queue-mutation actions. Shared invariant: every action except
|
||||||
* `setRadioArtistId` pushes a queue-undo snapshot and calls
|
* `setRadioArtistId` pushes a queue-undo snapshot and calls
|
||||||
@@ -44,6 +53,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
enqueue: (tracks, _orbitConfirmed = false) => {
|
enqueue: (tracks, _orbitConfirmed = false) => {
|
||||||
|
if (blockCrossServerEnqueue()) return;
|
||||||
if (!_orbitConfirmed && tracks.length > 1) {
|
if (!_orbitConfirmed && tracks.length > 1) {
|
||||||
void orbitBulkGuard(tracks.length).then(ok => {
|
void orbitBulkGuard(tracks.length).then(ok => {
|
||||||
if (ok) get().enqueue(tracks, true);
|
if (ok) get().enqueue(tracks, true);
|
||||||
@@ -129,6 +139,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
},
|
},
|
||||||
|
|
||||||
enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => {
|
enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => {
|
||||||
|
if (blockCrossServerEnqueue()) return;
|
||||||
if (!_orbitConfirmed && tracks.length > 1) {
|
if (!_orbitConfirmed && tracks.length > 1) {
|
||||||
void orbitBulkGuard(tracks.length).then(ok => {
|
void orbitBulkGuard(tracks.length).then(ok => {
|
||||||
if (ok) get().enqueueAt(tracks, insertIndex, true);
|
if (ok) get().enqueueAt(tracks, insertIndex, true);
|
||||||
@@ -154,6 +165,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
|
|
||||||
playNext: (tracks) => {
|
playNext: (tracks) => {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
|
if (blockCrossServerEnqueue()) return;
|
||||||
const state = get();
|
const state = get();
|
||||||
const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const }));
|
const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const }));
|
||||||
if (!state.currentTrack) {
|
if (!state.currentTrack) {
|
||||||
@@ -197,6 +209,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
clearSeekDebounce(); clearSeekTarget();
|
clearSeekDebounce(); clearSeekTarget();
|
||||||
clearRadioSessionSeenIds();
|
clearRadioSessionSeenIds();
|
||||||
setCurrentRadioArtistId(null);
|
setCurrentRadioArtistId(null);
|
||||||
|
clearQueueServerForPlayback();
|
||||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
syncQueueToServer([], null, 0);
|
syncQueueToServer([], null, 0);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import type { Track } from './playerStoreTypes';
|
import type { Track } from './playerStoreTypes';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
|
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: {
|
playerState: {
|
||||||
queue: [] as Track[],
|
queue: [] as Track[],
|
||||||
currentTrack: null as Track | null,
|
currentTrack: null as Track | null,
|
||||||
@@ -18,6 +18,9 @@ const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../api/subsonicPlayQueue', () => ({ savePlayQueue: savePlayQueueMock }));
|
vi.mock('../api/subsonicPlayQueue', () => ({ savePlayQueue: savePlayQueueMock }));
|
||||||
|
vi.mock('../utils/playback/playbackServer', () => ({
|
||||||
|
getPlaybackServerId: () => 'srv-a',
|
||||||
|
}));
|
||||||
vi.mock('./playerStore', () => ({
|
vi.mock('./playerStore', () => ({
|
||||||
usePlayerStore: { getState: () => playerState },
|
usePlayerStore: { getState: () => playerState },
|
||||||
}));
|
}));
|
||||||
@@ -65,7 +68,7 @@ describe('syncQueueToServer (debounced)', () => {
|
|||||||
it('fires once after 5 s with id list + current id + position in ms', () => {
|
it('fires once after 5 s with id list + current id + position in ms', () => {
|
||||||
syncQueueToServer(queue, queue[0], 30);
|
syncQueueToServer(queue, queue[0], 30);
|
||||||
vi.advanceTimersByTime(5000);
|
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', () => {
|
it('cancels the previous timer when called again before fire', () => {
|
||||||
@@ -74,7 +77,7 @@ describe('syncQueueToServer (debounced)', () => {
|
|||||||
syncQueueToServer([...queue, track('c')], queue[0], 20);
|
syncQueueToServer([...queue, track('c')], queue[0], 20);
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
|
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', () => {
|
it('caps the queue at 1000 ids', () => {
|
||||||
@@ -91,7 +94,7 @@ describe('syncQueueToServer (debounced)', () => {
|
|||||||
describe('flushQueueSyncToServer (immediate)', () => {
|
describe('flushQueueSyncToServer (immediate)', () => {
|
||||||
it('fires synchronously with no debounce', async () => {
|
it('fires synchronously with no debounce', async () => {
|
||||||
await flushQueueSyncToServer([track('a')], track('a'), 12);
|
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 () => {
|
it('cancels a pending debounced sync first', async () => {
|
||||||
@@ -126,7 +129,7 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
playerState.currentTrack = playerState.queue[0];
|
playerState.currentTrack = playerState.queue[0];
|
||||||
progressSnapshot.currentTime = 42;
|
progressSnapshot.currentTime = 42;
|
||||||
await flushPlayQueuePosition();
|
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 () => {
|
it('is a no-op when a radio session is active', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { savePlayQueue } from '../api/subsonicPlayQueue';
|
import { savePlayQueue } from '../api/subsonicPlayQueue';
|
||||||
import type { Track } from './playerStoreTypes';
|
import type { Track } from './playerStoreTypes';
|
||||||
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { getPlaybackProgressSnapshot } from './playbackProgress';
|
import { getPlaybackProgressSnapshot } from './playbackProgress';
|
||||||
import { usePlayerStore } from './playerStore';
|
import { usePlayerStore } from './playerStore';
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +32,8 @@ export function syncQueueToServer(queue: Track[], currentTrack: Track | null, cu
|
|||||||
syncTimeout = null;
|
syncTimeout = null;
|
||||||
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
|
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
|
||||||
const pos = Math.floor(currentTime * 1000);
|
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);
|
console.error('Failed to sync play queue to server', err);
|
||||||
});
|
});
|
||||||
}, SYNC_DEBOUNCE_MS);
|
}, SYNC_DEBOUNCE_MS);
|
||||||
@@ -46,7 +48,8 @@ export function flushQueueSyncToServer(queue: Track[], currentTrack: Track | nul
|
|||||||
lastQueueHeartbeatAt = Date.now();
|
lastQueueHeartbeatAt = Date.now();
|
||||||
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
|
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(t => t.id);
|
||||||
const pos = Math.floor(currentTime * 1000);
|
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);
|
console.error('Failed to flush play queue to server', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Track } from './playerStoreTypes';
|
import type { Track } from './playerStoreTypes';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||||
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
@@ -38,10 +39,11 @@ export function queueUndoRestoreAudioEngine(opts: {
|
|||||||
isReplayGainActive(), authState.replayGainMode,
|
isReplayGainActive(), authState.replayGainMode,
|
||||||
);
|
);
|
||||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
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);
|
recordEnginePlayUrl(track.id, url);
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, authState.activeServerId ?? '', url),
|
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackSid, url),
|
||||||
});
|
});
|
||||||
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
@@ -91,5 +93,5 @@ export function queueUndoRestoreAudioEngine(opts: {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setDeferHotCachePrefetch(false);
|
setDeferHotCachePrefetch(false);
|
||||||
});
|
});
|
||||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
touchHotCacheOnPlayback(track.id, getPlaybackServerId());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getSong } from '../api/subsonicLibrary';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { estimateLivePosition } from '../api/orbit';
|
import { estimateLivePosition } from '../api/orbit';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||||
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
@@ -117,7 +118,7 @@ export function runResume(set: SetState, get: GetState): void {
|
|||||||
invoke('audio_resume').catch(console.error);
|
invoke('audio_resume').catch(console.error);
|
||||||
setIsAudioPaused(false);
|
setIsAudioPaused(false);
|
||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
|
touchHotCacheOnPlayback(currentTrack.id, getPlaybackServerId());
|
||||||
} else {
|
} else {
|
||||||
// Engine has no loaded paused stream (app relaunch, or track ended and user
|
// Engine has no loaded paused stream (app relaunch, or track ended and user
|
||||||
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any
|
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any
|
||||||
@@ -128,7 +129,7 @@ export function runResume(set: SetState, get: GetState): void {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const authHot = useAuthStore.getState();
|
const authHot = useAuthStore.getState();
|
||||||
const resumePromoteSid = authHot.activeServerId;
|
const resumePromoteSid = getPlaybackServerId();
|
||||||
if (authHot.hotCacheEnabled && resumePromoteSid) {
|
if (authHot.hotCacheEnabled && resumePromoteSid) {
|
||||||
await promoteCompletedStreamToHotCache(
|
await promoteCompletedStreamToHotCache(
|
||||||
currentTrack,
|
currentTrack,
|
||||||
@@ -150,7 +151,7 @@ export function runResume(set: SetState, get: GetState): void {
|
|||||||
isReplayGainActive(), authStateCold.replayGainMode,
|
isReplayGainActive(), authStateCold.replayGainMode,
|
||||||
);
|
);
|
||||||
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
const coldServerId = getPlaybackServerId();
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
||||||
@@ -189,7 +190,7 @@ export function runResume(set: SetState, get: GetState): void {
|
|||||||
isReplayGainActive(), authStateCold.replayGainMode,
|
isReplayGainActive(), authStateCold.replayGainMode,
|
||||||
);
|
);
|
||||||
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
|
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
|
||||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
const coldServerId = getPlaybackServerId();
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
||||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
|
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { isRecoverableSeekError } from '../utils/audio/seekErrors';
|
import { isRecoverableSeekError } from '../utils/audio/seekErrors';
|
||||||
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
|
import { shouldRebindPlaybackToHotCache } from './playbackUrlRouting';
|
||||||
import type { PlayerState } from './playerStoreTypes';
|
import type { PlayerState } from './playerStoreTypes';
|
||||||
@@ -47,8 +48,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
|
|||||||
armSeekDebounce(100, () => {
|
armSeekDebounce(100, () => {
|
||||||
const s0 = get();
|
const s0 = get();
|
||||||
if (!s0.currentTrack) return;
|
if (!s0.currentTrack) return;
|
||||||
const authSeek = useAuthStore.getState();
|
const sidSeek = getPlaybackServerId();
|
||||||
const sidSeek = authSeek.activeServerId ?? '';
|
|
||||||
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
|
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
|
||||||
setSeekFallbackVisualTarget({
|
setSeekFallbackVisualTarget({
|
||||||
trackId: s0.currentTrack.id,
|
trackId: s0.currentTrack.id,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import {
|
|||||||
persistQueueVisibility,
|
persistQueueVisibility,
|
||||||
} from './queueVisibilityStorage';
|
} from './queueVisibilityStorage';
|
||||||
import type { PlayerState } from './playerStoreTypes';
|
import type { PlayerState } from './playerStoreTypes';
|
||||||
|
import {
|
||||||
|
ensurePlaybackServerActive,
|
||||||
|
playbackServerDiffersFromActive,
|
||||||
|
} from '../utils/playback/playbackServer';
|
||||||
|
|
||||||
type SetState = (
|
type SetState = (
|
||||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||||
@@ -41,10 +45,31 @@ export function createUiStateActions(set: SetState): Pick<
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
openContextMenu: (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({
|
set({
|
||||||
contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride },
|
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: () =>
|
closeContextMenu: () =>
|
||||||
set(state => ({
|
set(state => ({
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function initialSnapshot(): MiniSyncPayload {
|
|||||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||||
queue: (s.queue ?? []).map(toMini),
|
queue: (s.queue ?? []).map(toMini),
|
||||||
queueIndex: s.queueIndex ?? 0,
|
queueIndex: s.queueIndex ?? 0,
|
||||||
|
queueServerId: s.queueServerId ?? null,
|
||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
volume: s.volume ?? 1,
|
volume: s.volume ?? 1,
|
||||||
gaplessEnabled: false,
|
gaplessEnabled: false,
|
||||||
@@ -67,7 +68,7 @@ export function initialSnapshot(): MiniSyncPayload {
|
|||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
track: null, queue: [], queueIndex: 0, isPlaying: false,
|
track: null, queue: [], queueIndex: 0, queueServerId: null, isPlaying: false,
|
||||||
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
|
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
|
||||||
infiniteQueueEnabled: false, isMobile: false,
|
infiniteQueueEnabled: false, isMobile: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface MiniSyncPayload {
|
|||||||
track: MiniTrackInfo | null;
|
track: MiniTrackInfo | null;
|
||||||
queue: MiniTrackInfo[];
|
queue: MiniTrackInfo[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
|
queueServerId: string | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
volume: number;
|
volume: number;
|
||||||
gaplessEnabled: boolean;
|
gaplessEnabled: boolean;
|
||||||
@@ -62,6 +63,7 @@ function snapshot(): MiniSyncPayload {
|
|||||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||||
queue: (s.queue ?? []).map(toMini),
|
queue: (s.queue ?? []).map(toMini),
|
||||||
queueIndex: s.queueIndex ?? 0,
|
queueIndex: s.queueIndex ?? 0,
|
||||||
|
queueServerId: s.queueServerId ?? null,
|
||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
volume: s.volume,
|
volume: s.volume,
|
||||||
gaplessEnabled: !!a.gaplessEnabled,
|
gaplessEnabled: !!a.gaplessEnabled,
|
||||||
@@ -93,6 +95,7 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
payload.track?.starred ?? '',
|
payload.track?.starred ?? '',
|
||||||
(payload.track?.artists ?? []).map((a: SubsonicOpenArtistRef) => a.id ?? a.name).join('|'),
|
(payload.track?.artists ?? []).map((a: SubsonicOpenArtistRef) => a.id ?? a.name).join('|'),
|
||||||
payload.queueIndex,
|
payload.queueIndex,
|
||||||
|
payload.queueServerId ?? '',
|
||||||
payload.volume,
|
payload.volume,
|
||||||
payload.gaplessEnabled,
|
payload.gaplessEnabled,
|
||||||
payload.crossfadeEnabled,
|
payload.crossfadeEnabled,
|
||||||
@@ -110,6 +113,7 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|
||||||
|| state.queueIndex !== prev.queueIndex
|
|| state.queueIndex !== prev.queueIndex
|
||||||
|| state.queue !== prev.queue
|
|| state.queue !== prev.queue
|
||||||
|
|| state.queueServerId !== prev.queueServerId
|
||||||
|| state.volume !== prev.volume) {
|
|| state.volume !== prev.volume) {
|
||||||
push();
|
push();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<boolean> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { buildStreamUrl } from '../../api/subsonicStreamUrl';
|
import { buildStreamUrlForServer } from '../../api/subsonicStreamUrl';
|
||||||
import { useOfflineStore } from '../../store/offlineStore';
|
import { useOfflineStore } from '../../store/offlineStore';
|
||||||
import { useHotCacheStore } from '../../store/hotCacheStore';
|
import { useHotCacheStore } from '../../store/hotCacheStore';
|
||||||
|
import { getPlaybackServerId } from './playbackServer';
|
||||||
|
|
||||||
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
|
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
|
||||||
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
|
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
|
||||||
@@ -54,10 +55,11 @@ export function getPlaybackSourceKind(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Offline library → hot playback cache → HTTP stream. */
|
/** Offline library → hot playback cache → HTTP stream. */
|
||||||
export function resolvePlaybackUrl(trackId: string, serverId: string): string {
|
export function resolvePlaybackUrl(trackId: string, serverId?: string): string {
|
||||||
const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId);
|
const sid = serverId && serverId.length > 0 ? serverId : getPlaybackServerId();
|
||||||
|
const offline = useOfflineStore.getState().getLocalUrl(trackId, sid);
|
||||||
if (offline) return offline;
|
if (offline) return offline;
|
||||||
const hot = useHotCacheStore.getState().getLocalUrl(trackId, serverId);
|
const hot = useHotCacheStore.getState().getLocalUrl(trackId, sid);
|
||||||
if (hot) return hot;
|
if (hot) return hot;
|
||||||
return buildStreamUrl(trackId);
|
return buildStreamUrlForServer(sid, trackId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user