983a9d92be
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.
159 lines
5.8 KiB
Dart
159 lines
5.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../api/endpoints/likes.dart';
|
|
import '../likes/like_button.dart';
|
|
import '../models/album.dart';
|
|
import '../player/player_provider.dart';
|
|
import '../shared/widgets/server_image.dart';
|
|
import '../theme/theme_extension.dart';
|
|
import 'library_providers.dart';
|
|
import 'widgets/album_card.dart';
|
|
|
|
class ArtistDetailScreen extends ConsumerWidget {
|
|
const ArtistDetailScreen({required this.id, super.key});
|
|
final String id;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final artist = ref.watch(artistProvider(id));
|
|
final albums = ref.watch(artistAlbumsProvider(id));
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(),
|
|
backgroundColor: fs.obsidian,
|
|
body: artist.when(
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
data: (a) => ListView(children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(children: [
|
|
ClipOval(
|
|
child: SizedBox(
|
|
width: 96,
|
|
height: 96,
|
|
// Server derives artist cover from a representative
|
|
// album. Drift cache doesn't persist that pointer, so
|
|
// mirror the trick client-side: reuse the first album
|
|
// returned by artistAlbumsProvider. Falls back to
|
|
// slate while albums are loading or empty.
|
|
child: _ArtistAvatar(
|
|
serverCoverUrl: a.coverUrl,
|
|
albums: albums,
|
|
fs: fs,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Text(
|
|
a.name,
|
|
style: TextStyle(
|
|
color: fs.parchment,
|
|
fontFamily: fs.display.fontFamily,
|
|
fontSize: 24,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
width: 48, height: 48,
|
|
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
|
child: IconButton(
|
|
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
|
onPressed: () async {
|
|
final tracks = await ref.read(artistTracksProvider(id).future);
|
|
if (tracks.isEmpty) return;
|
|
final shuffled = [...tracks]..shuffle();
|
|
ref.read(playerActionsProvider).playTracks(shuffled);
|
|
},
|
|
),
|
|
),
|
|
LikeButton(kind: LikeKind.artist, id: a.id, size: 28),
|
|
]),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text('Albums', style: TextStyle(fontSize: 16)),
|
|
),
|
|
albums.when(
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
|
data: (list) => LayoutBuilder(builder: (ctx, constraints) {
|
|
const cols = 3;
|
|
const sidePad = 8.0;
|
|
const gap = 8.0;
|
|
final cellW =
|
|
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
|
cols;
|
|
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
|
// ≈ 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(),
|
|
padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16),
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: cols,
|
|
mainAxisExtent: cellH,
|
|
mainAxisSpacing: gap,
|
|
crossAxisSpacing: gap,
|
|
),
|
|
itemCount: list.length,
|
|
itemBuilder: (_, i) {
|
|
final AlbumRef album = list[i];
|
|
return AlbumCard(
|
|
album: album,
|
|
width: cellW,
|
|
titleMaxLines: 2,
|
|
showArtist: false,
|
|
onTap: () => context.push('/albums/${album.id}'),
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
|
/// non-empty; otherwise we mirror the server's "use the first album's
|
|
/// cover" rule client-side via the loaded album list.
|
|
class _ArtistAvatar extends StatelessWidget {
|
|
const _ArtistAvatar({
|
|
required this.serverCoverUrl,
|
|
required this.albums,
|
|
required this.fs,
|
|
});
|
|
final String serverCoverUrl;
|
|
final AsyncValue<List<AlbumRef>> albums;
|
|
final FabledSwordTheme fs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (serverCoverUrl.isNotEmpty) {
|
|
return ServerImage(
|
|
url: serverCoverUrl,
|
|
fit: BoxFit.cover,
|
|
fallback: Container(color: fs.slate),
|
|
);
|
|
}
|
|
final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null;
|
|
if (firstId == null) {
|
|
return Container(color: fs.slate);
|
|
}
|
|
return ServerImage(
|
|
url: '/api/albums/$firstId/cover',
|
|
fit: BoxFit.cover,
|
|
fallback: Container(color: fs.slate),
|
|
);
|
|
}
|
|
}
|