mobile-music/lib/library/browse_query.dart
2026-08-05 21:57:06 -04:00

181 lines
6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}