From d33abf565ccac54f6c11b9f7d040f4c3934ee774 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 5 May 2026 23:02:22 +0200 Subject: [PATCH] feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch (#466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CHANGELOG.md | 8 ++++ src/components/StarFilterButton.tsx | 68 +++++++++++++++++++++++++++++ src/locales/de.ts | 3 ++ src/locales/en.ts | 3 ++ src/locales/es.ts | 3 ++ src/locales/fr.ts | 3 ++ src/locales/nb.ts | 3 ++ src/locales/nl.ts | 3 ++ src/locales/ru.ts | 3 ++ src/locales/zh.ts | 3 ++ src/pages/AdvancedSearch.tsx | 40 ++++++++++++----- src/pages/Albums.tsx | 17 ++++++-- src/pages/Artists.tsx | 11 ++++- src/pages/Settings.tsx | 1 + 14 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 src/components/StarFilterButton.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 320b73d1..2b3db3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/components/StarFilterButton.tsx b/src/components/StarFilterButton.tsx new file mode 100644 index 00000000..5e9aed34 --- /dev/null +++ b/src/components/StarFilterButton.tsx @@ -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 ( + onChange(!active)} + aria-pressed={active} + aria-label={tooltip} + data-tooltip={tooltip} + data-tooltip-pos="bottom" + style={{ padding: '0.5rem', ...activeStyle }} + > + + + ); + } + + if (size === 'small') { + return ( + 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' }} + > + + {t('common.favorites')} + + ); + } + + return ( + onChange(!active)} + aria-pressed={active} + data-tooltip={tooltip} + data-tooltip-pos="bottom" + style={{ + display: 'flex', alignItems: 'center', gap: '0.4rem', ...activeStyle, + }} + > + + {t('common.favorites')} + + ); +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 899f2253..17caed90 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index e470a649..383d6d27 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/es.ts b/src/locales/es.ts index d0c201e0..bac368b9 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index f74da8c9..ef1c3691 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index a5844b42..9648e204 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 5169a723..14ab7760 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index fc0f9a7f..57819c78 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -525,6 +525,9 @@ export const ruTranslation = { filterSearchGenres: 'Поиск жанров…', filterNoGenres: 'Нет совпадений', filterClear: 'Сбросить', + favorites: 'Избранное', + favoritesTooltipOff: 'Показывать только избранное', + favoritesTooltipOn: 'Показать всё', play: 'Воспроизвести', bulkSelected: 'Выбрано: {{count}}', clearSelection: 'Сбросить выбор', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 7cd915fd..a494cca3 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -488,6 +488,9 @@ export const zhTranslation = { filterSearchGenres: '搜索流派…', filterNoGenres: '未找到匹配流派', filterClear: '清除', + favorites: '收藏', + favoritesTooltipOff: '仅显示收藏', + favoritesTooltipOn: '显示全部', play: '播放', bulkSelected: '已选 {{count}} 首', clearSelection: '清除选择', diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 27b84ce6..4be77da9 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -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('all'); + const [starredOnly, setStarredOnly] = useState(false); const [genres, setGenres] = useState([]); const [results, setResults] = useState(null); - const total = results - ? results.artists.length + results.albums.length + results.songs.length + const starredOverrides = usePlayerStore(s => s.starredOverrides); + const filteredResults = useMemo(() => { + 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 */} - + {typeOptions.map(opt => ( ))} + - {results && results.artists.length > 0 && ( + {filteredResults && filteredResults.artists.length > 0 && ( )} - {results && results.albums.length > 0 && ( + {filteredResults && filteredResults.albums.length > 0 && ( )} - {results && results.songs.length > 0 && ( + {filteredResults && filteredResults.songs.length > 0 && ( {t('search.songs')} @@ -328,7 +344,7 @@ export default function AdvancedSearch() { )} - {results.songs.map(song => ( + {filteredResults.songs.map(song => ( ))} {songsHasMore && ( diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 452286fe..32d48f1e 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -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('all'); + const [starredOnly, setStarredOnly] = useState(false); const observerTarget = useRef(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() { + + ('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() { {!(selectionMode && selectedIds.size > 0) && (<> + setShowArtistImages(!showArtistImages)} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 4c505987..814304f1 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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;