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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user