mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(queue): choose server for mixed shares
This commit is contained in:
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Playlist creation, smart-playlist editing, radio actions and other destination-sensitive flows select the target server explicitly instead of silently using the active server.
|
||||
* Context menus, ratings, favourites, sharing, offline pins, device sync and Orbit carry the item's owner through the complete action. The same numeric Subsonic ID may now safely exist on several servers at once.
|
||||
* Sharing a mixed-server queue asks which server's tracks to include in the link; single-server queues still copy immediately without an extra prompt.
|
||||
|
||||
## Changed
|
||||
|
||||
|
||||
@@ -15,3 +15,4 @@ export {
|
||||
} from './utils/playback/playbackServer';
|
||||
export { useVolumeToggle } from './hooks/useVolumeToggle';
|
||||
export { sameQueueTrack } from './utils/playback/queueIdentity';
|
||||
export { queueTrackIdsForServerProfile } from './utils/playback/trackServerScope';
|
||||
|
||||
@@ -12,10 +12,6 @@ import { usePlaylistStore } from '@/features/playlist';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { encodeSharePayload } from '@/lib/share/shareLink';
|
||||
import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
|
||||
import { copyTextToClipboard } from '@/lib/server/serverMagicString';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useLyricsStore } from '@/store/lyricsStore';
|
||||
import { LyricsPane } from '@/features/lyrics';
|
||||
@@ -23,6 +19,7 @@ import { NowPlayingInfo } from '@/features/nowPlaying';
|
||||
import { useLuckyMixStore } from '@/features/randomMix';
|
||||
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
|
||||
import { SavePlaylistModal } from '@/features/queue/components/SavePlaylistModal';
|
||||
import { ShareQueueModal } from '@/features/queue/components/ShareQueueModal';
|
||||
import { LoadPlaylistModal } from '@/features/queue/components/LoadPlaylistModal';
|
||||
import { QueueHeader } from '@/features/queue/components/QueueHeader';
|
||||
import { QueueCurrentTrack } from '@/features/queue/components/QueueCurrentTrack';
|
||||
@@ -32,14 +29,11 @@ import { QueueToolbar } from '@/features/queue/components/QueueToolbar';
|
||||
import { QueueList } from '@/features/queue/components/QueueList';
|
||||
import { QueueTabBar } from '@/features/queue/components/QueueTabBar';
|
||||
import { useQueueAutoScroll } from '@/features/queue/hooks/useQueueAutoScroll';
|
||||
import { useQueueShare } from '@/features/queue/hooks/useQueueShare';
|
||||
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '@/features/playback/hooks/useTimelinePlayHistory';
|
||||
import { buildTimelineDisplayRows } from '@/features/playback/utils/buildTimelineDisplayRows';
|
||||
import {
|
||||
activeServerQueueTrackIds,
|
||||
queueTrackIdsForServerProfile,
|
||||
} from '@/features/playback/utils/playback/trackServerScope';
|
||||
import { queueTrackIdsForServerProfile } from '@/features/playback';
|
||||
import { isActivePublicShareQueue } from '@/lib/share/navidromePublicSharePlayback';
|
||||
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
|
||||
|
||||
export default function QueuePanel() {
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
@@ -186,13 +180,20 @@ function QueuePanelHostOrSolo() {
|
||||
suppressNextAutoScrollRef,
|
||||
});
|
||||
|
||||
const queuePlaylistServerOptions = useMemo(() => servers
|
||||
.filter(server => queueTrackIdsForServerProfile(queueItems, server.id).length > 0)
|
||||
.map(server => ({ id: server.id, label: serverListDisplayLabel(server, servers) })), [queueItems, servers]);
|
||||
const defaultQueuePlaylistServerId = activeServerId
|
||||
&& queuePlaylistServerOptions.some(server => server.id === activeServerId)
|
||||
? activeServerId
|
||||
: queuePlaylistServerOptions[0]?.id ?? '';
|
||||
const {
|
||||
serverOptions: queueServerOptions,
|
||||
defaultServerId: defaultQueueServerId,
|
||||
shareModalOpen,
|
||||
handleCopy: handleCopyQueueShare,
|
||||
shareForServer,
|
||||
closeShareModal,
|
||||
} = useQueueShare({
|
||||
queueItems,
|
||||
servers,
|
||||
activeServerId,
|
||||
publicShareQueueActive,
|
||||
navidromePublicSharePageUrl,
|
||||
});
|
||||
const [activePlaylist, setActivePlaylist] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -232,7 +233,7 @@ function QueuePanelHostOrSolo() {
|
||||
if (activePlaylistSaveGenerationRef.current === generation) setSaveState('idle');
|
||||
}
|
||||
} else {
|
||||
if (queuePlaylistServerOptions.length === 0) return;
|
||||
if (queueServerOptions.length === 0) return;
|
||||
setSaveModalOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -248,35 +249,7 @@ function QueuePanelHostOrSolo() {
|
||||
clearQueue();
|
||||
setActivePlaylist(null);
|
||||
setSaveModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyQueueShare = async () => {
|
||||
if (publicShareQueueActive) {
|
||||
const pageUrl = navidromePublicSharePageUrl?.trim();
|
||||
if (!pageUrl) {
|
||||
showToast(t('queue.shareNavidromePublicMissing'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
const ok = await copyTextToClipboard(pageUrl);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
const ids = activeServerQueueTrackIds(queueItems);
|
||||
if (ids.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
// Queue share goes to remote recipients — use the share URL, not the
|
||||
// connect URL the active app is currently bound to (would leak the LAN
|
||||
// host on a dual-address profile).
|
||||
const active = useAuthStore.getState().getActiveServer();
|
||||
if (!active) return;
|
||||
const srv = serverShareBaseUrl(active);
|
||||
if (!srv) return;
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
closeShareModal();
|
||||
};
|
||||
|
||||
// Queue mode shows upcoming tracks only — the current track lives in the
|
||||
@@ -449,8 +422,8 @@ function QueuePanelHostOrSolo() {
|
||||
playlistOperationGenerationRef.current += 1;
|
||||
setSaveModalOpen(false);
|
||||
}}
|
||||
serverOptions={queuePlaylistServerOptions}
|
||||
initialServerId={defaultQueuePlaylistServerId}
|
||||
serverOptions={queueServerOptions}
|
||||
initialServerId={defaultQueueServerId}
|
||||
onSave={async (name, serverId) => {
|
||||
const generation = ++playlistOperationGenerationRef.current;
|
||||
activePlaylistSaveGenerationRef.current += 1;
|
||||
@@ -458,7 +431,7 @@ function QueuePanelHostOrSolo() {
|
||||
const trackIds = queueTrackIdsForServerProfile(queueItems, serverId);
|
||||
if (
|
||||
trackIds.length === 0
|
||||
|| !queuePlaylistServerOptions.some(server => server.id === serverId)
|
||||
|| !queueServerOptions.some(server => server.id === serverId)
|
||||
) {
|
||||
setSaveModalOpen(false);
|
||||
return;
|
||||
@@ -481,6 +454,15 @@ function QueuePanelHostOrSolo() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareModalOpen && (
|
||||
<ShareQueueModal
|
||||
onClose={closeShareModal}
|
||||
serverOptions={queueServerOptions}
|
||||
initialServerId={defaultQueueServerId}
|
||||
onShare={shareForServer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loadModalOpen && (
|
||||
<LoadPlaylistModal
|
||||
onClose={() => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from '@/ui/Modal';
|
||||
import ServerChoiceList from '@/ui/ServerChoiceList';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onShare: (serverId: string) => Promise<void>;
|
||||
serverOptions: Array<{ id: string; label: string }>;
|
||||
initialServerId: string;
|
||||
}
|
||||
|
||||
export function ShareQueueModal({ onClose, onShare, serverOptions, initialServerId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [serverId, setServerId] = useState(initialServerId);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (sharing || !serverId) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
await onShare(serverId);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { if (!sharing) onClose(); }}
|
||||
title={t('queue.shareQueue')}
|
||||
closeLabel={t('queue.close')}
|
||||
size="sm"
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={sharing}>
|
||||
{t('queue.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => { void submit(); }}
|
||||
disabled={sharing || !serverId}
|
||||
>
|
||||
{t('queue.shareQueue')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ServerChoiceList
|
||||
value={serverId}
|
||||
options={serverOptions}
|
||||
onChange={setServerId}
|
||||
ariaLabel={t('settings.servers')}
|
||||
disabled={sharing}
|
||||
autoFocusSelected
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+49
-3
@@ -1,12 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import QueuePanel from '@/features/queue/components/QueuePanel';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { makeTrack, seedQueue } from '@/test/helpers/factories';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
|
||||
|
||||
const copyTextToClipboardMock = vi.fn(async (_text: string) => true);
|
||||
|
||||
@@ -44,7 +45,7 @@ function seedPublicShareQueue(pageUrl: string) {
|
||||
});
|
||||
}
|
||||
|
||||
describe('QueuePanel public share toolbar', () => {
|
||||
describe('QueuePanel share toolbar', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
copyTextToClipboardMock.mockClear();
|
||||
@@ -79,4 +80,49 @@ describe('QueuePanel public share toolbar', () => {
|
||||
fireEvent.click(getByLabelText('Copy Navidrome share link'));
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(pageUrl);
|
||||
});
|
||||
|
||||
it('shares a single represented server without prompting, even when another server is active', async () => {
|
||||
const secondServerId = useAuthStore.getState().addServer({
|
||||
name: 'Second', url: 'https://second.test', username: 'u2', password: 'p2',
|
||||
});
|
||||
const track = makeTrack({ id: 'second-track', serverId: secondServerId });
|
||||
seedQueue([track], { currentTrack: track, serverId: secondServerId });
|
||||
|
||||
const { getByLabelText, queryByRole } = renderWithProviders(<QueuePanel />);
|
||||
fireEvent.click(getByLabelText('Copy queue share link'));
|
||||
|
||||
await waitFor(() => expect(copyTextToClipboardMock).toHaveBeenCalledOnce());
|
||||
expect(queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(decodeSharePayloadFromText(copyTextToClipboardMock.mock.calls[0]![0])).toEqual({
|
||||
srv: 'https://second.test',
|
||||
k: 'queue',
|
||||
ids: ['second-track'],
|
||||
});
|
||||
});
|
||||
|
||||
it('prompts for a server when the queue is mixed and shares only that server\'s tracks', async () => {
|
||||
const activeServerId = useAuthStore.getState().activeServerId!;
|
||||
const secondServerId = useAuthStore.getState().addServer({
|
||||
name: 'Second', url: 'https://second.test', username: 'u2', password: 'p2',
|
||||
});
|
||||
const activeTrack = makeTrack({ id: 'active-track', serverId: activeServerId });
|
||||
const secondTrack = makeTrack({ id: 'second-track', serverId: secondServerId });
|
||||
seedQueue([activeTrack, secondTrack], { currentTrack: activeTrack, serverId: activeServerId });
|
||||
|
||||
const { getByLabelText, getByRole, queryByRole } = renderWithProviders(<QueuePanel />);
|
||||
fireEvent.click(getByLabelText('Copy queue share link'));
|
||||
|
||||
const dialog = getByRole('dialog');
|
||||
expect(copyTextToClipboardMock).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: 'Second' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Copy queue share link' }));
|
||||
|
||||
await waitFor(() => expect(copyTextToClipboardMock).toHaveBeenCalledOnce());
|
||||
expect(queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(decodeSharePayloadFromText(copyTextToClipboardMock.mock.calls[0]![0])).toEqual({
|
||||
srv: 'https://second.test',
|
||||
k: 'queue',
|
||||
ids: ['second-track'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||
import { queueTrackIdsForServerProfile } from '@/features/playback';
|
||||
import { encodeSharePayload } from '@/lib/share/shareLink';
|
||||
import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
|
||||
import { copyTextToClipboard } from '@/lib/server/serverMagicString';
|
||||
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
|
||||
interface Options {
|
||||
queueItems: QueueItemRef[];
|
||||
servers: ServerProfile[];
|
||||
activeServerId: string | null;
|
||||
publicShareQueueActive: boolean;
|
||||
navidromePublicSharePageUrl: string | null;
|
||||
}
|
||||
|
||||
export function useQueueShare({
|
||||
queueItems,
|
||||
servers,
|
||||
activeServerId,
|
||||
publicShareQueueActive,
|
||||
navidromePublicSharePageUrl,
|
||||
}: Options) {
|
||||
const { t } = useTranslation();
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const serverOptions = useMemo(() => servers
|
||||
.filter(server => queueTrackIdsForServerProfile(queueItems, server.id).length > 0)
|
||||
.map(server => ({ id: server.id, label: serverListDisplayLabel(server, servers) })), [queueItems, servers]);
|
||||
const defaultServerId = activeServerId && serverOptions.some(server => server.id === activeServerId)
|
||||
? activeServerId
|
||||
: serverOptions[0]?.id ?? '';
|
||||
|
||||
const copyForServer = async (serverId: string) => {
|
||||
const ids = queueTrackIdsForServerProfile(queueItems, serverId);
|
||||
if (ids.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
// Queue share goes to remote recipients, so use the share URL instead of
|
||||
// the connect URL the app is currently bound to (which could leak a LAN host).
|
||||
const server = servers.find(candidate => candidate.id === serverId);
|
||||
if (!server) return;
|
||||
const srv = serverShareBaseUrl(server);
|
||||
if (!srv) return;
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (publicShareQueueActive) {
|
||||
const pageUrl = navidromePublicSharePageUrl?.trim();
|
||||
if (!pageUrl) {
|
||||
showToast(t('queue.shareNavidromePublicMissing'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
const ok = await copyTextToClipboard(pageUrl);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
if (serverOptions.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
if (serverOptions.length > 1) {
|
||||
setShareModalOpen(true);
|
||||
return;
|
||||
}
|
||||
await copyForServer(serverOptions[0]!.id);
|
||||
};
|
||||
|
||||
const shareForServer = async (serverId: string) => {
|
||||
try {
|
||||
if (serverOptions.some(server => server.id === serverId)) await copyForServer(serverId);
|
||||
} finally {
|
||||
setShareModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
serverOptions,
|
||||
defaultServerId,
|
||||
shareModalOpen,
|
||||
handleCopy,
|
||||
shareForServer,
|
||||
closeShareModal: () => setShareModalOpen(false),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
export interface ServerChoiceOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: ServerChoiceOption[];
|
||||
onChange: (serverId: string) => void;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
autoFocusSelected?: boolean;
|
||||
}
|
||||
|
||||
export default function ServerChoiceList({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
autoFocusSelected,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="nav-library-dropdown-panel" role="radiogroup" aria-label={ariaLabel}>
|
||||
{options.map(server => {
|
||||
const selected = server.id === value;
|
||||
return (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={`nav-library-dropdown-item ${selected ? 'nav-library-dropdown-item--selected' : ''}`}
|
||||
onClick={() => onChange(server.id)}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocusSelected && selected}
|
||||
>
|
||||
<span className="nav-library-dropdown-item-label">{server.label}</span>
|
||||
<span
|
||||
className={`nav-library-dropdown-item-toggle ${selected ? 'nav-library-dropdown-item-toggle--on' : ''}`}
|
||||
aria-hidden
|
||||
>
|
||||
{selected ? <Check size={16} strokeWidth={2.5} /> : <span className="nav-library-dropdown-item-toggle-box" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user