fix(queue): choose server for mixed shares

This commit is contained in:
cucadmuh
2026-07-21 13:47:29 +03:00
parent 62c30a0d63
commit 6928f1a4aa
7 changed files with 287 additions and 52 deletions
+1
View File
@@ -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
+1
View File
@@ -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';
+31 -49
View File
@@ -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>
);
}
@@ -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'],
});
});
});
+92
View File
@@ -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),
};
}
+52
View File
@@ -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>
);
}