diff --git a/flutter_client/lib/cache/adapters.dart b/flutter_client/lib/cache/adapters.dart index 0785284f..11899469 100644 --- a/flutter_client/lib/cache/adapters.dart +++ b/flutter_client/lib/cache/adapters.dart @@ -17,10 +17,21 @@ import '../models/track.dart'; import 'db.dart'; extension CachedArtistAdapter on CachedArtist { - ArtistRef toRef() => ArtistRef( + /// `coverAlbumId` lets the caller pass a representative album id so + /// the ArtistRef carries a reconstructed `/api/albums//cover` + /// URL. cached_artists doesn't store this directly — the server + /// derives it from the most-recent album at query time — so drift + /// readers that want the cover URL join cached_albums and pass the + /// first album's id through. Empty string yields empty coverUrl, + /// matching the server's behavior for endpoints that don't carry + /// the lookup (artist detail, search, raw cached_artists row reads). + ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef( id: id, name: name, sortName: sortName, + coverUrl: coverAlbumId.isNotEmpty + ? '/api/albums/$coverAlbumId/cover' + : '', ); } diff --git a/flutter_client/lib/cache/tile_providers.dart b/flutter_client/lib/cache/tile_providers.dart index 8d9be83f..6fb8e1fa 100644 --- a/flutter_client/lib/cache/tile_providers.dart +++ b/flutter_client/lib/cache/tile_providers.dart @@ -18,6 +18,7 @@ // subscriptions; drift handles this fine in practice but worth // measuring if a future surface scales to hundreds. +import 'package:drift/drift.dart' as drift show OrderingTerm; import 'package:drift/drift.dart' show leftOuterJoin; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -63,6 +64,12 @@ final albumTileProvider = /// Watches the cached_artists row for [id]. Triggers background /// hydration on miss; yields the row once it lands. +/// +/// LEFT JOIN cached_albums ordered by sort_title so the first row per +/// artist carries a representative album id; toRef() reconstructs +/// `/api/albums//cover` from it. Without this join the ArtistRef +/// has an empty coverUrl and tiles fall back to the music-notes +/// placeholder even though the server has the cover available. final artistTileProvider = StreamProvider.family((ref, id) async* { if (id.isEmpty) { @@ -70,7 +77,12 @@ final artistTileProvider = return; } final db = ref.watch(appDbProvider); - final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id)); + final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id))) + .join([ + leftOuterJoin(db.cachedAlbums, + db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), + ]) + ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); var enqueued = false; await for (final rows in query.watch()) { if (rows.isEmpty) { @@ -83,7 +95,12 @@ final artistTileProvider = yield null; continue; } - yield rows.first.toRef(); + // First row has the alphabetically-first album (or null if artist + // has no albums in drift yet — yields a coverless ArtistRef which + // the UI falls back to the placeholder icon for). + final artist = rows.first.readTable(db.cachedArtists); + final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums); + yield artist.toRef(coverAlbumId: firstAlbum?.id ?? ''); } }); diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index f7065224..a5d064a0 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -233,17 +233,28 @@ Map _trackToJson(TrackRef t) => { final artistProvider = StreamProvider.family((ref, id) { final db = ref.watch(appDbProvider); - return cacheFirst( - driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id))) - .watch(), + // LEFT JOIN cached_albums for cover-URL reconstruction (see + // CachedArtistAdapter.toRef). First row carries the alphabetically- + // first album. + final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id))) + .join([ + drift.leftOuterJoin(db.cachedAlbums, + db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), + ]) + ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); + return cacheFirst( + driftStream: query.watch(), fetchAndPopulate: () async { final api = await ref.read(libraryApiProvider.future); final fresh = await api.getArtist(id); await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift()); }, - toResult: (rows) => rows.isEmpty - ? const ArtistRef(id: '', name: '') - : rows.first.toRef(), + toResult: (rows) { + if (rows.isEmpty) return const ArtistRef(id: '', name: ''); + final artist = rows.first.readTable(db.cachedArtists); + final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums); + return artist.toRef(coverAlbumId: firstAlbum?.id ?? ''); + }, isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index 8392c728..6b2c7693 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -62,9 +62,19 @@ final _meApiProvider = FutureProvider((ref) async { /// via /api/library/sync. final _libraryArtistsProvider = StreamProvider>((ref) { final db = ref.watch(appDbProvider); - final query = db.select(db.cachedArtists) - ..orderBy([(t) => drift.OrderingTerm.asc(t.sortName)]); - return cacheFirst>( + // LEFT JOIN cached_albums so each artist row carries a representative + // album id for cover-URL reconstruction. Sorted by artist sortName + // then album sortTitle: the first row per artist gets the + // alphabetically-first album. Dedup happens in toResult. + final query = db.select(db.cachedArtists).join([ + drift.leftOuterJoin(db.cachedAlbums, + db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), + ]) + ..orderBy([ + drift.OrderingTerm.asc(db.cachedArtists.sortName), + drift.OrderingTerm.asc(db.cachedAlbums.sortTitle), + ]); + return cacheFirst>( driftStream: query.watch(), fetchAndPopulate: () async { // Cold-cache fallback: sync should have populated drift already, @@ -80,7 +90,23 @@ final _libraryArtistsProvider = StreamProvider>((ref) { ); }); }, - toResult: (rows) => rows.map((r) => r.toRef()).toList(), + toResult: (rows) { + // The join multiplies rows by album count; dedup by artist id + // keeping the first occurrence so we get one ArtistRef per + // artist with the alphabetically-first album's id for the + // cover-URL projection. Artists with no albums in drift yet + // come through as a single row with null cachedAlbums and an + // empty coverUrl — UI falls back to the placeholder icon. + final seen = {}; + final out = []; + for (final r in rows) { + final a = r.readTable(db.cachedArtists); + if (!seen.add(a.id)) continue; + final album = r.readTableOrNull(db.cachedAlbums); + out.add(a.toRef(coverAlbumId: album?.id ?? '')); + } + return out; + }, isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)),