mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
feat(library): expose entity source choices
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import AlbumHeader from '@/features/album/components/AlbumHeader';
|
||||
import { libraryResolveEntitySources } from '@/lib/api/library';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
|
||||
const navigate = vi.fn();
|
||||
@@ -24,6 +25,13 @@ vi.mock('@/cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
|
||||
vi.mock('@/lib/share/copyEntityShareLink', () => ({
|
||||
copyEntityShareLink: (...args: unknown[]) => copyEntityShareLink(...args),
|
||||
}));
|
||||
vi.mock('@/lib/api/library', () => ({
|
||||
libraryResolveEntitySources: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(libraryResolveEntitySources).mockReset();
|
||||
});
|
||||
|
||||
function baseProps() {
|
||||
return {
|
||||
@@ -171,4 +179,46 @@ describe('AlbumHeader genres', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Share album' }));
|
||||
expect(copyEntityShareLink).toHaveBeenCalledWith('album', 'al1', { serverId: 'srv-b' });
|
||||
});
|
||||
|
||||
it('opens a selected concrete source while preserving detail filters', async () => {
|
||||
navigate.mockClear();
|
||||
vi.mocked(libraryResolveEntitySources).mockResolvedValue([
|
||||
{
|
||||
serverId: 'srv-a', id: 'al1', libraryId: 'main', priority: 0,
|
||||
durationSec: null, suffix: null, bitRate: null, sizeBytes: null,
|
||||
starredAt: null, userRating: null,
|
||||
},
|
||||
{
|
||||
serverId: 'srv-b', id: 'al2', libraryId: 'archive', priority: 1,
|
||||
durationSec: null, suffix: null, bitRate: null, sizeBytes: null,
|
||||
starredAt: null, userRating: null,
|
||||
},
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<AlbumHeader
|
||||
{...baseProps()}
|
||||
info={albumInfo()}
|
||||
serverId="srv-a"
|
||||
sourceScopes={[
|
||||
{ serverId: 'srv-a', libraryId: 'main' },
|
||||
{ serverId: 'srv-b', libraryId: 'archive' },
|
||||
]}
|
||||
sourceServers={[
|
||||
{ id: 'srv-a', name: 'Primary', url: 'https://a.test', username: 'a', password: 'p' },
|
||||
{ id: 'srv-b', name: 'Archive', url: 'https://b.test', username: 'b', password: 'p' },
|
||||
]}
|
||||
sourceMusicFoldersByServer={{
|
||||
'srv-a': [{ id: 'main', name: 'Main' }],
|
||||
'srv-b': [{ id: 'archive', name: 'Archive' }],
|
||||
}}
|
||||
/>,
|
||||
{ route: '/album/al1?server=srv-a&lossless=1' },
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Available from 2 sources' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: /Archive · Archive.*Open from server/ }));
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith('/album/al2?server=srv-b&lossless=1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { EntityRatingSupportLevel, SubsonicItemGenre, SubsonicOpenArtistRef, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
||||
import { CoverArtImage } from '@/cover/CoverArtImage';
|
||||
import { useCoverLightboxSrc } from '@/cover/lightbox';
|
||||
@@ -23,6 +23,9 @@ import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offlin
|
||||
import { deriveAlbumGenreTags } from '@/lib/library/genreTags';
|
||||
import { genreColor } from '@/lib/library/genreColor';
|
||||
import { buildAlbumDetailPath, buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
|
||||
import EntitySourcePicker from '@/ui/EntitySourcePicker';
|
||||
import type { LibraryScopePair } from '@/lib/api/library';
|
||||
import type { MusicFolder, ServerProfile } from '@/store/authStoreTypes';
|
||||
|
||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
||||
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||
@@ -177,6 +180,9 @@ interface AlbumHeaderProps {
|
||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
sourceScopes?: LibraryScopePair[];
|
||||
sourceServers?: ServerProfile[];
|
||||
sourceMusicFoldersByServer?: Record<string, MusicFolder[]>;
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
@@ -205,10 +211,14 @@ export default function AlbumHeader({
|
||||
onEntityRatingChange,
|
||||
entityRatingSupport,
|
||||
actionPolicy,
|
||||
sourceScopes = [],
|
||||
sourceServers = [],
|
||||
sourceMusicFoldersByServer = {},
|
||||
}: AlbumHeaderProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const goBack = useAlbumDetailBack();
|
||||
const isMobile = useIsMobile();
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
@@ -361,6 +371,20 @@ export default function AlbumHeader({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{serverId && (
|
||||
<EntitySourcePicker
|
||||
entityType="album"
|
||||
anchorServerId={serverId}
|
||||
anchorId={info.id}
|
||||
scopes={sourceScopes}
|
||||
servers={sourceServers}
|
||||
musicFoldersByServer={sourceMusicFoldersByServer}
|
||||
onSelect={source => navigate(buildAlbumDetailPath(source.id, {
|
||||
serverId: source.serverId,
|
||||
search: location.search,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<div className="album-detail-entity-rating">
|
||||
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
|
||||
<StarRating
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { offlineActionPolicy } from '@/features/offline';
|
||||
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
|
||||
import { sameQueueTrack } from '@/features/playback';
|
||||
import { deriveEntitySourceScopes } from '@/lib/library/libraryBrowseScope';
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { t } = useTranslation();
|
||||
@@ -85,6 +86,7 @@ export default function AlbumDetail() {
|
||||
const routeServerId = readDetailServerId(searchParams, auth.activeServerId) ?? '';
|
||||
const albumOwnerServerId = album?.album.serverId ?? routeServerId;
|
||||
const albumOwnerId = album?.album.id ?? '';
|
||||
const entitySourceScopes = deriveEntitySourceScopes(auth, albumOwnerServerId);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const albumEntityRatingSupport = entityRatingSupportByServer[albumOwnerServerId] ?? 'unknown';
|
||||
@@ -464,6 +466,9 @@ const handleShuffleAll = () => {
|
||||
<AlbumHeader
|
||||
info={info}
|
||||
serverId={albumOwnerServerId}
|
||||
sourceScopes={entitySourceScopes}
|
||||
sourceServers={auth.servers}
|
||||
sourceMusicFoldersByServer={auth.musicFoldersByServer}
|
||||
headerArtistRefs={headerArtistRefs}
|
||||
songs={songs}
|
||||
coverRef={albumCoverRefResolved}
|
||||
|
||||
@@ -23,6 +23,11 @@ import WikipediaIcon from '@/ui/WikipediaIcon';
|
||||
import StarRating from '@/ui/StarRating';
|
||||
import { tooltipAttrs } from '@/ui/tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
|
||||
import EntitySourcePicker from '@/ui/EntitySourcePicker';
|
||||
import type { LibraryScopePair } from '@/lib/api/library';
|
||||
import type { MusicFolder, ServerProfile } from '@/store/authStoreTypes';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -50,6 +55,9 @@ interface Props {
|
||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
serverId: string;
|
||||
sourceScopes?: LibraryScopePair[];
|
||||
sourceServers?: ServerProfile[];
|
||||
sourceMusicFoldersByServer?: Record<string, MusicFolder[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,12 +113,14 @@ export default function ArtistDetailHero({
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
actionPolicy, serverId,
|
||||
actionPolicy, serverId, sourceScopes = [], sourceServers = [], sourceMusicFoldersByServer = {},
|
||||
}: Props) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('artistDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const goBack = useAlbumDetailBack();
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
const artistAlbumIds = useMemo(() => albums.map(a => a.id), [albums]);
|
||||
@@ -233,6 +243,19 @@ export default function ArtistDetailHero({
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
||||
</div>
|
||||
|
||||
<EntitySourcePicker
|
||||
entityType="artist"
|
||||
anchorServerId={artist.serverId ?? serverId}
|
||||
anchorId={artist.id}
|
||||
scopes={sourceScopes}
|
||||
servers={sourceServers}
|
||||
musicFoldersByServer={sourceMusicFoldersByServer}
|
||||
onSelect={source => navigate(buildArtistDetailPath(source.id, {
|
||||
serverId: source.serverId,
|
||||
search: location.search,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className="artist-detail-entity-rating">
|
||||
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
|
||||
<StarRating
|
||||
|
||||
@@ -40,6 +40,7 @@ import { LOSSLESS_MODE_QUERY } from '@/lib/library/losslessMode';
|
||||
import { sortArtistAlbumsByYear } from '@/features/artist/utils/sortArtistAlbums';
|
||||
import { readDetailServerId } from '@/lib/navigation/detailServerScope';
|
||||
import { coverServerScopeForServerId } from '@/cover/serverScope';
|
||||
import { deriveEntitySourceScopes } from '@/lib/library/libraryBrowseScope';
|
||||
|
||||
|
||||
export default function ArtistDetail() {
|
||||
@@ -50,11 +51,30 @@ export default function ArtistDetail() {
|
||||
const losslessOnly = searchParams.get('lossless') === '1';
|
||||
const authActiveServerId = useAuthStore(s => s.activeServerId);
|
||||
const activeServerId = readDetailServerId(searchParams, authActiveServerId) ?? '';
|
||||
const sourceServers = useAuthStore(s => s.servers);
|
||||
const sourceBrowseServerIds = useAuthStore(s => s.libraryBrowseServerIds);
|
||||
const sourceSelections = useAuthStore(s => s.libraryBrowseSelectionByServer);
|
||||
const sourceMusicFoldersByServer = useAuthStore(s => s.musicFoldersByServer);
|
||||
const {
|
||||
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||
loading, topSongsLoading, artistInfoLoading, featuredLoading,
|
||||
isStarred, setIsStarred,
|
||||
} = useArtistDetailData(id, { losslessOnly });
|
||||
const artistOwnerServerId = artist?.serverId ?? activeServerId;
|
||||
const entitySourceScopes = useMemo(() => deriveEntitySourceScopes({
|
||||
servers: sourceServers,
|
||||
activeServerId: authActiveServerId,
|
||||
libraryBrowseServerIds: sourceBrowseServerIds,
|
||||
musicFoldersByServer: sourceMusicFoldersByServer,
|
||||
libraryBrowseSelectionByServer: sourceSelections,
|
||||
}, artistOwnerServerId), [
|
||||
artistOwnerServerId,
|
||||
authActiveServerId,
|
||||
sourceBrowseServerIds,
|
||||
sourceMusicFoldersByServer,
|
||||
sourceSelections,
|
||||
sourceServers,
|
||||
]);
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
@@ -296,6 +316,9 @@ export default function ArtistDetail() {
|
||||
setHeaderCoverFailed={setHeaderCoverFailed}
|
||||
actionPolicy={artistActionPolicy}
|
||||
serverId={activeServerId}
|
||||
sourceScopes={entitySourceScopes}
|
||||
sourceServers={sourceServers}
|
||||
sourceMusicFoldersByServer={sourceMusicFoldersByServer}
|
||||
/>
|
||||
|
||||
{losslessOnly && <LosslessModeBanner />}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LibraryEntitySourceDto } from '@/lib/api/library';
|
||||
import { libraryResolveEntitySources } from '@/lib/api/library';
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { deriveLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
|
||||
import { deriveEntitySourceScopes } from '@/lib/library/libraryBrowseScope';
|
||||
import { sameQueueItemRef } from '@/features/playback/utils/playback/queueIdentity';
|
||||
|
||||
export interface PlaybackSourceFailure {
|
||||
@@ -70,10 +70,7 @@ export function reportPlaybackSourceFailure(args: {
|
||||
});
|
||||
|
||||
const auth = useAuthStore.getState();
|
||||
const configuredScope = deriveLibraryBrowseScope(auth, new Set()).pairs;
|
||||
const scopes = configuredScope.length > 0
|
||||
? configuredScope
|
||||
: [{ serverId: expectedRef.serverId, libraryId: null }];
|
||||
const scopes = deriveEntitySourceScopes(auth, expectedRef.serverId);
|
||||
|
||||
void libraryResolveEntitySources(expectedRef.serverId, {
|
||||
entityType: 'track',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import {
|
||||
deriveEntitySourceScopes,
|
||||
deriveEffectiveLibraryBrowseServerIds,
|
||||
deriveLibraryBrowseIndexScopes,
|
||||
deriveLibraryBrowseScope,
|
||||
@@ -132,4 +133,29 @@ describe('getLibraryBrowseScope', () => {
|
||||
libraryBrowseSelectionByServer: { active: ['one'] },
|
||||
}).fingerprint).toBe(JSON.stringify([['active', ['one']]]));
|
||||
});
|
||||
|
||||
it('uses configured source membership even when servers are unavailable', () => {
|
||||
const state = {
|
||||
servers: [{ id: 'primary' }, { id: 'offline' }],
|
||||
activeServerId: 'primary',
|
||||
libraryBrowseServerIds: ['primary', 'offline'],
|
||||
musicFoldersByServer: {},
|
||||
libraryBrowseSelectionByServer: { primary: ['music'], offline: [] },
|
||||
};
|
||||
|
||||
expect(deriveEntitySourceScopes(state, 'anchor')).toEqual([
|
||||
{ serverId: 'primary', libraryId: 'music' },
|
||||
{ serverId: 'offline', libraryId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the concrete anchor when no configured pair exists', () => {
|
||||
expect(deriveEntitySourceScopes({
|
||||
servers: [{ id: 'active' }],
|
||||
activeServerId: 'active',
|
||||
libraryBrowseServerIds: [],
|
||||
musicFoldersByServer: {},
|
||||
libraryBrowseSelectionByServer: {},
|
||||
}, 'owner')).toEqual([{ serverId: 'owner', libraryId: null }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,16 @@ export function deriveLibraryBrowseScope(
|
||||
};
|
||||
}
|
||||
|
||||
/** Configured scope for entity-source resolution, with the concrete anchor as a defensive fallback. */
|
||||
export function deriveEntitySourceScopes(
|
||||
state: LibraryBrowseScopeSource,
|
||||
anchorServerId: string,
|
||||
): LibraryBrowseScopePair[] {
|
||||
const configured = deriveLibraryBrowseScope(state, new Set()).pairs;
|
||||
if (configured.length > 0) return configured;
|
||||
return anchorServerId ? [{ serverId: anchorServerId, libraryId: null }] : [];
|
||||
}
|
||||
|
||||
export function getLibraryBrowseScope(): LibraryBrowseScope {
|
||||
return deriveLibraryBrowseScope(readLibraryBrowseScopeSource());
|
||||
}
|
||||
|
||||
@@ -67,5 +67,10 @@ export const common = {
|
||||
more: 'още',
|
||||
yearRange: 'Диапазон на години',
|
||||
clearAll: 'Изчисти всички',
|
||||
availableSources_one: 'Налично от {{count}} източник',
|
||||
availableSources_other: 'Налично от {{count}} източника',
|
||||
chooseSource: 'Изберете източник',
|
||||
currentSource: 'Текущ източник',
|
||||
openFromServer: 'Отвори от сървъра',
|
||||
libraryEmpty: 'Вашата библиотека е празна.',
|
||||
};
|
||||
|
||||
@@ -67,5 +67,10 @@ export const common = {
|
||||
more: 'mehr',
|
||||
yearRange: 'Jahresbereich',
|
||||
clearAll: 'Alles zurücksetzen',
|
||||
availableSources_one: 'Aus {{count}} Quelle verfügbar',
|
||||
availableSources_other: 'Aus {{count}} Quellen verfügbar',
|
||||
chooseSource: 'Quelle auswählen',
|
||||
currentSource: 'Aktuelle Quelle',
|
||||
openFromServer: 'Von diesem Server öffnen',
|
||||
libraryEmpty: 'Deine Bibliothek ist leer.',
|
||||
};
|
||||
|
||||
@@ -67,5 +67,10 @@ export const common = {
|
||||
more: 'more',
|
||||
yearRange: 'Year Range',
|
||||
clearAll: 'Clear all',
|
||||
availableSources_one: 'Available from {{count}} source',
|
||||
availableSources_other: 'Available from {{count}} sources',
|
||||
chooseSource: 'Choose a source',
|
||||
currentSource: 'Current source',
|
||||
openFromServer: 'Open from server',
|
||||
libraryEmpty: 'Your library is empty.',
|
||||
};
|
||||
|
||||
@@ -57,5 +57,10 @@ export const common = {
|
||||
more: 'más',
|
||||
yearRange: 'Rango de años',
|
||||
clearAll: 'Limpiar todo',
|
||||
availableSources_one: 'Disponible en {{count}} fuente',
|
||||
availableSources_other: 'Disponible en {{count}} fuentes',
|
||||
chooseSource: 'Elegir una fuente',
|
||||
currentSource: 'Fuente actual',
|
||||
openFromServer: 'Abrir desde el servidor',
|
||||
libraryEmpty: 'Tu biblioteca está vacía.',
|
||||
};
|
||||
|
||||
@@ -57,5 +57,10 @@ export const common = {
|
||||
more: 'plus',
|
||||
yearRange: 'Plage d\'années',
|
||||
clearAll: 'Tout effacer',
|
||||
availableSources_one: 'Disponible depuis {{count}} source',
|
||||
availableSources_other: 'Disponible depuis {{count}} sources',
|
||||
chooseSource: 'Choisir une source',
|
||||
currentSource: 'Source actuelle',
|
||||
openFromServer: 'Ouvrir depuis le serveur',
|
||||
libraryEmpty: 'Votre bibliothèque est vide.',
|
||||
};
|
||||
|
||||
@@ -67,5 +67,10 @@ export const common = {
|
||||
more: 'több',
|
||||
yearRange: 'Évtartomány',
|
||||
clearAll: 'Összes törlése',
|
||||
availableSources_one: '{{count}} forrásból érhető el',
|
||||
availableSources_other: '{{count}} forrásból érhető el',
|
||||
chooseSource: 'Forrás kiválasztása',
|
||||
currentSource: 'Jelenlegi forrás',
|
||||
openFromServer: 'Megnyitás erről a szerverről',
|
||||
libraryEmpty: 'A könyvtárad üres.',
|
||||
};
|
||||
|
||||
@@ -67,5 +67,10 @@ export const common = {
|
||||
more: 'altro',
|
||||
yearRange: 'Intervallo anni',
|
||||
clearAll: 'Pulisci tutto',
|
||||
availableSources_one: 'Disponibile da {{count}} fonte',
|
||||
availableSources_other: 'Disponibile da {{count}} fonti',
|
||||
chooseSource: 'Scegli una fonte',
|
||||
currentSource: 'Fonte attuale',
|
||||
openFromServer: 'Apri dal server',
|
||||
libraryEmpty: 'La libreria è vuota.',
|
||||
};
|
||||
|
||||
@@ -67,5 +67,10 @@ export const common = {
|
||||
more: 'さらに',
|
||||
yearRange: '年の範囲',
|
||||
clearAll: 'すべてクリア',
|
||||
availableSources_one: '{{count}} 件のソースで利用可能',
|
||||
availableSources_other: '{{count}} 件のソースで利用可能',
|
||||
chooseSource: 'ソースを選択',
|
||||
currentSource: '現在のソース',
|
||||
openFromServer: 'このサーバーから開く',
|
||||
libraryEmpty: 'ライブラリは空です。',
|
||||
};
|
||||
|
||||
@@ -57,5 +57,10 @@ export const common = {
|
||||
more: 'mer',
|
||||
yearRange: 'Årsspenn',
|
||||
clearAll: 'Tøm alt',
|
||||
availableSources_one: 'Tilgjengelig fra {{count}} kilde',
|
||||
availableSources_other: 'Tilgjengelig fra {{count}} kilder',
|
||||
chooseSource: 'Velg en kilde',
|
||||
currentSource: 'Gjeldende kilde',
|
||||
openFromServer: 'Åpne fra serveren',
|
||||
libraryEmpty: 'Biblioteket ditt er tomt.',
|
||||
};
|
||||
|
||||
@@ -57,5 +57,10 @@ export const common = {
|
||||
more: 'meer',
|
||||
yearRange: 'Jaarbereik',
|
||||
clearAll: 'Alles wissen',
|
||||
availableSources_one: 'Beschikbaar via {{count}} bron',
|
||||
availableSources_other: 'Beschikbaar via {{count}} bronnen',
|
||||
chooseSource: 'Kies een bron',
|
||||
currentSource: 'Huidige bron',
|
||||
openFromServer: 'Openen vanaf server',
|
||||
libraryEmpty: 'Je bibliotheek is leeg.',
|
||||
};
|
||||
|
||||
@@ -67,5 +67,10 @@ export const common = {
|
||||
more: 'więcej',
|
||||
yearRange: 'Zakres lat',
|
||||
clearAll: 'Wyczyść wszystko',
|
||||
availableSources_one: 'Dostępne z {{count}} źródła',
|
||||
availableSources_other: 'Dostępne z {{count}} źródeł',
|
||||
chooseSource: 'Wybierz źródło',
|
||||
currentSource: 'Bieżące źródło',
|
||||
openFromServer: 'Otwórz z serwera',
|
||||
libraryEmpty: 'Twoja biblioteka jest pusta.',
|
||||
};
|
||||
|
||||
@@ -67,5 +67,10 @@ export const common = {
|
||||
more: 'mai mult',
|
||||
yearRange: 'Interval de an',
|
||||
clearAll: 'Golește tot',
|
||||
availableSources_one: 'Disponibil din {{count}} sursă',
|
||||
availableSources_other: 'Disponibil din {{count}} surse',
|
||||
chooseSource: 'Alege o sursă',
|
||||
currentSource: 'Sursa curentă',
|
||||
openFromServer: 'Deschide de pe server',
|
||||
libraryEmpty: 'Biblioteca ta este goală.',
|
||||
};
|
||||
|
||||
@@ -67,5 +67,12 @@ export const common = {
|
||||
more: 'еще',
|
||||
yearRange: 'Диапазон лет',
|
||||
clearAll: 'Очистить всё',
|
||||
availableSources_one: 'Доступно из {{count}} источника',
|
||||
availableSources_few: 'Доступно из {{count}} источников',
|
||||
availableSources_many: 'Доступно из {{count}} источников',
|
||||
availableSources_other: 'Доступно из {{count}} источников',
|
||||
chooseSource: 'Выберите источник',
|
||||
currentSource: 'Текущий источник',
|
||||
openFromServer: 'Открыть с сервера',
|
||||
libraryEmpty: 'Ваша библиотека пуста.',
|
||||
};
|
||||
|
||||
@@ -57,5 +57,10 @@ export const common = {
|
||||
more: '更多',
|
||||
yearRange: '年份范围',
|
||||
clearAll: '清除全部',
|
||||
availableSources_one: '可从 {{count}} 个来源获取',
|
||||
availableSources_other: '可从 {{count}} 个来源获取',
|
||||
chooseSource: '选择来源',
|
||||
currentSource: '当前来源',
|
||||
openFromServer: '从此服务器打开',
|
||||
libraryEmpty: '你的音乐库是空的。',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
.entity-source-picker__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-card));
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.entity-source-picker__trigger:hover,
|
||||
.entity-source-picker__trigger[aria-expanded='true'] {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.entity-source-picker__trigger:focus-visible,
|
||||
.entity-source-picker__item:focus-visible {
|
||||
outline: 2px solid var(--focus-ring-color, var(--accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.entity-source-picker__menu {
|
||||
position: fixed;
|
||||
z-index: 10020;
|
||||
display: grid;
|
||||
width: min(360px, calc(100vw - 16px));
|
||||
max-height: min(360px, calc(100vh - 16px));
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--menu-bg, var(--bg-card));
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.entity-source-picker__item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.entity-source-picker__item:hover:not(:disabled) {
|
||||
background: var(--menu-item-hover, var(--bg-hover));
|
||||
}
|
||||
|
||||
.entity-source-picker__item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.entity-source-picker__item-label {
|
||||
overflow: hidden;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entity-source-picker__item-action {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
@import './ui-modal.css';
|
||||
@import './playback-delay-sleep-delayed-start-modal.css';
|
||||
@import './playback-alternative-modal.css';
|
||||
@import './entity-source-picker.css';
|
||||
@import './lucky-mix-pips-shared-by-the-inline-queue-lucky-cube-indicator.css';
|
||||
@import './playback-buffering-overlay.css';
|
||||
@import './playlist-edit-modal.css';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { libraryResolveEntitySources, type LibraryEntitySourceDto } from '@/lib/api/library';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import EntitySourcePicker from '@/ui/EntitySourcePicker';
|
||||
|
||||
vi.mock('@/lib/api/library', () => ({
|
||||
libraryResolveEntitySources: vi.fn(),
|
||||
}));
|
||||
|
||||
const resolveSources = vi.mocked(libraryResolveEntitySources);
|
||||
|
||||
const currentSource: LibraryEntitySourceDto = {
|
||||
serverId: 'server-a',
|
||||
id: 'album-a',
|
||||
libraryId: 'music-a',
|
||||
priority: 0,
|
||||
durationSec: null,
|
||||
suffix: null,
|
||||
bitRate: null,
|
||||
sizeBytes: null,
|
||||
starredAt: null,
|
||||
userRating: null,
|
||||
};
|
||||
|
||||
const alternateSource: LibraryEntitySourceDto = {
|
||||
...currentSource,
|
||||
serverId: 'server-b',
|
||||
id: 'album-b',
|
||||
libraryId: 'music-b',
|
||||
priority: 1,
|
||||
};
|
||||
|
||||
function props(onSelect = vi.fn()) {
|
||||
return {
|
||||
entityType: 'album' as const,
|
||||
anchorServerId: 'server-a',
|
||||
anchorId: 'album-a',
|
||||
scopes: [
|
||||
{ serverId: 'server-a', libraryId: 'music-a' },
|
||||
{ serverId: 'server-b', libraryId: 'music-b' },
|
||||
],
|
||||
servers: [
|
||||
{ id: 'server-a', name: 'Primary', url: 'https://a.test', username: 'alice', password: 'secret' },
|
||||
{ id: 'server-b', name: 'Archive', url: 'https://b.test', username: 'bob', password: 'secret' },
|
||||
],
|
||||
musicFoldersByServer: {
|
||||
'server-a': [{ id: 'music-a', name: 'Main library' }],
|
||||
'server-b': [{ id: 'music-b', name: 'Lossless' }],
|
||||
},
|
||||
onSelect,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resolveSources.mockReset();
|
||||
});
|
||||
|
||||
describe('EntitySourcePicker', () => {
|
||||
it('stays hidden when the entity has only one concrete source', async () => {
|
||||
resolveSources.mockResolvedValue([currentSource]);
|
||||
|
||||
renderWithProviders(<EntitySourcePicker {...props()} />);
|
||||
|
||||
await waitFor(() => expect(resolveSources).toHaveBeenCalledOnce());
|
||||
expect(screen.queryByRole('button', { name: /available from/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists labeled sources and selects only a concrete alternative', async () => {
|
||||
resolveSources.mockResolvedValue([currentSource, alternateSource]);
|
||||
const onSelect = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EntitySourcePicker {...props(onSelect)} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Available from 2 sources' }));
|
||||
|
||||
const current = screen.getByRole('menuitem', { name: /Primary · Main library.*Current source/ });
|
||||
const alternate = screen.getByRole('menuitem', { name: /Archive · Lossless.*Open from server/ });
|
||||
expect(current).toBeDisabled();
|
||||
expect(current).toHaveAttribute('aria-current', 'true');
|
||||
|
||||
await user.click(alternate);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(alternateSource);
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports menu keyboard navigation and restores trigger focus on Escape', async () => {
|
||||
const thirdSource = {
|
||||
...alternateSource,
|
||||
serverId: 'server-c',
|
||||
id: 'album-c',
|
||||
libraryId: 'unlisted-folder',
|
||||
priority: 2,
|
||||
};
|
||||
resolveSources.mockResolvedValue([currentSource, alternateSource, thirdSource]);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EntitySourcePicker {...props()} />);
|
||||
|
||||
const trigger = await screen.findByRole('button', { name: 'Available from 3 sources' });
|
||||
trigger.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
const alternatives = screen.getAllByRole('menuitem').filter(item => !item.hasAttribute('disabled'));
|
||||
expect(alternatives[0]).toHaveFocus();
|
||||
expect(screen.getByText('server-c · unlisted-folder')).toBeInTheDocument();
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(alternatives[1]).toHaveFocus();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Layers3, Server } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
libraryResolveEntitySources,
|
||||
type LibraryEntitySourceDto,
|
||||
type LibraryScopePair,
|
||||
type LibrarySourceEntityType,
|
||||
} from '@/lib/api/library';
|
||||
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
|
||||
|
||||
interface SourceServer {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface SourceFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface EntitySourcePickerProps {
|
||||
entityType: LibrarySourceEntityType;
|
||||
anchorServerId: string;
|
||||
anchorId: string;
|
||||
scopes: LibraryScopePair[];
|
||||
servers: SourceServer[];
|
||||
musicFoldersByServer: Record<string, SourceFolder[]>;
|
||||
onSelect: (source: LibraryEntitySourceDto) => void;
|
||||
}
|
||||
|
||||
function sourceLabel(
|
||||
source: LibraryEntitySourceDto,
|
||||
servers: SourceServer[],
|
||||
musicFoldersByServer: Record<string, SourceFolder[]>,
|
||||
): string {
|
||||
const server = servers.find(candidate => candidate.id === source.serverId);
|
||||
const serverLabel = server
|
||||
? serverListDisplayLabel(server, servers)
|
||||
: source.serverId;
|
||||
const folder = musicFoldersByServer[source.serverId]?.find(candidate => candidate.id === source.libraryId);
|
||||
const folderLabel = folder?.name || source.libraryId;
|
||||
return folderLabel ? `${serverLabel} · ${folderLabel}` : serverLabel;
|
||||
}
|
||||
|
||||
export default function EntitySourcePicker({
|
||||
entityType,
|
||||
anchorServerId,
|
||||
anchorId,
|
||||
scopes,
|
||||
servers,
|
||||
musicFoldersByServer,
|
||||
onSelect,
|
||||
}: EntitySourcePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [resolved, setResolved] = useState<{ key: string; sources: LibraryEntitySourceDto[] }>({
|
||||
key: '',
|
||||
sources: [],
|
||||
});
|
||||
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
const scopeKey = JSON.stringify(scopes);
|
||||
const requestKey = `${entityType}:${anchorServerId}:${anchorId}:${scopeKey}`;
|
||||
const sources = resolved.key === requestKey ? resolved.sources : [];
|
||||
const open = openKey === requestKey;
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchorServerId || !anchorId || scopes.length === 0) return;
|
||||
let cancelled = false;
|
||||
void libraryResolveEntitySources(anchorServerId, {
|
||||
entityType,
|
||||
anchorServerId,
|
||||
anchorId,
|
||||
scopes,
|
||||
}).then(result => {
|
||||
if (!cancelled) setResolved({ key: requestKey, sources: result });
|
||||
}).catch(error => {
|
||||
console.error('[psysonic] entity source lookup failed:', error);
|
||||
if (!cancelled) setResolved({ key: requestKey, sources: [] });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// scopeKey is the stable value dependency for the ordered scope array.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [anchorId, anchorServerId, entityType, scopeKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const button = buttonRef.current;
|
||||
const menu = menuRef.current;
|
||||
if (!button || !menu) return;
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
setCoords({
|
||||
x: Math.max(pad, Math.min(buttonRect.left, window.innerWidth - menuRect.width - pad)),
|
||||
y: Math.max(pad, Math.min(buttonRect.bottom + 6, window.innerHeight - menuRect.height - pad)),
|
||||
});
|
||||
menu.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled)')?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = () => {
|
||||
setOpenKey(null);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') { close(); return; }
|
||||
const items = [...(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)') ?? [])];
|
||||
if (items.length === 0) return;
|
||||
const current = items.indexOf(document.activeElement as HTMLElement);
|
||||
const focusAt = (index: number) => items[(index + items.length) % items.length].focus();
|
||||
if (event.key === 'ArrowDown') { event.preventDefault(); focusAt(current + 1); }
|
||||
else if (event.key === 'ArrowUp') { event.preventDefault(); focusAt(current < 0 ? -1 : current - 1); }
|
||||
else if (event.key === 'Home') { event.preventDefault(); focusAt(0); }
|
||||
else if (event.key === 'End') { event.preventDefault(); focusAt(items.length - 1); }
|
||||
};
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (
|
||||
!menuRef.current?.contains(event.target as Node)
|
||||
&& !buttonRef.current?.contains(event.target as Node)
|
||||
) close();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (sources.length < 2) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="entity-source-picker__trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpenKey(value => value === requestKey ? null : requestKey)}
|
||||
>
|
||||
<Layers3 size={14} aria-hidden="true" />
|
||||
{t('common.availableSources', { count: sources.length })}
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="entity-source-picker__menu"
|
||||
role="menu"
|
||||
aria-label={t('common.chooseSource')}
|
||||
style={{ left: coords.x, top: coords.y }}
|
||||
>
|
||||
{sources.map(source => {
|
||||
const current = source.serverId === anchorServerId && source.id === anchorId;
|
||||
const label = sourceLabel(source, servers, musicFoldersByServer);
|
||||
return (
|
||||
<button
|
||||
key={`${source.serverId}:${source.libraryId}:${source.id}`}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="entity-source-picker__item"
|
||||
disabled={current}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
setOpenKey(null);
|
||||
onSelect(source);
|
||||
}}
|
||||
>
|
||||
<Server size={16} aria-hidden="true" />
|
||||
<span className="entity-source-picker__item-label">{label}</span>
|
||||
<span className="entity-source-picker__item-action">
|
||||
{current ? t('common.currentSource') : t('common.openFromServer')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user