From ee5068c98c7b405ad0d88fc765638954ea41bd97 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 27 May 2026 13:04:00 +0300 Subject: [PATCH] feat(artist): sort albums by year on artist detail (#877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- CHANGELOG.md | 8 +++ src/config/settingsCredits.ts | 1 + src/locales/de/artistDetail.ts | 3 ++ src/locales/en/artistDetail.ts | 3 ++ src/locales/es/artistDetail.ts | 3 ++ src/locales/fr/artistDetail.ts | 3 ++ src/locales/nb/artistDetail.ts | 3 ++ src/locales/nl/artistDetail.ts | 3 ++ src/locales/ro/artistDetail.ts | 3 ++ src/locales/ru/artistDetail.ts | 3 ++ src/locales/zh/artistDetail.ts | 3 ++ src/pages/ArtistDetail.tsx | 63 ++++++++++++++++++---- src/store/artistAlbumYearSortStore.test.ts | 32 +++++++++++ src/store/artistAlbumYearSortStore.ts | 27 ++++++++++ src/utils/library/sortArtistAlbums.test.ts | 29 ++++++++++ src/utils/library/sortArtistAlbums.ts | 17 ++++++ 16 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 src/store/artistAlbumYearSortStore.test.ts create mode 100644 src/store/artistAlbumYearSortStore.ts create mode 100644 src/utils/library/sortArtistAlbums.test.ts create mode 100644 src/utils/library/sortArtistAlbums.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0c0252..20d6b3fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)** diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 4010745f..e5b78e10 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/locales/de/artistDetail.ts b/src/locales/de/artistDetail.ts index 8df7081f..8242cc1a 100644 --- a/src/locales/de/artistDetail.ts +++ b/src/locales/de/artistDetail.ts @@ -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', diff --git a/src/locales/en/artistDetail.ts b/src/locales/en/artistDetail.ts index 0ce53b29..d0659d4b 100644 --- a/src/locales/en/artistDetail.ts +++ b/src/locales/en/artistDetail.ts @@ -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.', diff --git a/src/locales/es/artistDetail.ts b/src/locales/es/artistDetail.ts index 78ff70e2..63df8b0a 100644 --- a/src/locales/es/artistDetail.ts +++ b/src/locales/es/artistDetail.ts @@ -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', diff --git a/src/locales/fr/artistDetail.ts b/src/locales/fr/artistDetail.ts index 96935946..9efeb03e 100644 --- a/src/locales/fr/artistDetail.ts +++ b/src/locales/fr/artistDetail.ts @@ -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', diff --git a/src/locales/nb/artistDetail.ts b/src/locales/nb/artistDetail.ts index 763fd943..4b16d343 100644 --- a/src/locales/nb/artistDetail.ts +++ b/src/locales/nb/artistDetail.ts @@ -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', diff --git a/src/locales/nl/artistDetail.ts b/src/locales/nl/artistDetail.ts index 5b8e3922..ed210a4b 100644 --- a/src/locales/nl/artistDetail.ts +++ b/src/locales/nl/artistDetail.ts @@ -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', diff --git a/src/locales/ro/artistDetail.ts b/src/locales/ro/artistDetail.ts index e60267e4..f3599c8f 100644 --- a/src/locales/ro/artistDetail.ts +++ b/src/locales/ro/artistDetail.ts @@ -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', diff --git a/src/locales/ru/artistDetail.ts b/src/locales/ru/artistDetail.ts index 3ffc8d70..a30bc7da 100644 --- a/src/locales/ru/artistDetail.ts +++ b/src/locales/ru/artistDetail.ts @@ -11,6 +11,9 @@ export const artistDetail = { notFound: 'Исполнитель не найден.', albumsBy: 'Альбомы — {{name}}', albumsByLossless: 'Lossless-альбомы — {{name}}', + sortYearToggleAria: 'Переключить порядок альбомов по году внутри каждого типа релиза', + sortYearDesc: 'По году, сначала новые', + sortYearAsc: 'По году, сначала старые', topTracks: 'Популярные треки', topTracksLossless: 'Lossless-треки', noAlbums: 'Альбомов нет.', diff --git a/src/locales/zh/artistDetail.ts b/src/locales/zh/artistDetail.ts index 8d55a2c5..272ea042 100644 --- a/src/locales/zh/artistDetail.ts +++ b/src/locales/zh/artistDetail.ts @@ -10,6 +10,9 @@ export const artistDetail = { noRadio: '未找到该艺术家的相似曲目。', notFound: '未找到艺术家。', albumsBy: '{{name}} 的专辑', + sortYearToggleAria: '在各发行类型内切换按年份排序', + sortYearDesc: '年份,从新到旧', + sortYearAsc: '年份,从旧到新', topTracks: '热门曲目', noAlbums: '未找到专辑。', trackTitle: '标题', diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index d3e9c99d..37b09999 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -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 ( -

- {losslessOnly - ? t('artistDetail.albumsByLossless', { name: artist.name }) - : t('artistDetail.albumsBy', { name: artist.name })} -

+
+

+ {losslessOnly + ? t('artistDetail.albumsByLossless', { name: artist.name }) + : t('artistDetail.albumsBy', { name: artist.name })} +

+ {albums.length > 0 && ( + + )} +
{albums.length > 0 ? ( groupedAlbums.length === 1 ? ( `${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 => ( diff --git a/src/store/artistAlbumYearSortStore.test.ts b/src/store/artistAlbumYearSortStore.test.ts new file mode 100644 index 00000000..12e49c1f --- /dev/null +++ b/src/store/artistAlbumYearSortStore.test.ts @@ -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'); + }); +}); diff --git a/src/store/artistAlbumYearSortStore.ts b/src/store/artistAlbumYearSortStore.ts new file mode 100644 index 00000000..b51654d5 --- /dev/null +++ b/src/store/artistAlbumYearSortStore.ts @@ -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; + yearOrderFor: (serverId: string) => ArtistAlbumYearOrder; + toggleYearOrder: (serverId: string) => void; +} + +export const useArtistAlbumYearSortStore = create((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 }, + })); + }, +})); diff --git a/src/utils/library/sortArtistAlbums.test.ts b/src/utils/library/sortArtistAlbums.test.ts new file mode 100644 index 00000000..14d62f58 --- /dev/null +++ b/src/utils/library/sortArtistAlbums.test.ts @@ -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']); + }); +}); diff --git a/src/utils/library/sortArtistAlbums.ts b/src/utils/library/sortArtistAlbums.ts new file mode 100644 index 00000000..3833f436 --- /dev/null +++ b/src/utils/library/sortArtistAlbums.ts @@ -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; +}