This commit is contained in:
Forrest 2026-08-05 21:57:06 -04:00
parent 2ce32cac4e
commit b6639fb1c9
26 changed files with 454 additions and 204 deletions

View file

@ -5,7 +5,7 @@ import '../subsonic/models.dart';
/// Held in `state/providers.dart` (not persisted) so the app never silently
/// reopens filtered. Sort order *is* persisted (see [AppSettings]).
class BrowseFilter {
const BrowseFilter({this.genre, this.year});
const BrowseFilter({this.genre, this.year, this.minRating});
/// Case-insensitive genre match, or null for "all genres".
final String? genre;
@ -13,14 +13,27 @@ class BrowseFilter {
/// Exact release-year match, or null for "all years".
final int? year;
bool get isActive => genre != null || year != null;
/// Minimum star rating (1–5) a track must meet, or null for "any rating".
/// Tracks are kept when their effective rating is `>= minRating`.
final int? minRating;
int get activeCount => (genre != null ? 1 : 0) + (year != null ? 1 : 0);
bool get isActive => genre != null || year != null || minRating != null;
BrowseFilter copyWith({Object? genre = _unset, Object? year = _unset}) =>
int get activeCount =>
(genre != null ? 1 : 0) +
(year != null ? 1 : 0) +
(minRating != null ? 1 : 0);
BrowseFilter copyWith({
Object? genre = _unset,
Object? year = _unset,
Object? minRating = _unset,
}) =>
BrowseFilter(
genre: identical(genre, _unset) ? this.genre : genre as String?,
year: identical(year, _unset) ? this.year : year as int?,
minRating:
identical(minRating, _unset) ? this.minRating : minRating as int?,
);
static const Object _unset = Object();
@ -60,6 +73,15 @@ bool _genreMatches(String? itemGenre, String? filterGenre) {
int _byString(String? a, String? b) =>
(a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase());
/// A track's effective 0–5 rating: the live value from the favorites map (see
/// `state/favorites.dart`) when present, else the index-time [Song.userRating],
/// else 0 (unrated). Mirrors the star UI on the Now Playing screen.
int _effectiveRating(Song s, Map<String, int> ratings) {
final live = ratings[s.id];
if (live != null && live > 0) return live;
return s.userRating ?? 0;
}
/// Compare where a null [a]/[b] always sorts *last*, regardless of [descending].
/// Takes bare [Comparable] so both `int` (`Comparable<num>`) and `DateTime` work.
int _nullsLast(Comparable? a, Comparable? b, {bool descending = false}) {
@ -112,11 +134,15 @@ List<Album> applyAlbumQuery(
List<Song> applyTrackQuery(
List<Song> songs,
BrowseFilter filter,
TrackSort sort,
) {
TrackSort sort, {
Map<String, int> ratings = const {},
}) {
final out = songs
.where((s) => _genreMatches(s.genre, filter.genre))
.where((s) => filter.year == null || s.year == filter.year)
.where((s) =>
filter.minRating == null ||
_effectiveRating(s, ratings) >= filter.minRating!)
.toList();
switch (sort) {
@ -144,6 +170,12 @@ List<Song> applyTrackQuery(
final c = _nullsLast(a.createdAt, b.createdAt, descending: true);
return c != 0 ? c : _byString(a.title, b.title);
});
case TrackSort.ratingDesc:
out.sort((a, b) {
// Descending: highest rating first; unrated (0) naturally sinks last.
final c = _effectiveRating(b, ratings) - _effectiveRating(a, ratings);
return c != 0 ? c : _byString(a.title, b.title);
});
}
return out;
}