mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(artist): sort albums by year on artist detail (#877)
* feat(artist): year sort for albums section on artist detail Add a sort dropdown next to "Albums by …" with release-type grouping (default), newest-first, and oldest-first by album year. * chore: note PR #877 in CHANGELOG and settings credits * fix(artist): toggle year sort inside release groups, session per server Replace dropdown with a click-to-toggle newest/oldest button. Keep release-type blocks; sort albums by year within each group. Persist order in session store.
This commit is contained in:
@@ -18,6 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Added
|
||||
|
||||
### Artist detail — album year sort
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#877](https://github.com/Psychotoxical/psysonic/pull/877)**
|
||||
|
||||
* **Albums by …** on the artist page: toggle year sort (newest/oldest) within each release-type block; preference persists for the session per server.
|
||||
|
||||
|
||||
|
||||
### Servers — edit existing profiles
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)**
|
||||
|
||||
@@ -133,6 +133,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Cover art pipeline: tier ladder, WebP disk cache, dense-grid prefetch, Settings cover cache budget (PR #869)',
|
||||
'Lossless: local index browse, Advanced Search and All Albums filters, artist/album drill-down mode, conserved sidebar page (PR #871)',
|
||||
'Albums: combined browse filters (genre/year/favorites/lossless/compilations), session restore from album detail, favorites reconcile via local index (PR #876)',
|
||||
'Artist detail: sort albums by year (newest/oldest) in the Albums section (PR #877)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,9 @@ export const artistDetail = {
|
||||
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
|
||||
notFound: 'Künstler nicht gefunden.',
|
||||
albumsBy: 'Alben von {{name}}',
|
||||
sortYearToggleAria: 'Jahressortierung innerhalb jedes Veröffentlichungstyps umschalten',
|
||||
sortYearDesc: 'Jahr, neueste zuerst',
|
||||
sortYearAsc: 'Jahr, älteste zuerst',
|
||||
topTracks: 'Beliebteste Titel',
|
||||
noAlbums: 'Keine Alben gefunden.',
|
||||
trackTitle: 'Titel',
|
||||
|
||||
@@ -11,6 +11,9 @@ export const artistDetail = {
|
||||
notFound: 'Artist not found.',
|
||||
albumsBy: 'Albums by {{name}}',
|
||||
albumsByLossless: 'Lossless albums by {{name}}',
|
||||
sortYearToggleAria: 'Toggle album year sort within each release type',
|
||||
sortYearDesc: 'Year, newest first',
|
||||
sortYearAsc: 'Year, oldest first',
|
||||
topTracks: 'Top Tracks',
|
||||
topTracksLossless: 'Lossless Tracks',
|
||||
noAlbums: 'No albums found.',
|
||||
|
||||
@@ -10,6 +10,9 @@ export const artistDetail = {
|
||||
noRadio: 'No se encontraron pistas similares para este artista.',
|
||||
notFound: 'Artista no encontrado.',
|
||||
albumsBy: 'Álbumes de {{name}}',
|
||||
sortYearToggleAria: 'Alternar orden por año dentro de cada tipo de lanzamiento',
|
||||
sortYearDesc: 'Año, más recientes',
|
||||
sortYearAsc: 'Año, más antiguos',
|
||||
topTracks: 'Mejores Pistas',
|
||||
noAlbums: 'No se encontraron álbumes.',
|
||||
trackTitle: 'Título',
|
||||
|
||||
@@ -10,6 +10,9 @@ export const artistDetail = {
|
||||
noRadio: 'Aucun morceau similaire trouvé pour cet artiste.',
|
||||
notFound: 'Artiste introuvable.',
|
||||
albumsBy: 'Albums de {{name}}',
|
||||
sortYearToggleAria: 'Inverser le tri par année dans chaque type de sortie',
|
||||
sortYearDesc: 'Année, plus récents',
|
||||
sortYearAsc: 'Année, plus anciens',
|
||||
topTracks: 'Morceaux populaires',
|
||||
noAlbums: 'Aucun album trouvé.',
|
||||
trackTitle: 'Titre',
|
||||
|
||||
@@ -10,6 +10,9 @@ export const artistDetail = {
|
||||
noRadio: 'Ingen lignende spor funnet for denne artisten.',
|
||||
notFound: 'Artisten ble ikke funnet.',
|
||||
albumsBy: 'Album fra {{name}}',
|
||||
sortYearToggleAria: 'Bytt årssortering innen hver utgivelsestype',
|
||||
sortYearDesc: 'År, nyeste først',
|
||||
sortYearAsc: 'År, eldste først',
|
||||
topTracks: 'Toppspor',
|
||||
noAlbums: 'Ingen album funnet.',
|
||||
trackTitle: 'Tittel',
|
||||
|
||||
@@ -10,6 +10,9 @@ export const artistDetail = {
|
||||
noRadio: 'Geen vergelijkbare nummers gevonden voor deze artiest.',
|
||||
notFound: 'Artiest niet gevonden.',
|
||||
albumsBy: 'Albums van {{name}}',
|
||||
sortYearToggleAria: 'Sorteervolgorde op jaar binnen elk uitgavetype wisselen',
|
||||
sortYearDesc: 'Jaar, nieuwste eerst',
|
||||
sortYearAsc: 'Jaar, oudste eerst',
|
||||
topTracks: 'Populaire nummers',
|
||||
noAlbums: 'Geen albums gevonden.',
|
||||
trackTitle: 'Titel',
|
||||
|
||||
@@ -10,6 +10,9 @@ export const artistDetail = {
|
||||
noRadio: 'Nicio piesă similară găsită pentru acest artist.',
|
||||
notFound: 'Artistul nu a fost găsit.',
|
||||
albumsBy: 'Albume de {{name}}',
|
||||
sortYearToggleAria: 'Comută sortarea după an în fiecare tip de lansare',
|
||||
sortYearDesc: 'An, cele mai noi',
|
||||
sortYearAsc: 'An, cele mai vechi',
|
||||
topTracks: 'Top Piese',
|
||||
noAlbums: 'Niciun album găsit.',
|
||||
trackTitle: 'Titlu',
|
||||
|
||||
@@ -11,6 +11,9 @@ export const artistDetail = {
|
||||
notFound: 'Исполнитель не найден.',
|
||||
albumsBy: 'Альбомы — {{name}}',
|
||||
albumsByLossless: 'Lossless-альбомы — {{name}}',
|
||||
sortYearToggleAria: 'Переключить порядок альбомов по году внутри каждого типа релиза',
|
||||
sortYearDesc: 'По году, сначала новые',
|
||||
sortYearAsc: 'По году, сначала старые',
|
||||
topTracks: 'Популярные треки',
|
||||
topTracksLossless: 'Lossless-треки',
|
||||
noAlbums: 'Альбомов нет.',
|
||||
|
||||
@@ -10,6 +10,9 @@ export const artistDetail = {
|
||||
noRadio: '未找到该艺术家的相似曲目。',
|
||||
notFound: '未找到艺术家。',
|
||||
albumsBy: '{{name}} 的专辑',
|
||||
sortYearToggleAria: '在各发行类型内切换按年份排序',
|
||||
sortYearDesc: '年份,从新到旧',
|
||||
sortYearAsc: '年份,从旧到新',
|
||||
topTracks: '热门曲目',
|
||||
noAlbums: '未找到专辑。',
|
||||
trackTitle: '标题',
|
||||
|
||||
+52
-11
@@ -7,7 +7,7 @@ import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2, AudioLines } from 'lucide-react';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2, AudioLines, ArrowDownUp } from 'lucide-react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
@@ -24,6 +24,10 @@ import { showToast } from '../utils/ui/toast';
|
||||
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
||||
import {
|
||||
DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
useArtistAlbumYearSortStore,
|
||||
} from '../store/artistAlbumYearSortStore';
|
||||
|
||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||
@@ -42,6 +46,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
import { sortArtistAlbumsByYear } from '../utils/library/sortArtistAlbums';
|
||||
|
||||
|
||||
export default function ArtistDetail() {
|
||||
@@ -84,6 +89,10 @@ export default function ArtistDetail() {
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const albumYearOrder = useArtistAlbumYearSortStore(
|
||||
s => s.orderByServer[activeServerId] ?? DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
);
|
||||
const toggleAlbumYearOrder = useArtistAlbumYearSortStore(s => s.toggleYearOrder);
|
||||
// MUST stay above the loading / !artist early returns or React's hook
|
||||
// call order will mismatch between renders.
|
||||
const sectionConfig = useArtistLayoutStore(s => s.sections);
|
||||
@@ -177,8 +186,11 @@ export default function ArtistDetail() {
|
||||
groups.get(key)!.push(album);
|
||||
}
|
||||
|
||||
const sortGroup = (group: SubsonicAlbum[]) =>
|
||||
sortArtistAlbumsByYear(group, albumYearOrder);
|
||||
|
||||
if (groups.size === 1 && groups.has(defaultKey)) {
|
||||
return [[translateType(defaultKey), albums] as const];
|
||||
return [[translateType(defaultKey), sortGroup(albums)] as const];
|
||||
}
|
||||
|
||||
const sortKey = (key: string) => {
|
||||
@@ -188,8 +200,11 @@ export default function ArtistDetail() {
|
||||
|
||||
return [...groups.entries()]
|
||||
.sort((a, b) => sortKey(a[0]) - sortKey(b[0]) || a[0].localeCompare(b[0]))
|
||||
.map(([key, group]) => [key.split(' · ').map(translateType).join(' · '), group] as const);
|
||||
}, [albums, t]);
|
||||
.map(([key, group]) => [
|
||||
key.split(' · ').map(translateType).join(' · '),
|
||||
sortGroup(group),
|
||||
] as const);
|
||||
}, [albums, albumYearOrder, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderCoverFailed(false);
|
||||
@@ -320,19 +335,45 @@ export default function ArtistDetail() {
|
||||
|
||||
case 'albums': return (
|
||||
<Fragment key="albums">
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('albums'), marginBottom: '1rem' }}>
|
||||
{losslessOnly
|
||||
? t('artistDetail.albumsByLossless', { name: artist.name })
|
||||
: t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
marginTop: sectionMt('albums'),
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
{losslessOnly
|
||||
? t('artistDetail.albumsByLossless', { name: artist.name })
|
||||
: t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
{albums.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface btn-sort-active"
|
||||
onClick={() => toggleAlbumYearOrder(activeServerId)}
|
||||
aria-label={t('artistDetail.sortYearToggleAria')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<ArrowDownUp size={14} />
|
||||
{albumYearOrder === 'yearDesc'
|
||||
? t('artistDetail.sortYearDesc')
|
||||
: t('artistDetail.sortYearAsc')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{albums.length > 0 ? (
|
||||
groupedAlbums.length === 1 ? (
|
||||
<VirtualCardGrid
|
||||
items={albums}
|
||||
items={groupedAlbums[0][1]}
|
||||
itemKey={(a, i) => `${a.id}-${i}`}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
layoutSignal={groupedAlbums[0][1].length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
useArtistAlbumYearSortStore,
|
||||
} from './artistAlbumYearSortStore';
|
||||
|
||||
describe('artistAlbumYearSortStore', () => {
|
||||
beforeEach(() => {
|
||||
useArtistAlbumYearSortStore.setState({ orderByServer: {} });
|
||||
});
|
||||
|
||||
it('defaults to newest first per server', () => {
|
||||
expect(useArtistAlbumYearSortStore.getState().yearOrderFor('s1')).toBe(
|
||||
DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles between newest and oldest for the same server', () => {
|
||||
const { toggleYearOrder, yearOrderFor } = useArtistAlbumYearSortStore.getState();
|
||||
toggleYearOrder('s1');
|
||||
expect(yearOrderFor('s1')).toBe('yearAsc');
|
||||
toggleYearOrder('s1');
|
||||
expect(yearOrderFor('s1')).toBe('yearDesc');
|
||||
});
|
||||
|
||||
it('keeps order per server independently', () => {
|
||||
const { toggleYearOrder, yearOrderFor } = useArtistAlbumYearSortStore.getState();
|
||||
toggleYearOrder('s1');
|
||||
expect(yearOrderFor('s1')).toBe('yearAsc');
|
||||
expect(yearOrderFor('s2')).toBe('yearDesc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { ArtistAlbumYearOrder } from '../utils/library/sortArtistAlbums';
|
||||
|
||||
export const DEFAULT_ARTIST_ALBUM_YEAR_ORDER: ArtistAlbumYearOrder = 'yearDesc';
|
||||
|
||||
interface ArtistAlbumYearSortStore {
|
||||
orderByServer: Record<string, ArtistAlbumYearOrder>;
|
||||
yearOrderFor: (serverId: string) => ArtistAlbumYearOrder;
|
||||
toggleYearOrder: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export const useArtistAlbumYearSortStore = create<ArtistAlbumYearSortStore>((set, get) => ({
|
||||
orderByServer: {},
|
||||
|
||||
yearOrderFor: (serverId) =>
|
||||
get().orderByServer[serverId] ?? DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
|
||||
toggleYearOrder: (serverId) => {
|
||||
if (!serverId) return;
|
||||
const current = get().yearOrderFor(serverId);
|
||||
const next: ArtistAlbumYearOrder = current === 'yearDesc' ? 'yearAsc' : 'yearDesc';
|
||||
set(s => ({
|
||||
orderByServer: { ...s.orderByServer, [serverId]: next },
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { sortArtistAlbumsByYear } from './sortArtistAlbums';
|
||||
|
||||
const album = (id: string, name: string, year?: number): SubsonicAlbum => ({
|
||||
id,
|
||||
name,
|
||||
artist: 'A',
|
||||
artistId: 'a',
|
||||
songCount: 1,
|
||||
duration: 1,
|
||||
year,
|
||||
});
|
||||
|
||||
describe('sortArtistAlbumsByYear', () => {
|
||||
const albums = [
|
||||
album('3', 'Gamma', 2000),
|
||||
album('1', 'Alpha', 1990),
|
||||
album('2', 'Beta', 2000),
|
||||
];
|
||||
|
||||
it('sorts by year descending then name', () => {
|
||||
expect(sortArtistAlbumsByYear(albums, 'yearDesc').map(a => a.id)).toEqual(['2', '3', '1']);
|
||||
});
|
||||
|
||||
it('sorts by year ascending then name', () => {
|
||||
expect(sortArtistAlbumsByYear(albums, 'yearAsc').map(a => a.id)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
|
||||
export type ArtistAlbumYearOrder = 'yearDesc' | 'yearAsc';
|
||||
|
||||
export function sortArtistAlbumsByYear(
|
||||
albums: SubsonicAlbum[],
|
||||
order: ArtistAlbumYearOrder,
|
||||
): SubsonicAlbum[] {
|
||||
const out = [...albums];
|
||||
out.sort((a, b) => {
|
||||
const ay = a.year ?? 0;
|
||||
const by = b.year ?? 0;
|
||||
if (ay !== by) return order === 'yearDesc' ? by - ay : ay - by;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user