181 lines
6 KiB
Dart
181 lines
6 KiB
Dart
import '../settings/settings_store.dart';
|
||
import '../subsonic/models.dart';
|
||
|
||
/// Session-scoped filter for a browse view: an optional genre and release year.
|
||
/// 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, this.minRating});
|
||
|
||
/// Case-insensitive genre match, or null for "all genres".
|
||
final String? genre;
|
||
|
||
/// Exact release-year match, or null for "all years".
|
||
final int? year;
|
||
|
||
/// 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;
|
||
|
||
bool get isActive => genre != null || year != null || minRating != null;
|
||
|
||
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();
|
||
}
|
||
|
||
/// Distinct, display-cased genres from a set of raw genre strings, sorted
|
||
/// alphabetically. Dedupes case-insensitively (keeping first-seen casing) so a
|
||
/// server that mixes "Rock"/"rock" collapses to one entry.
|
||
List<String> distinctGenres(Iterable<String?> raw) {
|
||
final byKey = <String, String>{};
|
||
for (final g in raw) {
|
||
final v = g?.trim();
|
||
if (v == null || v.isEmpty) continue;
|
||
byKey.putIfAbsent(v.toLowerCase(), () => v);
|
||
}
|
||
final out = byKey.values.toList()
|
||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||
return out;
|
||
}
|
||
|
||
/// Distinct release years present, newest first.
|
||
List<int> distinctYears(Iterable<int?> raw) {
|
||
final set = <int>{};
|
||
for (final y in raw) {
|
||
if (y != null && y > 0) set.add(y);
|
||
}
|
||
final out = set.toList()..sort((a, b) => b.compareTo(a));
|
||
return out;
|
||
}
|
||
|
||
bool _genreMatches(String? itemGenre, String? filterGenre) {
|
||
if (filterGenre == null) return true;
|
||
return itemGenre != null &&
|
||
itemGenre.toLowerCase() == filterGenre.toLowerCase();
|
||
}
|
||
|
||
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}) {
|
||
if (a == null && b == null) return 0;
|
||
if (a == null) return 1;
|
||
if (b == null) return -1;
|
||
final c = a.compareTo(b);
|
||
return descending ? -c : c;
|
||
}
|
||
|
||
/// Filter then sort albums for the browse grid. Pure — no I/O.
|
||
List<Album> applyAlbumQuery(
|
||
List<Album> albums,
|
||
BrowseFilter filter,
|
||
AlbumSort sort,
|
||
) {
|
||
final out = albums
|
||
.where((a) => _genreMatches(a.genre, filter.genre))
|
||
.where((a) => filter.year == null || a.year == filter.year)
|
||
.toList();
|
||
|
||
switch (sort) {
|
||
case AlbumSort.nameAsc:
|
||
out.sort((a, b) => _byString(a.name, b.name));
|
||
case AlbumSort.artistAsc:
|
||
out.sort((a, b) {
|
||
final c = _byString(a.artist, b.artist);
|
||
return c != 0 ? c : _byString(a.name, b.name);
|
||
});
|
||
case AlbumSort.yearDesc:
|
||
out.sort((a, b) {
|
||
final c = _nullsLast(a.year, b.year, descending: true);
|
||
return c != 0 ? c : _byString(a.name, b.name);
|
||
});
|
||
case AlbumSort.yearAsc:
|
||
out.sort((a, b) {
|
||
final c = _nullsLast(a.year, b.year);
|
||
return c != 0 ? c : _byString(a.name, b.name);
|
||
});
|
||
case AlbumSort.recentlyAdded:
|
||
out.sort((a, b) {
|
||
final c = _nullsLast(a.createdAt, b.createdAt, descending: true);
|
||
return c != 0 ? c : _byString(a.name, b.name);
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/// Filter then sort tracks for the browse list. Pure — no I/O.
|
||
List<Song> applyTrackQuery(
|
||
List<Song> songs,
|
||
BrowseFilter filter,
|
||
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) {
|
||
case TrackSort.titleAsc:
|
||
out.sort((a, b) => _byString(a.title, b.title));
|
||
case TrackSort.artistAsc:
|
||
out.sort((a, b) {
|
||
final c = _byString(a.artist, b.artist);
|
||
return c != 0 ? c : _byString(a.title, b.title);
|
||
});
|
||
case TrackSort.albumAsc:
|
||
out.sort((a, b) {
|
||
final c = _byString(a.album, b.album);
|
||
if (c != 0) return c;
|
||
final t = _nullsLast(a.track, b.track);
|
||
return t != 0 ? t : _byString(a.title, b.title);
|
||
});
|
||
case TrackSort.yearDesc:
|
||
out.sort((a, b) {
|
||
final c = _nullsLast(a.year, b.year, descending: true);
|
||
return c != 0 ? c : _byString(a.title, b.title);
|
||
});
|
||
case TrackSort.recentlyAdded:
|
||
out.sort((a, b) {
|
||
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;
|
||
}
|