mobile-music/lib/library/browse_query.dart
2026-08-04 16:08:51 -04:00

149 lines
4.8 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});
/// Case-insensitive genre match, or null for "all genres".
final String? genre;
/// Exact release-year match, or null for "all years".
final int? year;
bool get isActive => genre != null || year != null;
int get activeCount => (genre != null ? 1 : 0) + (year != null ? 1 : 0);
BrowseFilter copyWith({Object? genre = _unset, Object? year = _unset}) =>
BrowseFilter(
genre: identical(genre, _unset) ? this.genre : genre as String?,
year: identical(year, _unset) ? this.year : year 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());
/// 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,
) {
final out = songs
.where((s) => _genreMatches(s.genre, filter.genre))
.where((s) => filter.year == null || s.year == filter.year)
.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);
});
}
return out;
}