feat(library): expose entity source choices

This commit is contained in:
cucadmuh
2026-07-20 02:08:55 +03:00
parent 2b687cb391
commit 1f11f94d98
26 changed files with 621 additions and 8 deletions
@@ -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');
});
});
+25 -1
View File
@@ -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
+5
View File
@@ -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 }]);
});
});
+10
View File
@@ -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());
}
+5
View File
@@ -67,5 +67,10 @@ export const common = {
more: 'още',
yearRange: 'Диапазон на години',
clearAll: 'Изчисти всички',
availableSources_one: 'Налично от {{count}} източник',
availableSources_other: 'Налично от {{count}} източника',
chooseSource: 'Изберете източник',
currentSource: 'Текущ източник',
openFromServer: 'Отвори от сървъра',
libraryEmpty: 'Вашата библиотека е празна.',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -67,5 +67,10 @@ export const common = {
more: 'さらに',
yearRange: '年の範囲',
clearAll: 'すべてクリア',
availableSources_one: '{{count}} 件のソースで利用可能',
availableSources_other: '{{count}} 件のソースで利用可能',
chooseSource: 'ソースを選択',
currentSource: '現在のソース',
openFromServer: 'このサーバーから開く',
libraryEmpty: 'ライブラリは空です。',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -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.',
};
+5
View File
@@ -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ă.',
};
+7
View File
@@ -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: 'Ваша библиотека пуста.',
};
+5
View File
@@ -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;
}
+1
View File
@@ -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';
+116
View File
@@ -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();
});
});
+190
View File
@@ -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,
)}
</>
);
}