import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.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 '../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()!; 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> 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(LucideIcons.play, color: fs.parchment), onPressed: () async { try { final tracks = await ref.read(artistTracksProvider(id).future); 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) { 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) + slack. Artist line is suppressed in this // grid (showArtist: false) since the page header already // names the artist. Slack is generous on purpose — line- // height variations would otherwise overflow by 1px. final cellH = (cellW - 16) + 8 + 36 + 8; 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> 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), ); } }