offline updates and playhead fix

This commit is contained in:
Forrest 2026-08-16 12:46:30 -04:00
parent 7a199fe4df
commit 6663330260
14 changed files with 1673 additions and 545 deletions

View file

@ -0,0 +1,153 @@
import '../subsonic/models.dart';
/// Reconstructs [Album]s and [Artist]s from a flat list of downloaded [Song]s.
/// The offline library only ever has tracks (that's all that's cached on disk),
/// so album/artist entities are synthesized on demand. Pure — no I/O.
/// Compare two strings case-insensitively, treating null as empty (sorts first).
int _byString(String? a, String? b) =>
(a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase());
/// Compare where a null [a]/[b] always sorts *last*. Takes bare [Comparable] so
/// `int` (`Comparable<num>`) works for disc/track ordering.
int _nullsLast(Comparable? a, Comparable? b) {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a.compareTo(b);
}
/// True for a present, non-blank id/name.
bool _has(String? v) => v != null && v.trim().isNotEmpty;
/// First non-blank value in [values], or null if none.
String? _firstNonNull(Iterable<String?> values) {
for (final v in values) {
if (_has(v)) return v;
}
return null;
}
/// First non-null value in [values], or null if none. For nullable ints.
int? _firstNonNullInt(Iterable<int?> values) {
for (final v in values) {
if (v != null) return v;
}
return null;
}
/// Stable album key: prefer [Song.albumId], else fall back to the album NAME.
/// The synthesized [Album.id] mirrors this exactly (see [albumsFromSongs]), so
/// Phase 2 providers can look an album up by the same key it was built under.
String? _albumKey(Song s) => _has(s.albumId) ? s.albumId : s.album;
/// Stable artist key: prefer [Song.artistId], else fall back to the artist NAME.
String? _artistKey(Song s) => _has(s.artistId) ? s.artistId : s.artist;
/// Synthesize [Album]s from downloaded [Song]s, grouped by [_albumKey].
///
/// Songs with neither an albumId nor an album name are skipped — with no album
/// identity they can't form a meaningful album. The synthesized [Album.id] is
/// the albumId when present, else the album *name* itself (the same string used
/// as the grouping key), so lookups by id stay stable across rebuilds.
///
/// Returned albums are sorted by name (case-insensitive ascending) for a stable
/// default order.
List<Album> albumsFromSongs(List<Song> songs) {
// Preserve first-seen insertion order within groups; output order is imposed
// by the final sort, so the map's own ordering only needs to be deterministic.
final groups = <String, List<Song>>{};
for (final s in songs) {
final key = _albumKey(s);
if (key == null) continue; // no album identity → skip
groups.putIfAbsent(key, () => []).add(s);
}
final out = <Album>[];
groups.forEach((key, group) {
// id: albumId if any song carries one, else the name-derived key.
final albumId = _firstNonNull(group.map((s) => s.albumId));
final id = albumId ?? key;
final sorted = [...group]
..sort((a, b) {
final d = _nullsLast(a.discNumber, b.discNumber);
if (d != 0) return d;
final t = _nullsLast(a.track, b.track);
return t != 0 ? t : _byString(a.title, b.title);
});
out.add(
Album(
id: id,
name: _firstNonNull(group.map((s) => s.album)),
artist: _firstNonNull(group.map((s) => s.artist)),
artistId: _firstNonNull(group.map((s) => s.artistId)),
coverArt: _firstNonNull(group.map((s) => s.coverArt)),
year: _firstNonNullInt(group.map((s) => s.year)),
genre: _firstNonNull(group.map((s) => s.genre)),
songCount: group.length,
songs: sorted,
),
);
});
out.sort((a, b) => _byString(a.name, b.name));
return out;
}
/// Synthesize [Artist]s from downloaded [Song]s, grouped by [_artistKey].
///
/// Songs with neither an artistId nor an artist name are skipped. Each artist's
/// [Artist.albums] is built by running [albumsFromSongs] over that artist's own
/// songs, and its [Artist.id] follows the same id/name fallback as albums.
///
/// Returned artists are sorted by name (case-insensitive ascending).
List<Artist> artistsFromSongs(List<Song> songs) {
final groups = <String, List<Song>>{};
for (final s in songs) {
final key = _artistKey(s);
if (key == null) continue; // no artist identity → skip
groups.putIfAbsent(key, () => []).add(s);
}
final out = <Artist>[];
groups.forEach((key, group) {
// id: artistId if any song carries one, else the name-derived key.
final artistId = _firstNonNull(group.map((s) => s.artistId));
final id = artistId ?? key;
final albums = albumsFromSongs(group);
out.add(
Artist(
id: id,
name: _firstNonNull(group.map((s) => s.artist)),
coverArt: _firstNonNull(albums.map((a) => a.coverArt)),
albums: albums,
albumCount: albums.length,
),
);
});
out.sort((a, b) => _byString(a.name, b.name));
return out;
}
/// The synthesized album whose id == [id], or null. Keyed lookup for the album
/// detail provider.
Album? albumFromSongs(List<Song> songs, String id) {
for (final a in albumsFromSongs(songs)) {
if (a.id == id) return a;
}
return null;
}
/// The synthesized artist whose id == [id], or null. Keyed lookup for the artist
/// detail provider.
Artist? artistFromSongs(List<Song> songs, String id) {
for (final a in artistsFromSongs(songs)) {
if (a.id == id) return a;
}
return null;
}