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:
Psychotoxical
2026-06-25 13:16:24 +02:00
committed by GitHub
parent 4aa6427727
commit 8b89596fcf
22 changed files with 517 additions and 24 deletions
+136 -3
View File
@@ -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>}