mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
committed by
GitHub
parent
0fab2849e5
commit
d33abf565c
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -525,6 +525,9 @@ export const ruTranslation = {
|
||||
filterSearchGenres: 'Поиск жанров…',
|
||||
filterNoGenres: 'Нет совпадений',
|
||||
filterClear: 'Сбросить',
|
||||
favorites: 'Избранное',
|
||||
favoritesTooltipOff: 'Показывать только избранное',
|
||||
favoritesTooltipOn: 'Показать всё',
|
||||
play: 'Воспроизвести',
|
||||
bulkSelected: 'Выбрано: {{count}}',
|
||||
clearSelection: 'Сбросить выбор',
|
||||
|
||||
@@ -488,6 +488,9 @@ export const zhTranslation = {
|
||||
filterSearchGenres: '搜索流派…',
|
||||
filterNoGenres: '未找到匹配流派',
|
||||
filterClear: '清除',
|
||||
favorites: '收藏',
|
||||
favoritesTooltipOff: '仅显示收藏',
|
||||
favoritesTooltipOn: '显示全部',
|
||||
play: '播放',
|
||||
bulkSelected: '已选 {{count}} 首',
|
||||
clearSelection: '清除选择',
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user