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:
cucadmuh
2026-05-27 13:04:00 +03:00
committed by GitHub
parent 06da15caf3
commit ee5068c98c
16 changed files with 193 additions and 11 deletions
+8
View File
@@ -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)**
+1
View File
@@ -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)',
],
},
{
+3
View File
@@ -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',
+3
View File
@@ -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.',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -11,6 +11,9 @@ export const artistDetail = {
notFound: 'Исполнитель не найден.',
albumsBy: 'Альбомы — {{name}}',
albumsByLossless: 'Lossless-альбомы — {{name}}',
sortYearToggleAria: 'Переключить порядок альбомов по году внутри каждого типа релиза',
sortYearDesc: 'По году, сначала новые',
sortYearAsc: 'По году, сначала старые',
topTracks: 'Популярные треки',
topTracksLossless: 'Lossless-треки',
noAlbums: 'Альбомов нет.',
+3
View File
@@ -10,6 +10,9 @@ export const artistDetail = {
noRadio: '未找到该艺术家的相似曲目。',
notFound: '未找到艺术家。',
albumsBy: '{{name}} 的专辑',
sortYearToggleAria: '在各发行类型内切换按年份排序',
sortYearDesc: '年份,从新到旧',
sortYearAsc: '年份,从旧到新',
topTracks: '热门曲目',
noAlbums: '未找到专辑。',
trackTitle: '标题',
+52 -11
View File
@@ -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');
});
});
+27
View File
@@ -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']);
});
});
+17
View File
@@ -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;
}