4bd069430b
MetadataPrefetcher gains warmAlbums(ids) / warmArtists(ids) public methods so callers can fan out drift-cache warm-ups for whatever collection just landed. Wired into: - Library Artists tab — warms first 8 artists from the page on every data emit (initial + paginate + refresh). - Library Albums tab — same for first 8 albums. - Artist detail album grid — warms first 8 albums from the artist's album list as soon as it loads, so tapping into any of them is a drift hit. Hard-cap of 8 per call (same as the home prefetch). Set spans across calls aren't deduped at this layer because providers themselves short-circuit on cached values. Also instrument the artist-detail play button: try/catch around the artistTracksProvider read + playTracks call, snackbar on empty-tracks or thrown-error so silent failures stop being silent. The current behavior was an early return on tracks.isEmpty with no visible feedback.
215 lines
8.0 KiB
Dart
215 lines
8.0 KiB
Dart
import 'package:flutter/foundation.dart' show debugPrint;
|
|
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 '../cache/metadata_prefetcher.dart';
|
|
import '../likes/like_button.dart';
|
|
import '../models/album.dart';
|
|
import '../models/artist.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, this.seed, super.key});
|
|
final String id;
|
|
|
|
/// Optional artist reference from the caller. Lets the screen render
|
|
/// the name + avatar immediately while albums load.
|
|
final ArtistRef? seed;
|
|
|
|
@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));
|
|
|
|
// Resolve which artist info to render in the header. Live wins
|
|
// when present and populated; seed fills the gap during the
|
|
// first frame after navigation.
|
|
final liveArtist = artist.value;
|
|
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
|
|
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(),
|
|
backgroundColor: fs.obsidian,
|
|
body: artist.when(
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
// While loading: render header from seed if available so the
|
|
// page isn't blank.
|
|
loading: () => seed == null
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _artistBody(context, ref, seed!, albums, fs),
|
|
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _artistBody(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
ArtistRef a,
|
|
AsyncValue<List<AlbumRef>> albums,
|
|
FabledSwordTheme fs,
|
|
) =>
|
|
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 {
|
|
try {
|
|
final tracks =
|
|
await ref.read(artistTracksProvider(id).future);
|
|
debugPrint(
|
|
'artist_detail: play tapped — ${tracks.length} tracks for $id');
|
|
if (tracks.isEmpty) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'No tracks found for this artist yet.')),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
final shuffled = [...tracks]..shuffle();
|
|
await ref
|
|
.read(playerActionsProvider)
|
|
.playTracks(shuffled);
|
|
} catch (e) {
|
|
debugPrint('artist_detail: play failed: $e');
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text("Couldn't start playback: $e")),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
),
|
|
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) {
|
|
ref
|
|
.read(metadataPrefetcherProvider)
|
|
.warmAlbums(list.map((a) => a.id));
|
|
return 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}', extra: album),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
},
|
|
),
|
|
]);
|
|
}
|
|
|
|
/// 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),
|
|
);
|
|
}
|
|
}
|