From 47e16ebfef07ae3c951ca6c95ea96d8f4201baed Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:13:45 +0200 Subject: [PATCH] fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket (#977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket Three zunoz reports, one change: - Queue now-playing card: the artist and album links now underline on hover (not just recolour), matching clickable names everywhere else. The album link sits on .queue-current-sub itself; artist links are nested .is-link spans — both selectors covered. - Genre album cards split multi-artist credits into individual links like the rest of the app. Root cause was the local-index genre query hardcoding NULL for the album raw_json column, so OpenSubsonic artists[] never reached the card and it fell back to the flat "A • B" single link. Select a.raw_json instead (parity with the All Albums / advanced-search album queries). - Artists page alphabet index: # is now digits-only, and a new "Other" bucket collects accented Latin (Æ/Ø/Å…) and non-Latin scripts (CJK, Cyrillic, …) that previously fell into the # catch-all. Adds artistBucketKey/compareBuckets helpers (unit-tested), an OTHER bucket sorted last, and the artists.other label across 9 locales. * docs(changelog): queue link hover, genre card artist split, Artists Other bucket (#977) --- CHANGELOG.md | 8 ++++ .../src/genre_album_browse.rs | 2 +- src/components/artists/ArtistsListView.tsx | 6 +-- src/hooks/useArtistsFiltering.ts | 14 ++---- src/locales/de/artists.ts | 1 + src/locales/en/artists.ts | 1 + src/locales/es/artists.ts | 1 + src/locales/fr/artists.ts | 1 + src/locales/nb/artists.ts | 1 + src/locales/nl/artists.ts | 1 + src/locales/ro/artists.ts | 1 + src/locales/ru/artists.ts | 1 + src/locales/zh/artists.ts | 1 + src/pages/Artists.tsx | 3 +- src/styles/layout/queue-panel.css | 10 +++- .../componentHelpers/artistsHelpers.test.ts | 48 +++++++++++++++++++ src/utils/componentHelpers/artistsHelpers.ts | 27 ++++++++++- 17 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 src/utils/componentHelpers/artistsHelpers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f417bcad..68ec52cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -864,6 +864,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The track-list column header keeps its background on hover instead of turning transparent and letting rows show through. * **Track of the moment** and the browse rows split multi-artist tracks into individually clickable artist links, matching the album track list. +### Queue, Genre cards, and the Artists index + +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#977](https://github.com/Psychotoxical/psysonic/pull/977)** + +* The artist and album in the Queue's now-playing card now underline on hover like every other clickable name, instead of only changing colour. +* Album cards on a **Genre** page split multi-artist credits into individually clickable artist links, matching the rest of the app. +* On the **Artists** page the `#` index button now holds only names that start with a number; accented and non-Latin names (Æ Ø Å, Chinese, Japanese, Cyrillic, …) move to a new **Other** section instead of the `#` catch-all. + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs index a77f20ec..7fc7c3e9 100644 --- a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs +++ b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs @@ -137,7 +137,7 @@ pub fn list_albums_by_genre( COALESCE(a.cover_art_id, la.cover_art_id), \ COALESCE(a.starred_at, la.starred_at), \ COALESCE(a.synced_at, la.synced_at), \ - NULL \ + a.raw_json \ FROM ( \ SELECT \ t.server_id, \ diff --git a/src/components/artists/ArtistsListView.tsx b/src/components/artists/ArtistsListView.tsx index 0f4150e2..b80db2e5 100644 --- a/src/components/artists/ArtistsListView.tsx +++ b/src/components/artists/ArtistsListView.tsx @@ -3,7 +3,7 @@ import type { Virtualizer } from '@tanstack/react-virtual'; import type { TFunction } from 'i18next'; import type { SubsonicArtist } from '../../api/subsonicTypes'; import type { PlayerState } from '../../store/playerStoreTypes'; -import type { ArtistListFlatRow } from '../../utils/componentHelpers/artistsHelpers'; +import { OTHER_BUCKET, type ArtistListFlatRow } from '../../utils/componentHelpers/artistsHelpers'; import { ArtistRowAvatar } from './ArtistAvatars'; interface RowProps { @@ -123,7 +123,7 @@ export function ArtistsListView({ <> {letters.map(letter => (
-

{letter}

+

{letter === OTHER_BUCKET ? t('artists.other') : letter}

{groups[letter].map(artist => ( @@ -159,7 +159,7 @@ export function ArtistsListView({ transform: `translateY(${vi.start - artistListScrollMargin}px)`, }} > -

{row.letter}

+

{row.letter === OTHER_BUCKET ? t('artists.other') : row.letter}

); } diff --git a/src/hooks/useArtistsFiltering.ts b/src/hooks/useArtistsFiltering.ts index ecebce3c..64a5c174 100644 --- a/src/hooks/useArtistsFiltering.ts +++ b/src/hooks/useArtistsFiltering.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import type { SubsonicArtist } from '../api/subsonicTypes'; import { usePlayerStore } from '../store/playerStore'; -import { ALL_SENTINEL, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers'; +import { ALL_SENTINEL, artistBucketKey, compareBuckets, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers'; interface UseArtistsFilteringArgs { artists: SubsonicArtist[]; @@ -48,12 +48,7 @@ export function useArtistsFiltering({ const filtered = useMemo(() => { let out = artists; if (letterFilter !== ALL_SENTINEL) { - out = out.filter(a => { - const first = a.name[0]?.toUpperCase() ?? '#'; - const isAlpha = /^[A-Z]$/.test(first); - if (letterFilter === '#') return !isAlpha; - return first === letterFilter; - }); + out = out.filter(a => artistBucketKey(a.name) === letterFilter); } if (filter) { const needle = filter.toLowerCase(); @@ -72,12 +67,11 @@ export function useArtistsFiltering({ if (viewMode !== 'list') return { groups: {} as Record, letters: [] as string[] }; const g: Record = {}; for (const a of visible) { - const letter = a.name[0]?.toUpperCase() ?? '#'; - const key = /^[A-Z]$/.test(letter) ? letter : '#'; + const key = artistBucketKey(a.name); if (!g[key]) g[key] = []; g[key].push(a); } - return { groups: g, letters: Object.keys(g).sort() }; + return { groups: g, letters: Object.keys(g).sort(compareBuckets) }; }, [visible, viewMode]); const artistListFlatRows = useMemo((): ArtistListFlatRow[] => { diff --git a/src/locales/de/artists.ts b/src/locales/de/artists.ts index d364f406..dfb3605d 100644 --- a/src/locales/de/artists.ts +++ b/src/locales/de/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: 'Künstler', search: 'Suchen…', all: 'Alle', + other: 'Andere', gridView: 'Gitteransicht', listView: 'Listenansicht', imagesOn: 'Künstlerbilder aktiv — kann Netzwerk- und Systemlast erhöhen', diff --git a/src/locales/en/artists.ts b/src/locales/en/artists.ts index e5af1574..089e35a3 100644 --- a/src/locales/en/artists.ts +++ b/src/locales/en/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: 'Artists', search: 'Search…', all: 'All', + other: 'Other', gridView: 'Grid view', listView: 'List view', imagesOn: 'Artist images on — may increase network and system load', diff --git a/src/locales/es/artists.ts b/src/locales/es/artists.ts index a7b78747..27d2d131 100644 --- a/src/locales/es/artists.ts +++ b/src/locales/es/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: 'Artistas', search: 'Buscar…', all: 'Todos', + other: 'Otros', gridView: 'Vista de cuadrícula', listView: 'Vista de lista', imagesOn: 'Imágenes de artistas activadas — puede aumentar la carga de red y sistema', diff --git a/src/locales/fr/artists.ts b/src/locales/fr/artists.ts index 847502af..10c7187d 100644 --- a/src/locales/fr/artists.ts +++ b/src/locales/fr/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: 'Artistes', search: 'Rechercher…', all: 'Tous', + other: 'Autres', gridView: 'Vue en grille', listView: 'Vue en liste', imagesOn: 'Images d\'artistes activées — peut augmenter la charge réseau et système', diff --git a/src/locales/nb/artists.ts b/src/locales/nb/artists.ts index 5706f1d9..112a030c 100644 --- a/src/locales/nb/artists.ts +++ b/src/locales/nb/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: 'Artister', search: 'Søk…', all: 'Alle', + other: 'Andre', gridView: 'Rutenettvisning', listView: 'Listevisning', imagesOn: 'Artistbilder på - kan øke belastningen på nettverket og applikasjonen', diff --git a/src/locales/nl/artists.ts b/src/locales/nl/artists.ts index c380ceae..a6ce006e 100644 --- a/src/locales/nl/artists.ts +++ b/src/locales/nl/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: 'Artiesten', search: 'Zoeken…', all: 'Alle', + other: 'Overige', gridView: 'Rasterweergave', listView: 'Lijstweergave', imagesOn: 'Artiestafbeeldingen aan — kan netwerk- en systeembelasting verhogen', diff --git a/src/locales/ro/artists.ts b/src/locales/ro/artists.ts index d6b3259f..04c02969 100644 --- a/src/locales/ro/artists.ts +++ b/src/locales/ro/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: 'Artiști', search: 'Caută…', all: 'Tot', + other: 'Altele', gridView: 'Vizualizare grilă', listView: 'Vizualizare listă', imagesOn: 'Imagini artist pornit — poate mări încărcarea rețelei și a sistemului', diff --git a/src/locales/ru/artists.ts b/src/locales/ru/artists.ts index a29d1bdb..55de6c98 100644 --- a/src/locales/ru/artists.ts +++ b/src/locales/ru/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: 'Исполнители', search: 'Поиск…', all: 'Все', + other: 'Другое', gridView: 'Сетка', listView: 'Список', imagesOn: 'Фото исполнителей — больше трафика и нагрузка на систему', diff --git a/src/locales/zh/artists.ts b/src/locales/zh/artists.ts index a4f52f26..e223050c 100644 --- a/src/locales/zh/artists.ts +++ b/src/locales/zh/artists.ts @@ -2,6 +2,7 @@ export const artists = { title: '艺术家', search: '搜索…', all: '全部', + other: '其他', gridView: '网格视图', listView: '列表视图', imagesOn: '艺术家图片已开启 — 可能增加网络和系统负载', diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 1de92979..39e1a26a 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -14,6 +14,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { ALL_SENTINEL, ALPHABET, + OTHER_BUCKET, ARTIST_LIST_LAST_IN_LETTER_EST, ARTIST_LIST_LETTER_ROW_EST, ARTIST_LIST_ROW_EST, @@ -375,7 +376,7 @@ export default function Artists() { onClick={() => setLetterFilter(l)} className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`} > - {l === ALL_SENTINEL ? t('artists.all') : l} + {l === ALL_SENTINEL ? t('artists.all') : l === OTHER_BUCKET ? t('artists.other') : l} ))}
diff --git a/src/styles/layout/queue-panel.css b/src/styles/layout/queue-panel.css index 1840a8a0..541c0305 100644 --- a/src/styles/layout/queue-panel.css +++ b/src/styles/layout/queue-panel.css @@ -224,11 +224,17 @@ transition: color var(--transition-fast); } -.queue-current-sub.is-link { +/* Album link sits on the .queue-current-sub element itself; artist links are + .is-link spans nested inside it (OpenArtistRefInline). Both get the same + accent + underline affordance on hover as clickable names everywhere else. */ +.queue-current-sub.is-link, +.queue-current-sub .is-link { cursor: pointer; } -.queue-current-sub.is-link:hover { +.queue-current-sub.is-link:hover, +.queue-current-sub .is-link:hover { color: var(--accent); + text-decoration: underline; } .queue-current-enrichment { diff --git a/src/utils/componentHelpers/artistsHelpers.test.ts b/src/utils/componentHelpers/artistsHelpers.test.ts new file mode 100644 index 00000000..37ca5a03 --- /dev/null +++ b/src/utils/componentHelpers/artistsHelpers.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { artistBucketKey, compareBuckets, OTHER_BUCKET, ALPHABET } from './artistsHelpers'; + +describe('artistBucketKey', () => { + it('buckets A–Z names by their uppercased first letter', () => { + expect(artistBucketKey('Adele')).toBe('A'); + expect(artistBucketKey('zz top')).toBe('Z'); + expect(artistBucketKey('mGla')).toBe('M'); + }); + + it('puts digit-leading names in #', () => { + expect(artistBucketKey('2Pac')).toBe('#'); + expect(artistBucketKey('50 Cent')).toBe('#'); + expect(artistBucketKey('999')).toBe('#'); + }); + + it('puts accented Latin and non-Latin scripts in Other (not #)', () => { + expect(artistBucketKey('Ärzte')).toBe(OTHER_BUCKET); + expect(artistBucketKey('Øde')).toBe(OTHER_BUCKET); + expect(artistBucketKey('Å-band')).toBe(OTHER_BUCKET); + expect(artistBucketKey('이영지')).toBe(OTHER_BUCKET); // Korean + expect(artistBucketKey('くるり')).toBe(OTHER_BUCKET); // Japanese + expect(artistBucketKey('Кино')).toBe(OTHER_BUCKET); // Cyrillic + expect(artistBucketKey('王菲')).toBe(OTHER_BUCKET); // Chinese + }); + + it('puts symbol-leading and empty names in Other', () => { + expect(artistBucketKey('!!!')).toBe(OTHER_BUCKET); + expect(artistBucketKey(' ')).toBe(OTHER_BUCKET); + expect(artistBucketKey('')).toBe(OTHER_BUCKET); + }); + + it('ignores leading whitespace', () => { + expect(artistBucketKey(' Beatles')).toBe('B'); + }); +}); + +describe('compareBuckets', () => { + it('orders # first, then A–Z, then Other last', () => { + const shuffled = ['OTHER', 'M', '#', 'A', 'Z']; + expect([...shuffled].sort(compareBuckets)).toEqual(['#', 'A', 'M', 'Z', 'OTHER']); + }); + + it('ALPHABET ends with the Other bucket', () => { + expect(ALPHABET[ALPHABET.length - 1]).toBe(OTHER_BUCKET); + expect(ALPHABET).toContain('#'); + }); +}); diff --git a/src/utils/componentHelpers/artistsHelpers.ts b/src/utils/componentHelpers/artistsHelpers.ts index 8bd0bebe..113f162b 100644 --- a/src/utils/componentHelpers/artistsHelpers.ts +++ b/src/utils/componentHelpers/artistsHelpers.ts @@ -1,7 +1,32 @@ import type { SubsonicArtist } from '../../api/subsonicTypes'; export const ALL_SENTINEL = 'ALL'; -export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')]; +/** Catch-all bucket for names that start with neither an A–Z letter nor a digit + * (accented Latin like Æ/Ø/Å, and non-Latin scripts: CJK, Cyrillic, …). */ +export const OTHER_BUCKET = 'OTHER'; +export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), OTHER_BUCKET]; + +/** Stable ordering index for a bucket key — '#' first, A–Z, then 'Other' last. */ +const BUCKET_ORDER = new Map(ALPHABET.map((l, i) => [l, i])); + +/** + * Bucket an artist name into the alphabet index: + * - `#` → starts with a digit (0–9) + * - `A`–`Z` → starts with an ASCII letter + * - `OTHER` → anything else (accents, CJK, Cyrillic, symbols, empty) + */ +export function artistBucketKey(name: string): string { + const first = name?.trim()?.[0]; + if (!first) return OTHER_BUCKET; + if (/^[0-9]$/.test(first)) return '#'; + const up = first.toUpperCase(); + return /^[A-Z]$/.test(up) ? up : OTHER_BUCKET; +} + +/** Sort comparator for bucket keys following ALPHABET order (unknown keys last). */ +export function compareBuckets(a: string, b: string): number { + return (BUCKET_ORDER.get(a) ?? 999) - (BUCKET_ORDER.get(b) ?? 999); +} /** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */ export const ARTIST_LIST_LETTER_ROW_EST = 48;