fix(ui): refine server choice popovers

This commit is contained in:
cucadmuh
2026-07-21 14:39:11 +03:00
parent a6adc8299d
commit 831a6b701c
12 changed files with 319 additions and 103 deletions
+1 -1
View File
@@ -35,7 +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. Creating an Orbit session from a multi-server scope now asks which server should host it, temporarily keeps only that server in the library scope and shared queue, then restores the previous scope when the session ends.
* 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.
* Sharing a mixed-server queue opens a compact server picker directly below the share button; choosing a server copies its tracks immediately, while single-server queues still copy without an extra prompt. Unavailable servers are marked with an explanatory warning.
## Changed
@@ -5,10 +5,15 @@ import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { makeServer } from '@/test/helpers/factories';
import { useAuthStore } from '@/store/authStore';
import {
resetServerReachabilitySnapshot,
setServerReachability,
} from '@/lib/network/serverReachability';
const mocks = vi.hoisted(() => ({
startOrbitSession: vi.fn(),
}));
let secondServerId = '';
vi.mock('@/features/orbit/utils/orbit', () => ({
buildOrbitShareLink: (serverBase: string, sid: string) => `psysonic2-orbit:${serverBase}:${sid}`,
@@ -23,9 +28,11 @@ import OrbitStartModal from '@/features/orbit/components/OrbitStartModal';
beforeEach(() => {
resetAuthStore();
resetServerReachabilitySnapshot();
mocks.startOrbitSession.mockReset().mockResolvedValue({});
const first = makeServer({ id: 'srv-a', name: 'Server A', url: 'https://a.example' });
const second = makeServer({ id: 'srv-b', name: 'Server B', url: 'https://b.example' });
secondServerId = second.id;
useAuthStore.setState({
servers: [first, second],
activeServerId: first.id,
@@ -48,4 +55,17 @@ describe('OrbitStartModal', () => {
clearQueue: false,
}));
});
it('shows unavailable servers with a warning and no empty checkbox squares', () => {
setServerReachability(secondServerId, 'unavailable');
const { container } = renderWithProviders(<OrbitStartModal onClose={vi.fn()} />);
const unavailable = screen.getByRole('radio', { name: /Server B.*Cannot reach Server B/ });
expect(unavailable.querySelector('.server-choice-warning')).toHaveAttribute(
'data-tooltip',
'Cannot reach Server B. Check your network or server.',
);
expect(container.querySelector('.nav-library-dropdown-item-toggle-box')).toBeNull();
});
});
@@ -16,6 +16,7 @@ import { isLanUrl, serverShareBaseUrl } from '@/lib/server/serverEndpoint';
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
import { ORBIT_DEFAULT_MAX_USERS } from '@/features/orbit/api/orbit';
import ServerChoiceList from '@/ui/ServerChoiceList';
import { useUnavailableServerIds } from '@/lib/network/serverReachability';
interface Props { onClose: () => void; }
@@ -40,6 +41,7 @@ export default function OrbitStartModal({ onClose }: Props) {
const servers = useAuthStore(state => state.servers);
const libraryBrowseServerIds = useAuthStore(state => state.libraryBrowseServerIds);
const unavailableServerIds = useUnavailableServerIds();
const [serverId, setServerId] = useState(() => {
const auth = useAuthStore.getState();
return auth.libraryBrowseServerIds.includes(auth.activeServerId ?? '')
@@ -48,7 +50,16 @@ export default function OrbitStartModal({ onClose }: Props) {
});
const serverOptions = servers
.filter(server => libraryBrowseServerIds.includes(server.id))
.map(server => ({ id: server.id, label: serverListDisplayLabel(server, servers) }));
.map(server => {
const label = serverListDisplayLabel(server, servers);
return {
id: server.id,
label,
warning: unavailableServerIds.has(server.id)
? t('connection.offlineSubtitle', { server: label })
: undefined,
};
});
const server = servers.find(candidate => candidate.id === serverId);
// Orbit links go to remote guests — use the share URL (public by default
// when both are set; LAN only if shareUsesLocalUrl is on). The LAN warning
+8 -13
View File
@@ -19,7 +19,6 @@ 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';
@@ -183,10 +182,10 @@ function QueuePanelHostOrSolo() {
const {
serverOptions: queueServerOptions,
defaultServerId: defaultQueueServerId,
shareModalOpen,
sharePickerOpen,
handleCopy: handleCopyQueueShare,
shareForServer,
closeShareModal,
closeSharePicker,
} = useQueueShare({
queueItems,
servers,
@@ -249,7 +248,7 @@ function QueuePanelHostOrSolo() {
clearQueue();
setActivePlaylist(null);
setSaveModalOpen(false);
closeShareModal();
closeSharePicker();
};
// Queue mode shows upcoming tracks only — the current track lives in the
@@ -369,6 +368,11 @@ function QueuePanelHostOrSolo() {
handleSave={handleSave}
handleLoad={handleLoad}
handleCopyQueueShare={handleCopyQueueShare}
sharePickerOpen={sharePickerOpen}
queueServerOptions={queueServerOptions}
defaultQueueServerId={defaultQueueServerId}
shareForServer={shareForServer}
closeSharePicker={closeSharePicker}
handleClear={handleClear}
publicShareQueueActive={publicShareQueueActive}
gaplessEnabled={gaplessEnabled}
@@ -454,15 +458,6 @@ function QueuePanelHostOrSolo() {
/>
)}
{shareModalOpen && (
<ShareQueueModal
onClose={closeShareModal}
serverOptions={queueServerOptions}
initialServerId={defaultQueueServerId}
onShare={shareForServer}
/>
)}
{loadModalOpen && (
<LoadPlaylistModal
onClose={() => {
@@ -0,0 +1,118 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Share2 } from 'lucide-react';
import type { ServerChoiceOption } from '@/ui/ServerChoiceList';
import { ServerChoiceWarning } from '@/ui/ServerChoiceList';
interface Props {
label: string;
open: boolean;
options: ServerChoiceOption[];
initialServerId: string;
onTrigger: () => void;
onClose: () => void;
onShare: (serverId: string) => Promise<void>;
}
export function QueueShareButton({
label,
open,
options,
initialServerId,
onTrigger,
onClose,
onShare,
}: Props) {
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0, ready: false });
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
const panel = panelRef.current;
if (!trigger || !panel) return;
const triggerRect = trigger.getBoundingClientRect();
const panelWidth = panel.offsetWidth;
const margin = 8;
const idealLeft = triggerRect.left + triggerRect.width / 2 - panelWidth / 2;
const left = Math.max(margin, Math.min(idealLeft, window.innerWidth - panelWidth - margin));
setPosition({ top: triggerRect.bottom + 8, left, ready: true });
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [open, options]);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return;
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
onClose();
triggerRef.current?.focus();
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose, open]);
return (
<>
<button
ref={triggerRef}
type="button"
className={`queue-round-btn${open ? ' active' : ''}`}
onClick={onTrigger}
data-tooltip={open ? undefined : label}
aria-label={label}
aria-haspopup={options.length > 1 ? 'menu' : undefined}
aria-expanded={options.length > 1 ? open : undefined}
>
<Share2 size={13} />
</button>
{open && createPortal(
<div
ref={panelRef}
className="nav-library-dropdown-panel queue-share-popover"
role="menu"
aria-label={label}
style={{
position: 'fixed',
top: position.top,
left: position.left,
visibility: position.ready ? 'visible' : 'hidden',
}}
>
{options.map(server => (
<button
key={server.id}
type="button"
role="menuitem"
className="queue-share-server-item"
aria-label={server.warning ? `${server.label}. ${server.warning}` : undefined}
autoFocus={server.id === initialServerId}
onClick={() => { void onShare(server.id); }}
>
<span className="queue-share-server-label">{server.label}</span>
<ServerChoiceWarning warning={server.warning} />
</button>
))}
</div>,
document.body,
)}
</>
);
}
+24 -10
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {
Blend, Check, FolderOpen, Infinity as InfinityIcon, ListMusic, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
Blend, Check, FolderOpen, Infinity as InfinityIcon, ListMusic, MoveRight, Save, Shuffle, Trash2, Waves,
} from 'lucide-react';
import type { TFunction } from 'i18next';
import type { QueueItemRef } from '@/lib/media/trackTypes';
@@ -10,6 +10,8 @@ import type {
} from '@/store/queueToolbarStore';
import { getTransitionMode, setTransitionMode } from '@/features/playback/utils/playback/playbackTransition';
import { useOrbitStore } from '@/features/orbit';
import { QueueShareButton } from '@/features/queue/components/QueueShareButton';
import type { ServerChoiceOption } from '@/ui/ServerChoiceList';
interface Props {
queue: QueueItemRef[];
@@ -20,6 +22,11 @@ interface Props {
handleSave: () => void;
handleLoad: () => void;
handleCopyQueueShare: () => void;
sharePickerOpen: boolean;
queueServerOptions: ServerChoiceOption[];
defaultQueueServerId: string;
shareForServer: (serverId: string) => Promise<void>;
closeSharePicker: () => void;
handleClear: () => void;
publicShareQueueActive: boolean;
gaplessEnabled: boolean;
@@ -34,7 +41,9 @@ interface Props {
export function QueueToolbar({
queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
handleSave, handleLoad, handleCopyQueueShare, handleClear,
handleSave, handleLoad, handleCopyQueueShare,
sharePickerOpen, queueServerOptions, defaultQueueServerId, shareForServer, closeSharePicker,
handleClear,
publicShareQueueActive,
gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence,
crossfadeSecs, setCrossfadeSecs,
@@ -126,15 +135,20 @@ export function QueueToolbar({
);
case 'share':
return (
<button
<QueueShareButton
key={btn.id}
className="queue-round-btn"
onClick={() => void handleCopyQueueShare()}
data-tooltip={publicShareQueueActive ? t('queue.shareNavidromePublic') : t('queue.shareQueue')}
aria-label={publicShareQueueActive ? t('queue.shareNavidromePublic') : t('queue.shareQueue')}
>
<Share2 size={13} />
</button>
label={publicShareQueueActive ? t('queue.shareNavidromePublic') : t('queue.shareQueue')}
open={sharePickerOpen}
options={queueServerOptions}
initialServerId={defaultQueueServerId}
onTrigger={() => {
setShowCrossfadePopover(false);
setShowPlaylistMenu(false);
handleCopyQueueShare();
}}
onClose={closeSharePicker}
onShare={shareForServer}
/>
);
case 'clear':
return (
@@ -1,61 +0,0 @@
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>
);
}
@@ -8,6 +8,10 @@ import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
import {
resetServerReachabilitySnapshot,
setServerReachability,
} from '@/lib/network/serverReachability';
const copyTextToClipboardMock = vi.fn(async (_text: string) => true);
@@ -48,6 +52,7 @@ function seedPublicShareQueue(pageUrl: string) {
describe('QueuePanel share toolbar', () => {
beforeEach(() => {
resetAllStores();
resetServerReachabilitySnapshot();
copyTextToClipboardMock.mockClear();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
@@ -100,7 +105,7 @@ describe('QueuePanel share toolbar', () => {
});
});
it('prompts for a server when the queue is mixed and shares only that server\'s tracks', async () => {
it('opens a compact server menu for a mixed queue and copies as soon as a server is clicked', async () => {
const activeServerId = useAuthStore.getState().activeServerId!;
const secondServerId = useAuthStore.getState().addServer({
name: 'Second', url: 'https://second.test', username: 'u2', password: 'p2',
@@ -112,17 +117,37 @@ describe('QueuePanel share toolbar', () => {
const { getByLabelText, getByRole, queryByRole } = renderWithProviders(<QueuePanel />);
fireEvent.click(getByLabelText('Copy queue share link'));
const dialog = getByRole('dialog');
const menu = getByRole('menu', { name: 'Copy queue share link' });
expect(copyTextToClipboardMock).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('radio', { name: 'Second' }));
fireEvent.click(within(dialog).getByRole('button', { name: 'Copy queue share link' }));
expect(queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Second' }));
await waitFor(() => expect(copyTextToClipboardMock).toHaveBeenCalledOnce());
expect(queryByRole('dialog')).not.toBeInTheDocument();
expect(queryByRole('menu', { name: 'Copy queue share link' })).not.toBeInTheDocument();
expect(decodeSharePayloadFromText(copyTextToClipboardMock.mock.calls[0]![0])).toEqual({
srv: 'https://second.test',
k: 'queue',
ids: ['second-track'],
});
});
it('marks an unavailable server with an explanatory warning tooltip', () => {
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 });
setServerReachability(secondServerId, 'unavailable');
const { getByLabelText, getByRole } = renderWithProviders(<QueuePanel />);
fireEvent.click(getByLabelText('Copy queue share link'));
const item = getByRole('menuitem', { name: /Second.*Cannot reach Second/ });
expect(item.querySelector('.server-choice-warning')).toHaveAttribute(
'data-tooltip',
'Cannot reach Second. Check your network or server.',
);
});
});
+19 -7
View File
@@ -8,6 +8,8 @@ import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
import { copyTextToClipboard } from '@/lib/server/serverMagicString';
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
import { showToast } from '@/lib/dom/toast';
import { useUnavailableServerIds } from '@/lib/network/serverReachability';
import type { ServerChoiceOption } from '@/ui/ServerChoiceList';
interface Options {
queueItems: QueueItemRef[];
@@ -25,10 +27,20 @@ export function useQueueShare({
navidromePublicSharePageUrl,
}: Options) {
const { t } = useTranslation();
const [shareModalOpen, setShareModalOpen] = useState(false);
const serverOptions = useMemo(() => servers
const unavailableServerIds = useUnavailableServerIds();
const [sharePickerOpen, setSharePickerOpen] = useState(false);
const serverOptions = useMemo<ServerChoiceOption[]>(() => servers
.filter(server => queueTrackIdsForServerProfile(queueItems, server.id).length > 0)
.map(server => ({ id: server.id, label: serverListDisplayLabel(server, servers) })), [queueItems, servers]);
.map(server => {
const label = serverListDisplayLabel(server, servers);
return {
id: server.id,
label,
warning: unavailableServerIds.has(server.id)
? t('connection.offlineSubtitle', { server: label })
: undefined,
};
}), [queueItems, servers, t, unavailableServerIds]);
const defaultServerId = activeServerId && serverOptions.some(server => server.id === activeServerId)
? activeServerId
: serverOptions[0]?.id ?? '';
@@ -67,7 +79,7 @@ export function useQueueShare({
return;
}
if (serverOptions.length > 1) {
setShareModalOpen(true);
setSharePickerOpen(open => !open);
return;
}
await copyForServer(serverOptions[0]!.id);
@@ -77,16 +89,16 @@ export function useQueueShare({
try {
if (serverOptions.some(server => server.id === serverId)) await copyForServer(serverId);
} finally {
setShareModalOpen(false);
setSharePickerOpen(false);
}
};
return {
serverOptions,
defaultServerId,
shareModalOpen,
sharePickerOpen,
handleCopy,
shareForServer,
closeShareModal: () => setShareModalOpen(false),
closeSharePicker: () => setSharePickerOpen(false),
};
}
+37 -1
View File
@@ -202,6 +202,43 @@
cursor: default;
}
.queue-share-popover {
width: max-content;
min-width: 150px;
max-width: calc(100vw - 16px);
padding: var(--space-1);
z-index: 10050;
}
.queue-share-server-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.queue-share-server-item:hover,
.queue-share-server-item:focus-visible {
background: var(--menu-item-hover);
}
.queue-share-server-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-toolbar-sep {
width: 1px;
height: 18px;
@@ -451,4 +488,3 @@
padding: var(--space-3) var(--space-4) 0;
flex-shrink: 0;
}
+27
View File
@@ -288,6 +288,33 @@
text-overflow: ellipsis;
}
.server-choice-label {
display: flex;
align-items: center;
gap: var(--space-2);
}
.server-choice-label__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.server-choice-warning {
display: inline-flex;
align-items: center;
flex-shrink: 0;
color: var(--warning, #f59e0b);
}
.server-choice-check {
pointer-events: none;
}
.server-choice-check:hover {
background: transparent;
}
.nav-section-label {
font-size: 10px;
font-weight: 600;
+23 -4
View File
@@ -1,8 +1,9 @@
import { Check } from 'lucide-react';
import { Check, TriangleAlert } from 'lucide-react';
export interface ServerChoiceOption {
id: string;
label: string;
warning?: string;
}
interface Props {
@@ -14,6 +15,20 @@ interface Props {
autoFocusSelected?: boolean;
}
export function ServerChoiceWarning({ warning }: { warning?: string }) {
if (!warning) return null;
return (
<span
className="server-choice-warning"
data-tooltip={warning}
data-tooltip-wrap
aria-hidden
>
<TriangleAlert size={15} strokeWidth={2.25} />
</span>
);
}
export default function ServerChoiceList({
value,
options,
@@ -32,17 +47,21 @@ export default function ServerChoiceList({
type="button"
role="radio"
aria-checked={selected}
aria-label={server.warning ? `${server.label}. ${server.warning}` : undefined}
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-label server-choice-label">
<span className="server-choice-label__text">{server.label}</span>
<ServerChoiceWarning warning={server.warning} />
</span>
<span
className={`nav-library-dropdown-item-toggle ${selected ? 'nav-library-dropdown-item-toggle--on' : ''}`}
className={`nav-library-dropdown-item-toggle server-choice-check ${selected ? 'nav-library-dropdown-item-toggle--on' : ''}`}
aria-hidden
>
{selected ? <Check size={16} strokeWidth={2.5} /> : <span className="nav-library-dropdown-item-toggle-box" />}
{selected ? <Check size={16} strokeWidth={2.5} /> : null}
</span>
</button>
);