fix(flutter): tighter track row + missing artist in mini player + hide redundant artist on artist page

Track row (album detail):
- Vertical padding 10→6 and only render the artist line when non-empty,
  so tracks without a populated artist row don't reserve a blank line.
- Number column 28→22 — visually tighter without making 3-digit
  numbers cramp.

Mini player missing artist:
- Symptom was an empty `artist` field on MediaItem after tapping a
  track in album detail; layout change wasn't the cause. Real cause:
  albumProvider's cold-cache only wrote cachedAlbums + cachedTracks,
  never cachedArtists. The drift JOINs that build the AlbumRef + each
  TrackRef returned null for the artist row, so artistName fell back
  to '' and the audio handler stamped MediaItem.artist=''.
- Cold-cache now collects every distinct (artistId, artistName) pair
  off the album header + each track, inserts them into cachedArtists
  alongside the album/tracks. Subsequent JOINs populate artistName,
  the audio handler stamps it on MediaItem, and the mini player picks
  it up via the existing artistName.isNotEmpty render.
- Existing cached albums won't get artist names until they're
  re-fetched (cache invalidation TBD). Future cold-cache hits will.

Artist detail album grid:
- AlbumCard gains showArtist (default true). Pass false in the artist
  detail grid since the page header already names the artist. cellH
  trimmed by the 16dp artist line.
This commit is contained in:
2026-05-11 08:47:50 -04:00
parent 21ab0d78bb
commit 983a9d92be
4 changed files with 69 additions and 24 deletions
@@ -89,8 +89,10 @@ class ArtistDetailScreen extends ConsumerWidget {
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
cols;
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
// ≈ 36) + artist line ≈ 16 + small fudge.
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
// ≈ 36) + small fudge. Artist line is suppressed in this
// grid (showArtist: false) since the page header already
// names the artist.
final cellH = (cellW - 16) + 8 + 36 + 4;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -108,6 +110,7 @@ class ArtistDetailScreen extends ConsumerWidget {
album: album,
width: cellW,
titleMaxLines: 2,
showArtist: false,
onTap: () => context.push('/albums/${album.id}'),
);
},
@@ -179,7 +179,35 @@ final albumProvider = StreamProvider.family<
.getAlbum(albumId)
.timeout(const Duration(seconds: 10));
debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift');
// Collect every artist mentioned by the album + its tracks so
// the JOINs that drive both the album header and the track
// rows have something to bind to. Without this, drift returns
// null for the artist row and artistName surfaces empty —
// visible as a missing artist line in the mini player and
// the row list.
final artists = <String, ArtistRef>{};
if (fresh.album.artistId.isNotEmpty &&
fresh.album.artistName.isNotEmpty) {
artists[fresh.album.artistId] = ArtistRef(
id: fresh.album.artistId,
name: fresh.album.artistName,
);
}
for (final t in fresh.tracks) {
if (t.artistId.isNotEmpty &&
t.artistName.isNotEmpty &&
!artists.containsKey(t.artistId)) {
artists[t.artistId] = ArtistRef(
id: t.artistId,
name: t.artistName,
);
}
}
await db.batch((b) {
if (artists.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedArtists, artists.values.map((a) => a.toDrift()).toList());
}
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
b.insertAllOnConflictUpdate(db.cachedTracks,
fresh.tracks.map((t) => t.toDrift()).toList());
@@ -11,6 +11,7 @@ class AlbumCard extends StatelessWidget {
required this.onTap,
this.width = 140,
this.titleMaxLines = 1,
this.showArtist = true,
super.key,
});
final AlbumRef album;
@@ -26,6 +27,10 @@ class AlbumCard extends StatelessWidget {
/// ellipsized character at narrower widths.
final int titleMaxLines;
/// Suppress the artist name. Useful on surfaces where the artist is
/// already implied by the page header (e.g. artist detail).
final bool showArtist;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
@@ -61,12 +66,13 @@ class AlbumCard extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Text(
album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
if (showArtist && album.artistName.isNotEmpty)
Text(
album.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
),
@@ -30,31 +30,39 @@ class TrackRow extends StatelessWidget {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(children: [
if (track.trackNumber != null)
SizedBox(
width: 28,
width: 22,
child: Text(
track.trackNumber.toString(),
style: TextStyle(color: fs.ash, fontSize: 13),
),
),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
]),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
// Skip the artist line entirely when empty so the row
// height collapses to a single line of title — keeps
// dense album views from looking padded.
if (track.artistName.isNotEmpty)
Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12),
),
],
),
),
CachedIndicator(trackId: track.id),
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),