feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch (#466)

* feat(ui): StarFilterButton component + common i18n keys

Reusable toggle button for "favorites only" filtering. Three size
variants for different toolbar contexts:
- default: icon + label (Albums-style)
- compact: icon-only with 0.5rem padding (Artists view-mode buttons)
- small:   icon + label at 12px / 4×14 padding (AdvancedSearch tabs)

Adds common.favorites + favoritesTooltipOff/On in all 8 locales.

* feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch

Client-side filter using the existing useMemo pipelines on each page.
Reads starred state from item.starred + playerStore.starredOverrides
(O(1) Map lookup, picks up live star toggles without refetch).

- Albums: toolbar button (default size) next to compilation filter.
- Artists: toolbar button (compact / icon-only) before the Images toggle.
- AdvancedSearch: toolbar button (small) next to the result-type tabs;
  filters all three result categories (artists / albums / songs) and
  updates the count badges accordingly.

Filter state is ephemeral per-page (not persisted) so users don't get
surprised by hidden items after a restart. Zero extra server calls.

* docs(contributors): credit + changelog entry for #466
This commit is contained in:
Frank Stellmacher
2026-05-05 23:02:22 +02:00
committed by GitHub
parent 0fab2849e5
commit d33abf565c
14 changed files with 151 additions and 18 deletions
+8
View File
@@ -33,6 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* New optional toggle in Settings → Audio → Playback ("Preserve Play Next order"). When on, multiple "Play Next" insertions **queue up behind each other** instead of the latest one bumping earlier picks down. Default off — existing behaviour unchanged.
* Side-benefit: single-song "Play Next" now goes through the unified `enqueueAt` path and gets undo + server-sync support that the album path already had.
### Library — "favorites only" filter on Albums, Artists and Advanced Search
**By [@Psychotoxical](https://github.com/Psychotoxical), suggested by [@lilgringo](https://github.com/lilgringo), PR [#466](https://github.com/Psychotoxical/psysonic/pull/466)**
* New star-toggle button in the toolbars of **Albums**, **Artists** and **Advanced Search** that flips the visible list to favourites-only.
* Filter state is ephemeral per page (not persisted) so users don't come back to a half-empty library and wonder where their content went.
* Reads star state live from in-memory overrides — toggling a favourite from a context menu updates the visible list immediately, no refetch.
## Changed
### Dependencies — npm / Cargo refresh and rodio 0.22
+68
View File
@@ -0,0 +1,68 @@
import { Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Props {
active: boolean;
onChange: (next: boolean) => void;
/** 'default' = icon + label, regular padding (Albums toolbar).
* 'compact' = icon-only, 0.5rem padding (Artists view-mode buttons).
* 'small' = icon + label, 4px/14px padding + 12px text (AdvancedSearch tabs). */
size?: 'default' | 'compact' | 'small';
}
export default function StarFilterButton({ active, onChange, size = 'default' }: Props) {
const { t } = useTranslation();
const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff');
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
if (size === 'compact') {
return (
<button
type="button"
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
onClick={() => onChange(!active)}
aria-pressed={active}
aria-label={tooltip}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
style={{ padding: '0.5rem', ...activeStyle }}
>
<Star size={20} fill={active ? 'currentColor' : 'none'} />
</button>
);
}
if (size === 'small') {
return (
<button
type="button"
className={`btn ${active ? 'btn-primary' : 'btn-surface'}`}
onClick={() => onChange(!active)}
aria-pressed={active}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
style={{ fontSize: 12, padding: '4px 14px', display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
>
<Star size={12} fill={active ? 'currentColor' : 'none'} />
{t('common.favorites')}
</button>
);
}
return (
<button
type="button"
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
onClick={() => onChange(!active)}
aria-pressed={active}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem', ...activeStyle,
}}
>
<Star size={14} fill={active ? 'currentColor' : 'none'} />
{t('common.favorites')}
</button>
);
}
+3
View File
@@ -495,6 +495,9 @@ export const deTranslation = {
filterSearchGenres: 'Genres suchen…',
filterNoGenres: 'Keine Genres gefunden',
filterClear: 'Zurücksetzen',
favorites: 'Favoriten',
favoritesTooltipOff: 'Nur Favoriten anzeigen',
favoritesTooltipOn: 'Alle anzeigen',
play: 'Abspielen',
bulkSelected: '{{count}} ausgewählt',
clearSelection: 'Auswahl aufheben',
+3
View File
@@ -497,6 +497,9 @@ export const enTranslation = {
filterSearchGenres: 'Search genres…',
filterNoGenres: 'No genres match',
filterClear: 'Clear',
favorites: 'Favorites',
favoritesTooltipOff: 'Show only favorites',
favoritesTooltipOn: 'Show all',
play: 'Play',
bulkSelected: '{{count}} selected',
clearSelection: 'Clear selection',
+3
View File
@@ -496,6 +496,9 @@ export const esTranslation = {
filterSearchGenres: 'Buscar géneros…',
filterNoGenres: 'Ningún género coincide',
filterClear: 'Limpiar',
favorites: 'Favoritos',
favoritesTooltipOff: 'Mostrar solo favoritos',
favoritesTooltipOn: 'Mostrar todo',
play: 'Reproducir',
bulkSelected: '{{count}} seleccionados',
clearSelection: 'Limpiar selección',
+3
View File
@@ -494,6 +494,9 @@ export const frTranslation = {
filterSearchGenres: 'Rechercher des genres…',
filterNoGenres: 'Aucun genre trouvé',
filterClear: 'Effacer',
favorites: 'Favoris',
favoritesTooltipOff: 'Afficher uniquement les favoris',
favoritesTooltipOn: 'Tout afficher',
play: 'Lire',
bulkSelected: '{{count}} sélectionné(s)',
clearSelection: 'Effacer la sélection',
+3
View File
@@ -494,6 +494,9 @@ export const nbTranslation = {
filterSearchGenres: 'Søk i sjangre…',
filterNoGenres: 'Ingen sjangre samsvarer',
filterClear: 'Tøm',
favorites: 'Favoritter',
favoritesTooltipOff: 'Vis bare favoritter',
favoritesTooltipOn: 'Vis alle',
play: 'Spill',
bulkSelected: '{{count}} valgt',
clearSelection: 'Fjern utvalg',
+3
View File
@@ -493,6 +493,9 @@ export const nlTranslation = {
filterSearchGenres: 'Genres zoeken…',
filterNoGenres: 'Geen genres gevonden',
filterClear: 'Wissen',
favorites: 'Favorieten',
favoritesTooltipOff: 'Alleen favorieten tonen',
favoritesTooltipOn: 'Alles tonen',
play: 'Afspelen',
bulkSelected: '{{count}} geselecteerd',
clearSelection: 'Selectie wissen',
+3
View File
@@ -525,6 +525,9 @@ export const ruTranslation = {
filterSearchGenres: 'Поиск жанров…',
filterNoGenres: 'Нет совпадений',
filterClear: 'Сбросить',
favorites: 'Избранное',
favoritesTooltipOff: 'Показывать только избранное',
favoritesTooltipOn: 'Показать всё',
play: 'Воспроизвести',
bulkSelected: 'Выбрано: {{count}}',
clearSelection: 'Сбросить выбор',
+3
View File
@@ -488,6 +488,9 @@ export const zhTranslation = {
filterSearchGenres: '搜索流派…',
filterNoGenres: '未找到匹配流派',
filterClear: '清除',
favorites: '收藏',
favoritesTooltipOff: '仅显示收藏',
favoritesTooltipOn: '显示全部',
play: '播放',
bulkSelected: '已选 {{count}} 首',
clearSelection: '清除选择',
+28 -12
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SlidersVertical } from 'lucide-react';
import {
@@ -10,7 +10,9 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import SongRow, { SongListHeader } from '../components/SongRow';
import CustomSelect from '../components/CustomSelect';
import StarFilterButton from '../components/StarFilterButton';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
@@ -37,10 +39,23 @@ export default function AdvancedSearch() {
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
const [resultType, setResultType] = useState<ResultType>('all');
const [starredOnly, setStarredOnly] = useState(false);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [results, setResults] = useState<Results | null>(null);
const total = results
? results.artists.length + results.albums.length + results.songs.length
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const filteredResults = useMemo<Results | null>(() => {
if (!results) return null;
if (!starredOnly) return results;
const isFav = (id: string, base: boolean | string | undefined) =>
id in starredOverrides ? !!starredOverrides[id] : !!base;
return {
artists: results.artists.filter(a => isFav(a.id, a.starred)),
albums: results.albums.filter(a => isFav(a.id, a.starred)),
songs: results.songs.filter(s => isFav(s.id, s.starred)),
};
}, [results, starredOnly, starredOverrides]);
const total = filteredResults
? filteredResults.artists.length + filteredResults.albums.length + filteredResults.songs.length
: 0;
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
@@ -260,7 +275,7 @@ export default function AdvancedSearch() {
{/* Row 3: Result type + Search button */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
{typeOptions.map(opt => (
<button
key={opt.id}
@@ -272,6 +287,7 @@ export default function AdvancedSearch() {
{opt.label}
</button>
))}
<StarFilterButton size="small" active={starredOnly} onChange={setStarredOnly} />
</div>
<button
className="btn btn-primary"
@@ -303,21 +319,21 @@ export default function AdvancedSearch() {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{results && results.artists.length > 0 && (
{filteredResults && filteredResults.artists.length > 0 && (
<ArtistRow
title={`${t('search.artists')} (${results.artists.length})`}
artists={results.artists}
title={`${t('search.artists')} (${filteredResults.artists.length})`}
artists={filteredResults.artists}
/>
)}
{results && results.albums.length > 0 && (
{filteredResults && filteredResults.albums.length > 0 && (
<AlbumRow
title={`${t('search.albums')} (${results.albums.length})`}
albums={results.albums}
title={`${t('search.albums')} (${filteredResults.albums.length})`}
albums={filteredResults.albums}
/>
)}
{results && results.songs.length > 0 && (
{filteredResults && filteredResults.songs.length > 0 && (
<section>
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>
{t('search.songs')}
@@ -328,7 +344,7 @@ export default function AdvancedSearch() {
)}
</h2>
<SongListHeader />
{results.songs.map(song => (
{filteredResults.songs.map(song => (
<SongRow key={song.id} song={song} />
))}
{songsHasMore && (
+13 -4
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import YearFilterButton from '../components/YearFilterButton';
import StarFilterButton from '../components/StarFilterButton';
import SortDropdown from '../components/SortDropdown';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import { songToTrack } from '../store/playerStore';
@@ -50,6 +51,7 @@ export default function Albums() {
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
const [compFilter, setCompFilter] = useState<CompFilter>('all');
const [starredOnly, setStarredOnly] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
// ── Multi-selection ──────────────────────────────────────────────────────
@@ -74,11 +76,16 @@ export default function Albums() {
setSelectedIds(new Set());
};
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const visibleAlbums = useMemo(() => {
if (compFilter === 'all') return albums;
if (compFilter === 'only') return albums.filter(a => a.isCompilation);
return albums.filter(a => !a.isCompilation);
}, [albums, compFilter]);
let out = albums;
if (compFilter === 'only') out = out.filter(a => a.isCompilation);
else if (compFilter === 'hide') out = out.filter(a => !a.isCompilation);
if (starredOnly) {
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
}
return out;
}, [albums, compFilter, starredOnly, starredOverrides]);
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
@@ -261,6 +268,8 @@ export default function Albums() {
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<StarFilterButton active={starredOnly} onChange={setStarredOnly} />
<button
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
onClick={cycleCompFilter}
+9 -2
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
import StarFilterButton from '../components/StarFilterButton';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
@@ -79,6 +80,7 @@ export default function Artists() {
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
const [starredOnly, setStarredOnly] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const showArtistImages = useAuthStore(s => s.showArtistImages);
@@ -129,8 +131,9 @@ export default function Artists() {
// Reset infinite scroll when filters or image setting change
useEffect(() => {
setVisibleCount(PAGE_SIZE);
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
}, [filter, letterFilter, starredOnly, viewMode, PAGE_SIZE]);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
// Filter pipeline — memoised so unrelated state changes (selection mode,
// viewMode, etc.) don't re-iterate the full artists array. With 5000+
// artists each re-render walked the list twice without this.
@@ -148,8 +151,11 @@ export default function Artists() {
const needle = filter.toLowerCase();
out = out.filter(a => a.name.toLowerCase().includes(needle));
}
if (starredOnly) {
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
}
return out;
}, [artists, letterFilter, filter]);
}, [artists, letterFilter, filter, starredOnly, starredOverrides]);
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
const hasMore = visibleCount < filtered.length;
@@ -200,6 +206,7 @@ export default function Artists() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
+1
View File
@@ -361,6 +361,7 @@ const CONTRIBUTORS = [
'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
'Random Mix: playlist-size picker (50/75/100/125/150) and filter-panel layout cleanup (PR #445)',
'Queue: optional "Preserve Play Next order" toggle — multiple Play Next inserts queue up behind each other instead of latest-on-top (PR #464)',
'Library: "favorites only" filter on Albums, Artists and Advanced Search — toolbar toggle reading star overrides live (PR #466)',
],
},
] as const;