diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9d2c42..e7bf8104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Settings → Audio → Track transitions → AutoDJ:** choose **Auto** (content-driven overlap, up to 12 s) or **Limit** (slider 2–30 s, default 15 s when enabled) to cap how long AutoDJ may overlap tracks. * The cap applies to end-of-track planning, JS auto-advance, smooth skip, and Orbit transition sync; the audio engine accepts dynamic overlap overrides up to 30 s. +### Multiple genres in album details + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1186](https://github.com/Psychotoxical/psysonic/pull/1186)**, suggested by [@Thraka](https://github.com/Thraka) + +* Album details now surface every genre a release spans instead of just the first one: the main genre shows inline with a **+N** chip that opens the full, clickable list, each genre linking to its genre page. +* Genres combine album and track tags (matching the genre browser) and read from the local library index when it is ready, so they also work offline. + ## Changed diff --git a/src/components/AlbumHeader.test.tsx b/src/components/AlbumHeader.test.tsx new file mode 100644 index 00000000..84de801c --- /dev/null +++ b/src/components/AlbumHeader.test.tsx @@ -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(); + 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 = {}) => ({ + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + 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( + , + ); + 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( + , + ); + + 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(); + }); +}); diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index cdf2c2f9..cef34bd2 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -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(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('[role="menuitem"]')?.focus(); + }, [pos]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { onClose(); return; } + const items = Array.from( + ref.current?.querySelectorAll('[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( +
+ {genres.map(g => ( + + ))} +
, + 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(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 && } {lightbox} + {genreMenuPos && ( + { + setGenreMenuPos(null); + genreMoreRef.current?.focus(); + }} + /> + )} +
{resolvedCoverUrl && enableCoverArtBackground && ( <> @@ -203,9 +303,42 @@ export default function AlbumHeader({ linkClassName="album-detail-artist-link" />

+ {genreTags.length > 0 && ( +
+ + {genreTags.length > 1 && ( + + )} +
+ )}
{info.year && {info.year}} - {info.genre && · {info.genre}} · {songs.length} Tracks · {formatLongDuration(totalDuration)} {formatLabel && · {formatLabel}} diff --git a/src/hooks/useAlbumDetailData.ts b/src/hooks/useAlbumDetailData.ts index e4cb95c9..aa0284ab 100644 --- a/src/hooks/useAlbumDetailData.ts +++ b/src/hooks/useAlbumDetailData.ts @@ -17,6 +17,7 @@ import { offlineLocalBrowseEnabled, } from '../utils/offline/offlineLocalBrowse'; import { readDetailServerId } from '../utils/navigation/detailServerScope'; +import { libraryIsReady } from '../utils/library/libraryReady'; import { shouldAttemptSubsonicForActiveServer, shouldAttemptSubsonicForServer, @@ -96,8 +97,6 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe } }; - const libraryFirst = favoritesOfflineEnabled && !!detailServerId; - void (async () => { if (offlineBrowseActive && detailServerId) { const local = await resolveAlbum(detailServerId, id); @@ -115,7 +114,13 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe return; } - if (libraryFirst && detailServerId) { + // Index-first when the local SQLite index is ready, not only when the + // favorites-offline toggle is on — album detail then opens from SQLite + // (and offline) with the same genres genre browse derives. + const indexReady = !!detailServerId && await libraryIsReady(detailServerId); + const canLoadLocal = (favoritesOfflineEnabled || indexReady) && !!detailServerId; + + if (canLoadLocal && detailServerId) { try { const local = await resolveAlbum(detailServerId, id); if (local) { @@ -131,7 +136,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe : shouldAttemptSubsonicForActiveServer(); if (!detailNetworkAllowed) { - if (favoritesOfflineEnabled && detailServerId) { + if (canLoadLocal && detailServerId) { try { const local = await resolveAlbum(detailServerId, id); if (local) { @@ -159,7 +164,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe applyAlbumPayload(data); await loadRelatedAlbums(detailServerId, data.album.artistId, false, false); } catch { - if (favoritesOfflineEnabled && detailServerId) { + if (canLoadLocal && detailServerId) { try { const local = await loadAlbumFromLibraryIndex(detailServerId, id); if (local) { diff --git a/src/locales/de/albumDetail.ts b/src/locales/de/albumDetail.ts index 25b70e95..8f80b723 100644 --- a/src/locales/de/albumDetail.ts +++ b/src/locales/de/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} Tracks', goToArtist: 'Zu {{artist}} wechseln', moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen', + moreGenreAlbums: 'Weitere Alben im Genre {{genre}}', + genresModalTitle: 'Genres', + showAllGenres: 'Alle Genres anzeigen', trackTitle: 'Titel', trackAlbum: 'Album', trackArtist: 'Interpret', diff --git a/src/locales/en/albumDetail.ts b/src/locales/en/albumDetail.ts index 039628d8..d8f4e472 100644 --- a/src/locales/en/albumDetail.ts +++ b/src/locales/en/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} Tracks', goToArtist: 'Go to {{artist}}', moreLabelAlbums: 'More albums on {{label}}', + moreGenreAlbums: 'More albums in {{genre}}', + genresModalTitle: 'Genres', + showAllGenres: 'Show all genres', trackTitle: 'Title', trackAlbum: 'Album', trackArtist: 'Artist', diff --git a/src/locales/es/albumDetail.ts b/src/locales/es/albumDetail.ts index ec1b6486..1aa722f3 100644 --- a/src/locales/es/albumDetail.ts +++ b/src/locales/es/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} Pistas', goToArtist: 'Ir a {{artist}}', moreLabelAlbums: 'Más álbumes en {{label}}', + moreGenreAlbums: 'Más álbumes del género {{genre}}', + genresModalTitle: 'Géneros', + showAllGenres: 'Mostrar todos los géneros', trackTitle: 'Título', trackAlbum: 'Álbum', trackArtist: 'Artista', diff --git a/src/locales/fr/albumDetail.ts b/src/locales/fr/albumDetail.ts index 236a67ee..5e8b092c 100644 --- a/src/locales/fr/albumDetail.ts +++ b/src/locales/fr/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} pistes', goToArtist: 'Aller à {{artist}}', moreLabelAlbums: 'Plus d\'albums sur {{label}}', + moreGenreAlbums: 'Plus d\'albums du genre {{genre}}', + genresModalTitle: 'Genres', + showAllGenres: 'Afficher tous les genres', trackTitle: 'Titre', trackAlbum: 'Album', trackArtist: 'Artiste', diff --git a/src/locales/hu/albumDetail.ts b/src/locales/hu/albumDetail.ts index cee8dc3b..778c4a27 100644 --- a/src/locales/hu/albumDetail.ts +++ b/src/locales/hu/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} szám', goToArtist: 'Ugrás ide: {{artist}}', moreLabelAlbums: 'Több album a(z) {{label}} kiadótól', + moreGenreAlbums: 'Több album a(z) {{genre}} műfajban', + genresModalTitle: 'Műfajok', + showAllGenres: 'Összes műfaj megjelenítése', trackTitle: 'Cím', trackAlbum: 'Album', trackArtist: 'Előadó', diff --git a/src/locales/ja/albumDetail.ts b/src/locales/ja/albumDetail.ts index 0e33ecfe..6c11092e 100644 --- a/src/locales/ja/albumDetail.ts +++ b/src/locales/ja/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} 曲', goToArtist: '{{artist}} へ移動', moreLabelAlbums: '{{label}} の他のアルバム', + moreGenreAlbums: '{{genre}} ジャンルの他のアルバム', + genresModalTitle: 'ジャンル', + showAllGenres: 'すべてのジャンルを表示', trackTitle: 'タイトル', trackAlbum: 'アルバム', trackArtist: 'アーティスト', diff --git a/src/locales/nb/albumDetail.ts b/src/locales/nb/albumDetail.ts index 3994831f..9790f76e 100644 --- a/src/locales/nb/albumDetail.ts +++ b/src/locales/nb/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} spor', goToArtist: 'Gå til {{artist}}', moreLabelAlbums: 'Flere album på {{label}}', + moreGenreAlbums: 'Flere album i sjangeren {{genre}}', + genresModalTitle: 'Sjangere', + showAllGenres: 'Vis alle sjangere', trackTitle: 'Tittel', trackAlbum: 'Album', trackArtist: 'Artist', diff --git a/src/locales/nl/albumDetail.ts b/src/locales/nl/albumDetail.ts index 936d52a4..a4490603 100644 --- a/src/locales/nl/albumDetail.ts +++ b/src/locales/nl/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} nummers', goToArtist: 'Naar {{artist}}', moreLabelAlbums: 'Meer albums op {{label}}', + moreGenreAlbums: 'Meer albums in het genre {{genre}}', + genresModalTitle: 'Genres', + showAllGenres: 'Alle genres tonen', trackTitle: 'Titel', trackAlbum: 'Album', trackArtist: 'Artiest', diff --git a/src/locales/ro/albumDetail.ts b/src/locales/ro/albumDetail.ts index 78f9a405..91837587 100644 --- a/src/locales/ro/albumDetail.ts +++ b/src/locales/ro/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} Piese', goToArtist: 'Către {{artist}}', moreLabelAlbums: 'Mai multe albume în {{label}}', + moreGenreAlbums: 'Mai multe albume din genul {{genre}}', + genresModalTitle: 'Genuri', + showAllGenres: 'Afișează toate genurile', trackTitle: 'Titlu', trackAlbum: 'Album', trackArtist: 'Artist', diff --git a/src/locales/ru/albumDetail.ts b/src/locales/ru/albumDetail.ts index c5cfed63..abd749c1 100644 --- a/src/locales/ru/albumDetail.ts +++ b/src/locales/ru/albumDetail.ts @@ -29,6 +29,9 @@ export const albumDetail = { tracksCount: 'Композиций: {{n}}', goToArtist: 'Перейти к {{artist}}', moreLabelAlbums: 'Другие альбомы на {{label}}', + moreGenreAlbums: 'Другие альбомы в жанре {{genre}}', + genresModalTitle: 'Жанры', + showAllGenres: 'Показать все жанры', trackTitle: 'Название', trackAlbum: 'Альбом', trackArtist: 'Исполнитель', diff --git a/src/locales/zh/albumDetail.ts b/src/locales/zh/albumDetail.ts index 66abf19e..3986d8f5 100644 --- a/src/locales/zh/albumDetail.ts +++ b/src/locales/zh/albumDetail.ts @@ -28,6 +28,9 @@ export const albumDetail = { tracksCount: '{{n}} 首曲目', goToArtist: '前往 {{artist}}', moreLabelAlbums: '{{label}} 的更多专辑', + moreGenreAlbums: '{{genre}} 类型的更多专辑', + genresModalTitle: '流派', + showAllGenres: '显示所有流派', trackTitle: '标题', trackAlbum: '专辑', trackArtist: '艺术家', diff --git a/src/pages/Genres.tsx b/src/pages/Genres.tsx index 903cbc4d..d39cbe4a 100644 --- a/src/pages/Genres.tsx +++ b/src/pages/Genres.tsx @@ -11,19 +11,7 @@ import { fetchGenreCatalog, filterGenresWithContent } from '../utils/library/gen import { libraryScopeForServer } from '../api/subsonicClient'; import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache'; import { resolveIndexKey } from '../utils/server/serverIndexKey'; - -const CTP_COLORS = [ - 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)', - 'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)', - 'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)', - 'var(--ctp-blue)', 'var(--ctp-lavender)', -]; - -function genreColor(name: string): string { - let h = 0; - for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; - return CTP_COLORS[h % CTP_COLORS.length]; -} +import { genreColor } from '../utils/library/genreColor'; const SCROLL_KEY = 'genres-scroll'; const FONT_MIN_REM = 0.78; diff --git a/src/styles/components/album-detail.css b/src/styles/components/album-detail.css index e1fc1a6e..8566024b 100644 --- a/src/styles/components/album-detail.css +++ b/src/styles/components/album-detail.css @@ -1 +1,82 @@ /* ─ Album Detail ─ */ + +/* Shared Accent-Pill for the album-detail genre row (primary genre + "+N"). */ +.album-detail-genre-pill { + appearance: none; + vertical-align: middle; + padding: 0.1em 0.45em; + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); + background: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.album-detail-genre-pill:hover { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + color: var(--accent); +} + +.album-detail-genre-pill:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} + +.album-detail-genre-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 2px 0 8px; +} + +/* Cursor-anchored genre list (context-menu style) opened from the "+N" chip. */ +.genre-menu { + position: fixed; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 130px; + max-width: 280px; + max-height: 60vh; + overflow-y: auto; + padding: 6px; + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); +} + +/* Genre chips mirror the .genre-pill styling on the Genres page. */ +.genre-menu-item { + appearance: none; + text-align: left; + padding: 0.12em 0.5em; + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--genre-color, var(--accent)) 22%, transparent); + background: color-mix(in srgb, var(--genre-color, var(--accent)) 6%, transparent); + color: var(--text-primary); + font-size: 11px; + font-weight: 600; + font-family: inherit; + white-space: nowrap; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.genre-menu-item:hover { + background: color-mix(in srgb, var(--genre-color, var(--accent)) 14%, transparent); + border-color: color-mix(in srgb, var(--genre-color, var(--accent)) 45%, transparent); + color: var(--genre-color, var(--accent)); +} + +.genre-menu-item:focus-visible { + outline: var(--focus-ring-width) solid var(--genre-color, var(--focus-ring-color)); + outline-offset: var(--focus-ring-offset); +} diff --git a/src/utils/library/genreColor.ts b/src/utils/library/genreColor.ts new file mode 100644 index 00000000..5bf48220 --- /dev/null +++ b/src/utils/library/genreColor.ts @@ -0,0 +1,13 @@ +const CTP_COLORS = [ + 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)', + 'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)', + 'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)', + 'var(--ctp-blue)', 'var(--ctp-lavender)', +]; + +/** Stable Catppuccin-palette colour for a genre name — same hash on the Genres page and album detail. */ +export function genreColor(name: string): string { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; + return CTP_COLORS[h % CTP_COLORS.length]; +} diff --git a/src/utils/library/genreTags.test.ts b/src/utils/library/genreTags.test.ts index eb5fc728..f6c7cf61 100644 --- a/src/utils/library/genreTags.test.ts +++ b/src/utils/library/genreTags.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { genreTagsFor, parseItemGenres, splitGenreTags } from './genreTags'; +import { deriveAlbumGenreTags, genreTagsFor, parseItemGenres, splitGenreTags } from './genreTags'; describe('splitGenreTags', () => { it('splits Navidrome-default separators and dedupes case-insensitively', () => { @@ -51,3 +51,37 @@ describe('genreTagsFor', () => { ]); }); }); + +describe('deriveAlbumGenreTags', () => { + it('returns album tags only when songs carry no extra genres', () => { + expect(deriveAlbumGenreTags( + { genres: [{ name: 'Power Metal' }, { name: 'Rock' }] }, + [{ genres: [{ name: 'Rock' }] }], + )).toEqual(['Power Metal', 'Rock']); + }); + + it('surfaces track-only genres when the album has none', () => { + expect(deriveAlbumGenreTags( + {}, + [{ genres: [{ name: 'Metal' }] }, { genres: [{ name: 'Jazz' }] }], + )).toEqual(['Metal', 'Jazz']); + }); + + it('appends additional track genres after the primary album genres', () => { + expect(deriveAlbumGenreTags( + { genre: 'Rock' }, + [{ genre: 'Metal' }], + )).toEqual(['Rock', 'Metal']); + }); + + it('dedupes a track genre that equals an album genre, keeping primary position', () => { + expect(deriveAlbumGenreTags( + { genres: [{ name: 'Power Metal' }] }, + [{ genres: [{ name: 'power metal' }] }, { genres: [{ name: 'Heavy Metal' }] }], + )).toEqual(['Power Metal', 'Heavy Metal']); + }); + + it('splits a legacy compound album genre string via genreTagsFor', () => { + expect(deriveAlbumGenreTags({ genre: 'Rock; Metal' }, [])).toEqual(['Rock', 'Metal']); + }); +}); diff --git a/src/utils/library/genreTags.ts b/src/utils/library/genreTags.ts index b1a2a6f6..3383d3df 100644 --- a/src/utils/library/genreTags.ts +++ b/src/utils/library/genreTags.ts @@ -63,3 +63,28 @@ export function genreTagsFor(item: GenreTagSource): string[] { const g = item.genre?.trim(); return g ? splitGenreTags(g) : []; } + +/** + * All genres a release should surface: album-level tags first (authoritative for + * order and "what the release is"), then track-only tags appended in track order, + * case-insensitively deduped against what is already shown. Mirrors what genre + * browse derives from `track_genre`, but in-memory from the already-loaded songs — + * no extra SQL round-trip. + */ +export function deriveAlbumGenreTags( + album: GenreTagSource, + songs: GenreTagSource[] = [], +): string[] { + const primary = genreTagsFor(album); + const seen = new Set(primary.map(g => g.toLocaleLowerCase())); + const out = [...primary]; + for (const song of songs) { + for (const g of genreTagsFor(song)) { + const key = g.toLocaleLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(g); + } + } + return out; +} diff --git a/src/utils/offline/offlineMediaResolve.test.ts b/src/utils/offline/offlineMediaResolve.test.ts index 900a6256..bc0f62c1 100644 --- a/src/utils/offline/offlineMediaResolve.test.ts +++ b/src/utils/offline/offlineMediaResolve.test.ts @@ -19,6 +19,7 @@ const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: const getAlbumForServerMock = vi.fn((_serverId: string, _albumId: string) => ({})); const getArtistForServerMock = vi.fn((_serverId: string, _artistId: string) => ({})); const getPlaylistForServerMock = vi.fn((_serverId: string, _playlistId: string) => ({})); +const libraryIsReadyMock = vi.fn(async (_serverId: string) => false); vi.mock('./offlineBrowseMode', () => ({ isOfflineBrowseActive: () => isOfflineBrowseActiveMock(), @@ -61,12 +62,18 @@ vi.mock('../../api/subsonicPlaylists', () => ({ getPlaylistForServerMock(serverId, playlistId), })); +vi.mock('../library/libraryReady', () => ({ + libraryIsReady: (serverId: string) => libraryIsReadyMock(serverId), +})); + describe('offlineMediaResolve', () => { beforeEach(() => { isOfflineBrowseActiveMock.mockReturnValue(false); offlineLocalBrowseEnabledMock.mockReturnValue(false); playlistsOfflineBrowseEnabledMock.mockReturnValue(false); shouldAttemptSubsonicForServerMock.mockReturnValue(true); + libraryIsReadyMock.mockReset(); + libraryIsReadyMock.mockResolvedValue(false); loadAlbumFromLocalPlaybackMock.mockReset(); loadArtistFromLocalPlaybackMock.mockReset(); loadAlbumFromLibraryIndexMock.mockReset(); @@ -114,6 +121,31 @@ describe('offlineMediaResolve', () => { expect(result?.album.name).toBe('Idx'); }); + it('resolveAlbum prefers the library index when ready, without hitting the network', async () => { + libraryIsReadyMock.mockResolvedValue(true); + loadAlbumFromLibraryIndexMock.mockResolvedValue({ + album: { id: 'alb-1', name: 'Idx' }, + songs: [{ id: 't1' }], + }); + const result = await resolveAlbum('srv-1', 'alb-1'); + expect(loadAlbumFromLibraryIndexMock).toHaveBeenCalledWith('srv-1', 'alb-1'); + expect(result?.album.name).toBe('Idx'); + expect(getAlbumForServerMock).not.toHaveBeenCalled(); + }); + + it('resolveAlbum falls back to network when the index is ready but has no hit', async () => { + libraryIsReadyMock.mockResolvedValue(true); + loadAlbumFromLibraryIndexMock.mockResolvedValue(null); + getAlbumForServerMock.mockResolvedValue({ + album: { id: 'alb-1', name: 'Net' }, + songs: [{ id: 't1' }, { id: 't2' }], + }); + const result = await resolveAlbum('srv-1', 'alb-1'); + expect(loadAlbumFromLibraryIndexMock).toHaveBeenCalledWith('srv-1', 'alb-1'); + expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1'); + expect(result?.album.name).toBe('Net'); + }); + it('resolveAlbumForActiveServer uses active server id', async () => { getAlbumForServerMock.mockResolvedValue({ album: { id: 'alb-2' }, diff --git a/src/utils/offline/offlineMediaResolve.ts b/src/utils/offline/offlineMediaResolve.ts index 382126ed..96aa71d3 100644 --- a/src/utils/offline/offlineMediaResolve.ts +++ b/src/utils/offline/offlineMediaResolve.ts @@ -10,6 +10,7 @@ import type { import { useAuthStore } from '../../store/authStore'; import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard'; import { isOfflineBrowseActive } from './offlineBrowseMode'; +import { libraryIsReady } from '../library/libraryReady'; import { loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex, @@ -27,8 +28,10 @@ import { export type ResolvedAlbum = { album: SubsonicAlbum; songs: SubsonicSong[] }; /** - * Album detail / play / enqueue: network album when reachable (complete track list); - * local bytes or library index when offline browse is active. + * Album detail / play / enqueue: the local SQLite index first when it is ready + * (same data genre browse reads, no network round-trip, works offline), then the + * network album when reachable (complete track list), then the index as fallback. + * Local bytes when offline browse is active. */ export async function resolveAlbum( serverId: string, @@ -37,6 +40,12 @@ export async function resolveAlbum( if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) { return loadAlbumFromLocalPlayback(serverId, albumId); } + if (await libraryIsReady(serverId)) { + try { + const hit = await loadAlbumFromLibraryIndex(serverId, albumId); + if (hit) return hit; + } catch { /* index error → network fallback */ } + } const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled; const networkAllowed = shouldAttemptSubsonicForServer(serverId);