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 distinctGenres(Iterable raw) { final byKey = {}; 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 distinctYears(Iterable raw) { final set = {}; 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 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`) 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 applyAlbumQuery( List 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 applyTrackQuery( List songs, BrowseFilter filter, TrackSort sort, { Map 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; }