mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
feat(album): show all genres in album details, linkable to genre pages (#1186)
* feat(album): show all genres in album details, linkable to genre pages The album header listed only a single genre. It now renders every genre from the album's OpenSubsonic genres[] (falling back to splitting the legacy genre string), each linking to its genre page via the existing genre route. * docs(changelog): multiple genres in album details (#1186) * refactor(genres): extract genreColor into a shared util * feat(album): union album and track genres behind a +N cursor menu * feat(album): read album detail from the library index when ready * docs(changelog): expand the album genres entry * feat(album): genre pill row with keyboard-navigable popover * test(offline): cover index-first resolveAlbum path with mocked libraryIsReady * refactor(styles): move album-detail genre styles out of cover-lightbox.css
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { 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 './AlbumHeader';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
|
||||
const navigate = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importActual => {
|
||||
const actual = await importActual<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => navigate };
|
||||
});
|
||||
|
||||
// Genre-unrelated dependencies — stub so the test stays focused on the meta row.
|
||||
vi.mock('../cover/useLibraryCoverRef', () => ({ useAlbumCoverRef: () => undefined }));
|
||||
vi.mock('../cover/lightbox', () => ({ useCoverLightboxSrc: () => ({ open: vi.fn(), lightbox: null }) }));
|
||||
vi.mock('../hooks/useAlbumDetailBack', () => ({ useAlbumDetailBack: () => vi.fn() }));
|
||||
vi.mock('../hooks/useIsMobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('../store/themeStore', () => ({ useThemeStore: () => false }));
|
||||
vi.mock('./StarRating', () => ({ default: () => null }));
|
||||
vi.mock('./OpenArtistRefInline', () => ({ OpenArtistRefInline: () => null }));
|
||||
vi.mock('../cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
|
||||
|
||||
function baseProps() {
|
||||
return {
|
||||
headerArtistRefs: [],
|
||||
songs: [] as SubsonicSong[],
|
||||
resolvedCoverUrl: null,
|
||||
isStarred: false,
|
||||
downloadProgress: null,
|
||||
offlineStatus: 'none' as const,
|
||||
offlineProgress: null,
|
||||
bio: null,
|
||||
bioOpen: false,
|
||||
onToggleStar: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
onCacheOffline: vi.fn(),
|
||||
onRemoveOffline: vi.fn(),
|
||||
onPlayAll: vi.fn(),
|
||||
onEnqueueAll: vi.fn(),
|
||||
onBio: vi.fn(),
|
||||
onCloseBio: vi.fn(),
|
||||
entityRatingValue: 0,
|
||||
onEntityRatingChange: vi.fn(),
|
||||
entityRatingSupport: 'unknown' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const albumInfo = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'al1', name: 'Album', artist: 'Artist', artistId: 'a1', ...over,
|
||||
});
|
||||
|
||||
describe('AlbumHeader genres', () => {
|
||||
it('shows the primary genre inline and the rest in a cursor menu', async () => {
|
||||
navigate.mockClear();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<AlbumHeader
|
||||
{...baseProps()}
|
||||
info={albumInfo({ genres: [{ name: 'Power Metal' }, { name: 'Rock' }] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Primary genre is a link; the extra genre stays hidden until the menu opens.
|
||||
expect(screen.getByRole('button', { name: 'More albums in Power Metal' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Rock')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show all genres' }));
|
||||
|
||||
// The menu lists only the remaining genres — the primary is not repeated.
|
||||
expect(screen.getAllByText('Power Metal')).toHaveLength(1);
|
||||
const rock = screen.getByText('Rock');
|
||||
await user.click(rock);
|
||||
expect(navigate).toHaveBeenCalledWith('/genres/Rock', { state: { returnTo: '/album/al1' } });
|
||||
});
|
||||
|
||||
it('unions track-level genres after the album genres', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<AlbumHeader
|
||||
{...baseProps()}
|
||||
songs={[{ id: 't1', duration: 100, genres: [{ name: 'Heavy Metal' }] } as unknown as SubsonicSong]}
|
||||
info={albumInfo({ genres: [{ name: 'Power Metal' }] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Album has one genre, the track adds another → +1 chip, extra genre in the menu.
|
||||
await user.click(screen.getByRole('button', { name: 'Show all genres' }));
|
||||
expect(screen.getByText('Heavy Metal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no +N control for a single genre', () => {
|
||||
renderWithProviders(
|
||||
<AlbumHeader {...baseProps()} info={albumInfo({ genres: [{ name: 'Power Metal' }] })} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'More albums in Power Metal' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Show all genres' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to splitting the legacy genre string', () => {
|
||||
renderWithProviders(
|
||||
<AlbumHeader {...baseProps()} info={albumInfo({ genre: 'Rock; Metal' })} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'More albums in Rock' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show all genres' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens via keyboard with focus inside the menu, arrow-navigates, and restores focus on close', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<AlbumHeader
|
||||
{...baseProps()}
|
||||
info={albumInfo({ genres: [{ name: 'Power Metal' }, { name: 'Rock' }, { name: 'Jazz' }] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const more = screen.getByRole('button', { name: 'Show all genres' });
|
||||
more.focus();
|
||||
// Enter activates the chip from the keyboard — focus must land on the first item.
|
||||
await user.keyboard('{Enter}');
|
||||
const items = screen.getAllByRole('menuitem');
|
||||
expect(items[0]).toHaveFocus();
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(items[1]).toHaveFocus();
|
||||
|
||||
// Escape closes the menu and returns focus to the +N trigger.
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
expect(more).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
|
||||
import React from 'react';
|
||||
import type { EntityRatingSupportLevel, SubsonicItemGenre, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
||||
@@ -20,6 +20,8 @@ import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
import { deriveAlbumGenreTags } from '../utils/library/genreTags';
|
||||
import { genreColor } from '../utils/library/genreColor';
|
||||
|
||||
/** 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.
|
||||
@@ -54,6 +56,83 @@ function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||
}
|
||||
|
||||
|
||||
/** Cursor-anchored genre list (context-menu style) for the "+N" chip. */
|
||||
function GenreMenu({
|
||||
genres, pos, onPick, onClose,
|
||||
}: {
|
||||
genres: string[];
|
||||
pos: { x: number; y: number };
|
||||
onPick: (genre: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [coords, setCoords] = useState(pos);
|
||||
|
||||
// Clamp into the viewport once the menu has measured its own size, then move
|
||||
// focus to the first genre so keyboard users land inside the menu.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
setCoords({
|
||||
x: Math.max(pad, Math.min(pos.x, window.innerWidth - rect.width - pad)),
|
||||
y: Math.max(pad, Math.min(pos.y, window.innerHeight - rect.height - pad)),
|
||||
});
|
||||
el.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||
}, [pos]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { onClose(); return; }
|
||||
const items = Array.from(
|
||||
ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [],
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
const focusAt = (i: number) => items[(i + items.length) % items.length].focus();
|
||||
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); focusAt(at + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); focusAt(at < 0 ? -1 : at - 1); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); focusAt(0); }
|
||||
else if (e.key === 'End') { e.preventDefault(); focusAt(items.length - 1); }
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="genre-menu"
|
||||
role="menu"
|
||||
aria-label={t('albumDetail.genresModalTitle')}
|
||||
style={{ left: coords.x, top: coords.y }}
|
||||
>
|
||||
{genres.map(g => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="genre-menu-item"
|
||||
style={{ '--genre-color': genreColor(g) } as CSSProperties}
|
||||
onClick={() => onPick(g)}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface AlbumInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -61,6 +140,8 @@ interface AlbumInfo {
|
||||
artistId: string;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
/** OpenSubsonic atomic genres — preferred over the legacy `genre` string. */
|
||||
genres?: SubsonicItemGenre[];
|
||||
coverArt?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
@@ -139,6 +220,13 @@ export default function AlbumHeader({
|
||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||
const isNewAlbum = isAlbumRecentlyAdded(info.created);
|
||||
const showBioButton = !isVariousArtistsLabel(info.artist);
|
||||
const genreTags = deriveAlbumGenreTags(info, songs);
|
||||
const [genreMenuPos, setGenreMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const genreMoreRef = useRef<HTMLButtonElement>(null);
|
||||
const goToGenre = (genre: string) => {
|
||||
setGenreMenuPos(null);
|
||||
navigate(`/genres/${encodeURIComponent(genre)}`, { state: { returnTo: `/album/${info.id}` } });
|
||||
};
|
||||
|
||||
const handleShareAlbum = async () => {
|
||||
try {
|
||||
@@ -155,6 +243,18 @@ export default function AlbumHeader({
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
{lightbox}
|
||||
|
||||
{genreMenuPos && (
|
||||
<GenreMenu
|
||||
genres={genreTags.slice(1)}
|
||||
pos={genreMenuPos}
|
||||
onPick={goToGenre}
|
||||
onClose={() => {
|
||||
setGenreMenuPos(null);
|
||||
genreMoreRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||
<>
|
||||
@@ -203,9 +303,42 @@ export default function AlbumHeader({
|
||||
linkClassName="album-detail-artist-link"
|
||||
/>
|
||||
</p>
|
||||
{genreTags.length > 0 && (
|
||||
<div className="album-detail-genre-row">
|
||||
<button
|
||||
className="album-detail-genre-pill"
|
||||
data-tooltip={t('albumDetail.moreGenreAlbums', { genre: genreTags[0] })}
|
||||
aria-label={t('albumDetail.moreGenreAlbums', { genre: genreTags[0] })}
|
||||
onClick={() => goToGenre(genreTags[0])}
|
||||
>
|
||||
{genreTags[0]}
|
||||
</button>
|
||||
{genreTags.length > 1 && (
|
||||
<button
|
||||
ref={genreMoreRef}
|
||||
className="album-detail-genre-pill"
|
||||
data-tooltip={t('albumDetail.showAllGenres')}
|
||||
aria-label={t('albumDetail.showAllGenres')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={genreMenuPos != null}
|
||||
onClick={e => {
|
||||
// Keyboard activation (Enter/Space) reports clientX/Y = 0 →
|
||||
// anchor below the chip instead of the viewport corner.
|
||||
if (e.detail === 0) {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setGenreMenuPos({ x: r.left, y: r.bottom + 4 });
|
||||
} else {
|
||||
setGenreMenuPos({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{`+${genreTags.length - 1}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
{info.genre && <span>· {info.genre}</span>}
|
||||
<span>· {songs.length} Tracks</span>
|
||||
<span>· {formatLongDuration(totalDuration)}</span>
|
||||
{formatLabel && <span>· {formatLabel}</span>}
|
||||
|
||||
Reference in New Issue
Block a user