fix(flutter): system playlist tap loads tracks (mirror album refetch fix)

System playlists now show in the home row (good!) but tapping them
landed on an empty playlist. Same root cause as the earlier album
bug: playlistsListProvider wrote the playlist *row* to drift but
never the tracks. playlistDetailProvider only triggered cold-fetch
when the row was missing, so it yielded with an empty track list.

Refactor playlistDetailProvider to mirror albumProvider's approach:
- fetchAttempted guard (one-shot per subscription).
- Detect "playlist row exists but trackRows empty" and trigger the
  same fetchAndPopulate the cold-cache path uses. Drift watch
  re-emits with populated tracks.
- 10s timeout on the API fetch + diagnostic prints so any failure
  surfaces in logs.

While here, fix two adjacent issues:
- Wipe + re-insert this playlist's track positions on every fetch
  so server-side deletions actually propagate. Without the wipe,
  removed tracks would linger in drift forever.
- Write the artist + album rows referenced by the fetched tracks
  into cachedArtists / cachedAlbums (deduped). Without this, the
  joined artistName/albumTitle columns in the playlist track rows
  surface empty.

Cover art for system playlists is a separate issue — server emits
coverUrl but it may be empty for system mixes; PlaylistCard falls
back to the queue_music icon. Will tackle in a follow-up.
This commit is contained in:
2026-05-11 12:26:17 -04:00
parent a77d4ceac0
commit 2299824ad9
@@ -135,47 +135,134 @@ final playlistDetailProvider =
}
}
Future<void> fetchAndPopulate() async {
final api = await ref.read(playlistsApiProvider.future);
final fresh = await api.get(id);
await db.batch((b) {
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
mode: drift.InsertMode.insertOrReplace);
for (var i = 0; i < fresh.tracks.length; i++) {
final t = fresh.tracks[i];
if (t.trackId == null) continue;
b.insert(
db.cachedPlaylistTracks,
CachedPlaylistTracksCompanion.insert(
playlistId: id,
trackId: t.trackId!,
position: drift.Value(i),
),
mode: drift.InsertMode.insertOrReplace,
);
Future<bool> fetchAndPopulate() async {
try {
final api = await ref.read(playlistsApiProvider.future);
debugPrint('playlistDetailProvider($id): calling get');
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
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.
final artists = <String, ArtistRefRow>{};
final albums = <String, AlbumRefRow>{};
for (final t in fresh.tracks) {
final aId = t.artistId;
final aName = t.artistName;
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName));
}
final lbId = t.albumId;
final lbTitle = t.albumTitle;
if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) {
albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? ''));
}
}
});
await db.batch((b) {
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
mode: drift.InsertMode.insertOrReplace);
// Wipe + re-insert this playlist's track positions so deletions
// propagate. Without the wipe, removed tracks would linger in
// drift after the playlist mutated server-side.
b.deleteWhere(
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
for (var i = 0; i < fresh.tracks.length; i++) {
final t = fresh.tracks[i];
if (t.trackId == null) continue;
b.insert(
db.cachedPlaylistTracks,
CachedPlaylistTracksCompanion.insert(
playlistId: id,
trackId: t.trackId!,
position: drift.Value(i),
),
mode: drift.InsertMode.insertOrReplace,
);
}
if (artists.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedArtists,
artists.values
.map((a) => CachedArtistsCompanion.insert(
id: a.id,
name: a.name,
sortName: a.name,
))
.toList(),
);
}
if (albums.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedAlbums,
albums.values
.map((a) => CachedAlbumsCompanion.insert(
id: a.id,
artistId: a.artistId,
title: a.title,
sortTitle: a.title,
))
.toList(),
);
}
});
debugPrint(
'playlistDetailProvider($id): drift write done; awaiting watch re-emit');
return true;
} catch (e, st) {
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
return false;
}
}
// Once-per-subscription guard so an empty server response doesn't
// cause repeated re-fetches.
var fetchAttempted = false;
var revalidated = false;
await for (final playlistRows in playlistQuery.watch()) {
if (playlistRows.isEmpty) {
if (await isOnline()) {
try {
await fetchAndPopulate();
// watch() re-emits next iteration with populated row.
} catch (_) {
yield emptyDetail();
}
} else {
debugPrint('playlistDetailProvider($id): drift miss, cold-fetching');
if (fetchAttempted) {
yield emptyDetail();
continue;
}
fetchAttempted = true;
if (!await isOnline()) {
yield emptyDetail();
continue;
}
final ok = await fetchAndPopulate();
if (!ok) yield emptyDetail();
continue;
}
debugPrint(
'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)');
final playlist = playlistRows.first.toRef();
final trackRows = await tracksQuery.get();
// Same pattern as albumProvider: playlist row exists but no tracks
// (e.g., playlistsListProvider wrote the row, sync hasn't carried
// tracks for system playlists). Trigger the same fetch, drift
// watch re-emits with populated tracks.
if (trackRows.isEmpty && !fetchAttempted) {
debugPrint(
'playlistDetailProvider($id): playlist hit but no tracks; fetching');
fetchAttempted = true;
if (await isOnline()) {
final ok = await fetchAndPopulate();
if (ok) continue; // wait for watch re-emit
}
}
final tracks = trackRows.asMap().entries.map((e) {
final r = e.value;
final track = r.readTableOrNull(db.cachedTracks);
@@ -196,18 +283,32 @@ final playlistDetailProvider =
yield PlaylistDetail(playlist: playlist, tracks: tracks);
// SWR: first complete cache hit kicks one background refresh so
// the playlist catches up to server state (new tracks, renames,
// etc.) without making the user wait.
if (!revalidated) {
// SWR: first complete cache hit kicks one background refresh.
if (!revalidated && trackRows.isNotEmpty) {
revalidated = true;
if (await isOnline()) {
unawaited(fetchAndPopulate().catchError((_) {}));
unawaited(fetchAndPopulate());
}
}
}
});
/// Lightweight tuples used inside fetchAndPopulate to dedupe artist
/// and album rows referenced by playlist tracks before we batch-write
/// them to drift.
class ArtistRefRow {
ArtistRefRow(this.id, this.name);
final String id;
final String name;
}
class AlbumRefRow {
AlbumRefRow(this.id, this.title, this.artistId);
final String id;
final String title;
final String artistId;
}
final systemPlaylistsStatusProvider =
FutureProvider<SystemPlaylistsStatus>((ref) async {
final dio = await ref.watch(dioProvider.future);