mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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.
|
* **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.
|
* 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
|
## Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { EntityRatingSupportLevel, SubsonicItemGenre, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React from 'react';
|
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Play, Heart, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
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 { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||||
import { tooltipAttrs } from './tooltipAttrs';
|
import { tooltipAttrs } from './tooltipAttrs';
|
||||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
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`
|
/** 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.
|
* 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 {
|
interface AlbumInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -61,6 +140,8 @@ interface AlbumInfo {
|
|||||||
artistId: string;
|
artistId: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
|
/** OpenSubsonic atomic genres — preferred over the legacy `genre` string. */
|
||||||
|
genres?: SubsonicItemGenre[];
|
||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
recordLabel?: string;
|
recordLabel?: string;
|
||||||
created?: 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 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 isNewAlbum = isAlbumRecentlyAdded(info.created);
|
||||||
const showBioButton = !isVariousArtistsLabel(info.artist);
|
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 () => {
|
const handleShareAlbum = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -155,6 +243,18 @@ export default function AlbumHeader({
|
|||||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||||
{lightbox}
|
{lightbox}
|
||||||
|
|
||||||
|
{genreMenuPos && (
|
||||||
|
<GenreMenu
|
||||||
|
genres={genreTags.slice(1)}
|
||||||
|
pos={genreMenuPos}
|
||||||
|
onPick={goToGenre}
|
||||||
|
onClose={() => {
|
||||||
|
setGenreMenuPos(null);
|
||||||
|
genreMoreRef.current?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="album-detail-header">
|
<div className="album-detail-header">
|
||||||
{resolvedCoverUrl && enableCoverArtBackground && (
|
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||||
<>
|
<>
|
||||||
@@ -203,9 +303,42 @@ export default function AlbumHeader({
|
|||||||
linkClassName="album-detail-artist-link"
|
linkClassName="album-detail-artist-link"
|
||||||
/>
|
/>
|
||||||
</p>
|
</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">
|
<div className="album-detail-info">
|
||||||
{info.year && <span>{info.year}</span>}
|
{info.year && <span>{info.year}</span>}
|
||||||
{info.genre && <span>· {info.genre}</span>}
|
|
||||||
<span>· {songs.length} Tracks</span>
|
<span>· {songs.length} Tracks</span>
|
||||||
<span>· {formatLongDuration(totalDuration)}</span>
|
<span>· {formatLongDuration(totalDuration)}</span>
|
||||||
{formatLabel && <span>· {formatLabel}</span>}
|
{formatLabel && <span>· {formatLabel}</span>}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
offlineLocalBrowseEnabled,
|
offlineLocalBrowseEnabled,
|
||||||
} from '../utils/offline/offlineLocalBrowse';
|
} from '../utils/offline/offlineLocalBrowse';
|
||||||
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||||
|
import { libraryIsReady } from '../utils/library/libraryReady';
|
||||||
import {
|
import {
|
||||||
shouldAttemptSubsonicForActiveServer,
|
shouldAttemptSubsonicForActiveServer,
|
||||||
shouldAttemptSubsonicForServer,
|
shouldAttemptSubsonicForServer,
|
||||||
@@ -96,8 +97,6 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const libraryFirst = favoritesOfflineEnabled && !!detailServerId;
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (offlineBrowseActive && detailServerId) {
|
if (offlineBrowseActive && detailServerId) {
|
||||||
const local = await resolveAlbum(detailServerId, id);
|
const local = await resolveAlbum(detailServerId, id);
|
||||||
@@ -115,7 +114,13 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
return;
|
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 {
|
try {
|
||||||
const local = await resolveAlbum(detailServerId, id);
|
const local = await resolveAlbum(detailServerId, id);
|
||||||
if (local) {
|
if (local) {
|
||||||
@@ -131,7 +136,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
: shouldAttemptSubsonicForActiveServer();
|
: shouldAttemptSubsonicForActiveServer();
|
||||||
|
|
||||||
if (!detailNetworkAllowed) {
|
if (!detailNetworkAllowed) {
|
||||||
if (favoritesOfflineEnabled && detailServerId) {
|
if (canLoadLocal && detailServerId) {
|
||||||
try {
|
try {
|
||||||
const local = await resolveAlbum(detailServerId, id);
|
const local = await resolveAlbum(detailServerId, id);
|
||||||
if (local) {
|
if (local) {
|
||||||
@@ -159,7 +164,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
applyAlbumPayload(data);
|
applyAlbumPayload(data);
|
||||||
await loadRelatedAlbums(detailServerId, data.album.artistId, false, false);
|
await loadRelatedAlbums(detailServerId, data.album.artistId, false, false);
|
||||||
} catch {
|
} catch {
|
||||||
if (favoritesOfflineEnabled && detailServerId) {
|
if (canLoadLocal && detailServerId) {
|
||||||
try {
|
try {
|
||||||
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
|
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
|
||||||
if (local) {
|
if (local) {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} Tracks',
|
tracksCount: '{{n}} Tracks',
|
||||||
goToArtist: 'Zu {{artist}} wechseln',
|
goToArtist: 'Zu {{artist}} wechseln',
|
||||||
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
|
moreLabelAlbums: 'Weitere Alben von {{label}} anzeigen',
|
||||||
|
moreGenreAlbums: 'Weitere Alben im Genre {{genre}}',
|
||||||
|
genresModalTitle: 'Genres',
|
||||||
|
showAllGenres: 'Alle Genres anzeigen',
|
||||||
trackTitle: 'Titel',
|
trackTitle: 'Titel',
|
||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Interpret',
|
trackArtist: 'Interpret',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} Tracks',
|
tracksCount: '{{n}} Tracks',
|
||||||
goToArtist: 'Go to {{artist}}',
|
goToArtist: 'Go to {{artist}}',
|
||||||
moreLabelAlbums: 'More albums on {{label}}',
|
moreLabelAlbums: 'More albums on {{label}}',
|
||||||
|
moreGenreAlbums: 'More albums in {{genre}}',
|
||||||
|
genresModalTitle: 'Genres',
|
||||||
|
showAllGenres: 'Show all genres',
|
||||||
trackTitle: 'Title',
|
trackTitle: 'Title',
|
||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artist',
|
trackArtist: 'Artist',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} Pistas',
|
tracksCount: '{{n}} Pistas',
|
||||||
goToArtist: 'Ir a {{artist}}',
|
goToArtist: 'Ir a {{artist}}',
|
||||||
moreLabelAlbums: 'Más álbumes en {{label}}',
|
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',
|
trackTitle: 'Título',
|
||||||
trackAlbum: 'Álbum',
|
trackAlbum: 'Álbum',
|
||||||
trackArtist: 'Artista',
|
trackArtist: 'Artista',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} pistes',
|
tracksCount: '{{n}} pistes',
|
||||||
goToArtist: 'Aller à {{artist}}',
|
goToArtist: 'Aller à {{artist}}',
|
||||||
moreLabelAlbums: 'Plus d\'albums sur {{label}}',
|
moreLabelAlbums: 'Plus d\'albums sur {{label}}',
|
||||||
|
moreGenreAlbums: 'Plus d\'albums du genre {{genre}}',
|
||||||
|
genresModalTitle: 'Genres',
|
||||||
|
showAllGenres: 'Afficher tous les genres',
|
||||||
trackTitle: 'Titre',
|
trackTitle: 'Titre',
|
||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artiste',
|
trackArtist: 'Artiste',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} szám',
|
tracksCount: '{{n}} szám',
|
||||||
goToArtist: 'Ugrás ide: {{artist}}',
|
goToArtist: 'Ugrás ide: {{artist}}',
|
||||||
moreLabelAlbums: 'Több album a(z) {{label}} kiadótól',
|
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',
|
trackTitle: 'Cím',
|
||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Előadó',
|
trackArtist: 'Előadó',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} 曲',
|
tracksCount: '{{n}} 曲',
|
||||||
goToArtist: '{{artist}} へ移動',
|
goToArtist: '{{artist}} へ移動',
|
||||||
moreLabelAlbums: '{{label}} の他のアルバム',
|
moreLabelAlbums: '{{label}} の他のアルバム',
|
||||||
|
moreGenreAlbums: '{{genre}} ジャンルの他のアルバム',
|
||||||
|
genresModalTitle: 'ジャンル',
|
||||||
|
showAllGenres: 'すべてのジャンルを表示',
|
||||||
trackTitle: 'タイトル',
|
trackTitle: 'タイトル',
|
||||||
trackAlbum: 'アルバム',
|
trackAlbum: 'アルバム',
|
||||||
trackArtist: 'アーティスト',
|
trackArtist: 'アーティスト',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} spor',
|
tracksCount: '{{n}} spor',
|
||||||
goToArtist: 'Gå til {{artist}}',
|
goToArtist: 'Gå til {{artist}}',
|
||||||
moreLabelAlbums: 'Flere album på {{label}}',
|
moreLabelAlbums: 'Flere album på {{label}}',
|
||||||
|
moreGenreAlbums: 'Flere album i sjangeren {{genre}}',
|
||||||
|
genresModalTitle: 'Sjangere',
|
||||||
|
showAllGenres: 'Vis alle sjangere',
|
||||||
trackTitle: 'Tittel',
|
trackTitle: 'Tittel',
|
||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artist',
|
trackArtist: 'Artist',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} nummers',
|
tracksCount: '{{n}} nummers',
|
||||||
goToArtist: 'Naar {{artist}}',
|
goToArtist: 'Naar {{artist}}',
|
||||||
moreLabelAlbums: 'Meer albums op {{label}}',
|
moreLabelAlbums: 'Meer albums op {{label}}',
|
||||||
|
moreGenreAlbums: 'Meer albums in het genre {{genre}}',
|
||||||
|
genresModalTitle: 'Genres',
|
||||||
|
showAllGenres: 'Alle genres tonen',
|
||||||
trackTitle: 'Titel',
|
trackTitle: 'Titel',
|
||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artiest',
|
trackArtist: 'Artiest',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} Piese',
|
tracksCount: '{{n}} Piese',
|
||||||
goToArtist: 'Către {{artist}}',
|
goToArtist: 'Către {{artist}}',
|
||||||
moreLabelAlbums: 'Mai multe albume în {{label}}',
|
moreLabelAlbums: 'Mai multe albume în {{label}}',
|
||||||
|
moreGenreAlbums: 'Mai multe albume din genul {{genre}}',
|
||||||
|
genresModalTitle: 'Genuri',
|
||||||
|
showAllGenres: 'Afișează toate genurile',
|
||||||
trackTitle: 'Titlu',
|
trackTitle: 'Titlu',
|
||||||
trackAlbum: 'Album',
|
trackAlbum: 'Album',
|
||||||
trackArtist: 'Artist',
|
trackArtist: 'Artist',
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export const albumDetail = {
|
|||||||
tracksCount: 'Композиций: {{n}}',
|
tracksCount: 'Композиций: {{n}}',
|
||||||
goToArtist: 'Перейти к {{artist}}',
|
goToArtist: 'Перейти к {{artist}}',
|
||||||
moreLabelAlbums: 'Другие альбомы на {{label}}',
|
moreLabelAlbums: 'Другие альбомы на {{label}}',
|
||||||
|
moreGenreAlbums: 'Другие альбомы в жанре {{genre}}',
|
||||||
|
genresModalTitle: 'Жанры',
|
||||||
|
showAllGenres: 'Показать все жанры',
|
||||||
trackTitle: 'Название',
|
trackTitle: 'Название',
|
||||||
trackAlbum: 'Альбом',
|
trackAlbum: 'Альбом',
|
||||||
trackArtist: 'Исполнитель',
|
trackArtist: 'Исполнитель',
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const albumDetail = {
|
|||||||
tracksCount: '{{n}} 首曲目',
|
tracksCount: '{{n}} 首曲目',
|
||||||
goToArtist: '前往 {{artist}}',
|
goToArtist: '前往 {{artist}}',
|
||||||
moreLabelAlbums: '{{label}} 的更多专辑',
|
moreLabelAlbums: '{{label}} 的更多专辑',
|
||||||
|
moreGenreAlbums: '{{genre}} 类型的更多专辑',
|
||||||
|
genresModalTitle: '流派',
|
||||||
|
showAllGenres: '显示所有流派',
|
||||||
trackTitle: '标题',
|
trackTitle: '标题',
|
||||||
trackAlbum: '专辑',
|
trackAlbum: '专辑',
|
||||||
trackArtist: '艺术家',
|
trackArtist: '艺术家',
|
||||||
|
|||||||
+1
-13
@@ -11,19 +11,7 @@ import { fetchGenreCatalog, filterGenresWithContent } from '../utils/library/gen
|
|||||||
import { libraryScopeForServer } from '../api/subsonicClient';
|
import { libraryScopeForServer } from '../api/subsonicClient';
|
||||||
import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache';
|
import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache';
|
||||||
import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
||||||
|
import { genreColor } from '../utils/library/genreColor';
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCROLL_KEY = 'genres-scroll';
|
const SCROLL_KEY = 'genres-scroll';
|
||||||
const FONT_MIN_REM = 0.78;
|
const FONT_MIN_REM = 0.78;
|
||||||
|
|||||||
@@ -1 +1,82 @@
|
|||||||
/* ─ Album Detail ─ */
|
/* ─ 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { genreTagsFor, parseItemGenres, splitGenreTags } from './genreTags';
|
import { deriveAlbumGenreTags, genreTagsFor, parseItemGenres, splitGenreTags } from './genreTags';
|
||||||
|
|
||||||
describe('splitGenreTags', () => {
|
describe('splitGenreTags', () => {
|
||||||
it('splits Navidrome-default separators and dedupes case-insensitively', () => {
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,3 +63,28 @@ export function genreTagsFor(item: GenreTagSource): string[] {
|
|||||||
const g = item.genre?.trim();
|
const g = item.genre?.trim();
|
||||||
return g ? splitGenreTags(g) : [];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?:
|
|||||||
const getAlbumForServerMock = vi.fn((_serverId: string, _albumId: string) => ({}));
|
const getAlbumForServerMock = vi.fn((_serverId: string, _albumId: string) => ({}));
|
||||||
const getArtistForServerMock = vi.fn((_serverId: string, _artistId: string) => ({}));
|
const getArtistForServerMock = vi.fn((_serverId: string, _artistId: string) => ({}));
|
||||||
const getPlaylistForServerMock = vi.fn((_serverId: string, _playlistId: string) => ({}));
|
const getPlaylistForServerMock = vi.fn((_serverId: string, _playlistId: string) => ({}));
|
||||||
|
const libraryIsReadyMock = vi.fn(async (_serverId: string) => false);
|
||||||
|
|
||||||
vi.mock('./offlineBrowseMode', () => ({
|
vi.mock('./offlineBrowseMode', () => ({
|
||||||
isOfflineBrowseActive: () => isOfflineBrowseActiveMock(),
|
isOfflineBrowseActive: () => isOfflineBrowseActiveMock(),
|
||||||
@@ -61,12 +62,18 @@ vi.mock('../../api/subsonicPlaylists', () => ({
|
|||||||
getPlaylistForServerMock(serverId, playlistId),
|
getPlaylistForServerMock(serverId, playlistId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../library/libraryReady', () => ({
|
||||||
|
libraryIsReady: (serverId: string) => libraryIsReadyMock(serverId),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('offlineMediaResolve', () => {
|
describe('offlineMediaResolve', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
isOfflineBrowseActiveMock.mockReturnValue(false);
|
isOfflineBrowseActiveMock.mockReturnValue(false);
|
||||||
offlineLocalBrowseEnabledMock.mockReturnValue(false);
|
offlineLocalBrowseEnabledMock.mockReturnValue(false);
|
||||||
playlistsOfflineBrowseEnabledMock.mockReturnValue(false);
|
playlistsOfflineBrowseEnabledMock.mockReturnValue(false);
|
||||||
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
||||||
|
libraryIsReadyMock.mockReset();
|
||||||
|
libraryIsReadyMock.mockResolvedValue(false);
|
||||||
loadAlbumFromLocalPlaybackMock.mockReset();
|
loadAlbumFromLocalPlaybackMock.mockReset();
|
||||||
loadArtistFromLocalPlaybackMock.mockReset();
|
loadArtistFromLocalPlaybackMock.mockReset();
|
||||||
loadAlbumFromLibraryIndexMock.mockReset();
|
loadAlbumFromLibraryIndexMock.mockReset();
|
||||||
@@ -114,6 +121,31 @@ describe('offlineMediaResolve', () => {
|
|||||||
expect(result?.album.name).toBe('Idx');
|
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 () => {
|
it('resolveAlbumForActiveServer uses active server id', async () => {
|
||||||
getAlbumForServerMock.mockResolvedValue({
|
getAlbumForServerMock.mockResolvedValue({
|
||||||
album: { id: 'alb-2' },
|
album: { id: 'alb-2' },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
||||||
import { isOfflineBrowseActive } from './offlineBrowseMode';
|
import { isOfflineBrowseActive } from './offlineBrowseMode';
|
||||||
|
import { libraryIsReady } from '../library/libraryReady';
|
||||||
import {
|
import {
|
||||||
loadAlbumFromLibraryIndex,
|
loadAlbumFromLibraryIndex,
|
||||||
loadArtistFromLibraryIndex,
|
loadArtistFromLibraryIndex,
|
||||||
@@ -27,8 +28,10 @@ import {
|
|||||||
export type ResolvedAlbum = { album: SubsonicAlbum; songs: SubsonicSong[] };
|
export type ResolvedAlbum = { album: SubsonicAlbum; songs: SubsonicSong[] };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Album detail / play / enqueue: network album when reachable (complete track list);
|
* Album detail / play / enqueue: the local SQLite index first when it is ready
|
||||||
* local bytes or library index when offline browse is active.
|
* (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(
|
export async function resolveAlbum(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
@@ -37,6 +40,12 @@ export async function resolveAlbum(
|
|||||||
if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) {
|
if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) {
|
||||||
return loadAlbumFromLocalPlayback(serverId, albumId);
|
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 favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled;
|
||||||
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
|
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user