feat(flutter): #393 — always-visible play button on home cards

AlbumCard / ArtistCard / PlaylistCard gain a 44dp circular play
button overlaid bottom-right of the cover art. Mirrors the
hover-revealed .play-overlay on the web cards; always visible
because hover is not a real interaction on touch.

Per-card semantics match the web:

- AlbumCard: fetches /api/albums/{id}, starts playback from track 0.
- ArtistCard: fetches /api/artists/{id}/tracks, Fisher-Yates shuffles,
  plays from index 0 (matches web's playQueue(shuffle(tracks), 0)).
- PlaylistCard: fetches /api/playlists/{id}, materializes available
  rows into TrackRef, plays from index 0. Disabled state when
  trackCount == 0 — semi-transparent button, taps ignored.

Shared PlayCircleButton widget manages loading state (spinner during
fetch) so each card's onPressed can stay async without re-entrancy
guards. Cards become ConsumerWidget so they can reach the
playerActionsProvider + relevant API providers; constructor surface
unchanged so existing call sites (artist_detail, library_screen,
home_screen) keep working.

CompactTrackCard unchanged — its tap already plays-from-here.

For #393 / #356 umbrella.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 12:35:57 -04:00
parent 89d8b4b5a0
commit 872b0de304
4 changed files with 254 additions and 48 deletions
@@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.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 'play_circle_button.dart';
class AlbumCard extends StatelessWidget {
class AlbumCard extends ConsumerWidget {
const AlbumCard({
required this.album,
required this.onTap,
@@ -32,7 +36,7 @@ class AlbumCard extends StatelessWidget {
final bool showArtist;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final coverSize = width - 16;
return SizedBox(
@@ -47,17 +51,29 @@ class AlbumCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: coverSize,
height: coverSize,
color: fs.slate,
child: album.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg',
fit: BoxFit.cover)
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
),
// Stack: cover image + overlaid play button at bottom-right.
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: coverSize,
height: coverSize,
color: fs.slate,
child: album.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg',
fit: BoxFit.cover)
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
),
),
Positioned(
bottom: 6,
right: 6,
child: PlayCircleButton(
onPressed: () => _playAlbum(ref),
),
),
],
),
const SizedBox(height: 8),
Text(
@@ -80,4 +96,17 @@ class AlbumCard extends StatelessWidget {
),
);
}
/// Fetches the album's tracks via /api/albums/{id} and starts playback
/// from the first track. Errors are swallowed by the button's outer
/// try/finally; callers don't surface them — failed fetches just keep
/// the spinner visible until the button is retapped.
Future<void> _playAlbum(WidgetRef ref) async {
final api = await ref.read(libraryApiProvider.future);
final result = await api.getAlbum(album.id);
if (result.tracks.isEmpty) return;
await ref
.read(playerActionsProvider)
.playTracks(result.tracks, initialIndex: 0);
}
}