fix(flutter): write cachedTracks rows from playlist fetch (was the empty-rows bug)

Detail screen showed empty rows for system playlists because the
fetch batch wrote cachedPlaylists, cachedPlaylistTracks (positions),
cachedArtists, and cachedAlbums — but never cachedTracks themselves.
The detail screen's LEFT OUTER JOIN cachedTracks then returned null
on every row, so trackId was null and titles came back empty.

PlaylistTrack on the wire carries enough to populate cachedTracks
(id, title, albumId, artistId, durationSec). Adds a third dedup map
in fetchAndPopulate, batched with the existing artist + album writes.
track_number / disc_number aren't on the wire so they default to 0;
the detail screen doesn't surface them.

Reusing cachedTracks across albumProvider + playlistDetailProvider
also means tapping a playlist's track to play it now finds the row
in drift instead of triggering another fetch.
This commit is contained in:
2026-05-11 18:38:21 -04:00
parent 8d466ebdd5
commit 572325e23f
@@ -158,13 +158,29 @@ final playlistDetailProvider =
debugPrint(
'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing');
// Collect the artist + album rows referenced by these tracks so
// the JOINs that build artistName/albumTitle in the row view
// have something to bind to. Without this, system-playlist tracks
// surface with empty artist/album columns.
// Collect the track + artist + album rows referenced by these
// playlist entries. Without writing the cachedTracks rows
// themselves, the detail-screen JOIN against cachedTracks
// returns null on every row (only the playlist_tracks join
// succeeds), so the UI shows a list with empty titles and
// unplayable tracks.
final tracks = <String, _TrackRefRow>{};
final artists = <String, ArtistRefRow>{};
final albums = <String, AlbumRefRow>{};
for (final t in fresh.tracks) {
final tId = t.trackId;
if (tId != null && tId.isNotEmpty) {
tracks.putIfAbsent(
tId,
() => _TrackRefRow(
id: tId,
title: t.title,
albumId: t.albumId ?? '',
artistId: t.artistId ?? '',
durationSec: t.durationSec,
),
);
}
final aId = t.artistId;
final aName = t.artistName;
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
@@ -213,6 +229,25 @@ final playlistDetailProvider =
.toList(),
);
}
if (tracks.isNotEmpty) {
// Insert/update cachedTracks so the detail screen's JOIN
// produces real titles + durations. We don't have
// track_number / disc_number on the wire (PlaylistTrack
// omits them), so they default to 0 — UI doesn't surface
// them on this screen so it's fine.
b.insertAllOnConflictUpdate(
db.cachedTracks,
tracks.values
.map((t) => CachedTracksCompanion.insert(
id: t.id,
albumId: t.albumId,
artistId: t.artistId,
title: t.title,
durationMs: drift.Value(t.durationSec * 1000),
))
.toList(),
);
}
if (albums.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedAlbums,
@@ -339,6 +374,21 @@ class AlbumRefRow {
final String artistId;
}
class _TrackRefRow {
_TrackRefRow({
required this.id,
required this.title,
required this.albumId,
required this.artistId,
required this.durationSec,
});
final String id;
final String title;
final String albumId;
final String artistId;
final int durationSec;
}
final systemPlaylistsStatusProvider =
FutureProvider<SystemPlaylistsStatus>((ref) async {
final dio = await ref.watch(dioProvider.future);