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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import '../../models/album.dart'; import '../../models/album.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/server_image.dart'; import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.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({ const AlbumCard({
required this.album, required this.album,
required this.onTap, required this.onTap,
@@ -32,7 +36,7 @@ class AlbumCard extends StatelessWidget {
final bool showArtist; final bool showArtist;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!; final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final coverSize = width - 16; final coverSize = width - 16;
return SizedBox( return SizedBox(
@@ -47,17 +51,29 @@ class AlbumCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ClipRRect( // Stack: cover image + overlaid play button at bottom-right.
borderRadius: BorderRadius.circular(6), Stack(
child: Container( children: [
width: coverSize, ClipRRect(
height: coverSize, borderRadius: BorderRadius.circular(6),
color: fs.slate, child: Container(
child: album.coverUrl.isEmpty width: coverSize,
? SvgPicture.asset('assets/svg/album-fallback.svg', height: coverSize,
fit: BoxFit.cover) color: fs.slate,
: ServerImage(url: album.coverUrl, fit: BoxFit.cover), 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), const SizedBox(height: 8),
Text( 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);
}
} }
@@ -1,17 +1,23 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import '../../models/artist.dart'; import '../../models/artist.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/server_image.dart'; import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart'; import '../../theme/theme_extension.dart';
import '../library_providers.dart';
import 'play_circle_button.dart';
class ArtistCard extends StatelessWidget { class ArtistCard extends ConsumerWidget {
const ArtistCard({required this.artist, required this.onTap, super.key}); const ArtistCard({required this.artist, required this.onTap, super.key});
final ArtistRef artist; final ArtistRef artist;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!; final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SizedBox( return SizedBox(
width: 140, width: 140,
@@ -22,16 +28,31 @@ class ArtistCard extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipOval( // Stack: circular avatar + overlaid play button at bottom-right.
child: Container( // The avatar is a circle (ClipOval) — bottom-right of its
width: 124, // bounding box still places the button inside the visible area
height: 124, // because the button is small relative to the radius.
color: fs.slate, Stack(
child: artist.coverUrl.isEmpty children: [
? SvgPicture.asset('assets/svg/album-fallback.svg', ClipOval(
fit: BoxFit.cover) child: Container(
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover), width: 124,
), height: 124,
color: fs.slate,
child: artist.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg',
fit: BoxFit.cover)
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
),
),
Positioned(
bottom: 4,
right: 4,
child: PlayCircleButton(
onPressed: () => _playArtistShuffle(ref),
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -46,4 +67,18 @@ class ArtistCard extends StatelessWidget {
), ),
); );
} }
/// Fetches the artist's tracks via /api/artists/{id}/tracks, shuffles
/// them (Fisher-Yates, default Random), and plays from index 0.
/// Matches the web ArtistCard's `playQueue(shuffle(tracks), 0)`.
Future<void> _playArtistShuffle(WidgetRef ref) async {
final api = await ref.read(libraryApiProvider.future);
final tracks = await api.getArtistTracks(artist.id);
if (tracks.isEmpty) return;
final shuffled = List.of(tracks);
shuffled.shuffle(Random());
await ref
.read(playerActionsProvider)
.playTracks(shuffled, initialIndex: 0);
}
} }
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Always-visible 44dp circular play button overlaid on home-screen
/// card art (AlbumCard / ArtistCard / PlaylistCard). Mirrors the
/// hover-revealed `.play-overlay` on the web cards, but always shown
/// because hover is not a real interaction on touch.
///
/// Manages its own loading state via [_starting] so the caller's tap
/// handler can be `Future<void> Function()` without worrying about
/// re-entrancy. Disabled state suppresses the tap (used for empty
/// playlists).
class PlayCircleButton extends StatefulWidget {
const PlayCircleButton({
required this.onPressed,
this.enabled = true,
this.size = 44,
super.key,
});
/// Tap handler. Returns a Future so the button can show a spinner
/// during the play setup (fetch detail tracks, etc.).
final Future<void> Function() onPressed;
/// When false, the button is rendered semi-transparent and taps are
/// ignored. Empty playlists / artists with no tracks should disable.
final bool enabled;
/// Outer diameter in logical pixels. 44 is the iOS / Android
/// touch-target minimum; matches the design doc.
final double size;
@override
State<PlayCircleButton> createState() => _PlayCircleButtonState();
}
class _PlayCircleButtonState extends State<PlayCircleButton> {
bool _starting = false;
Future<void> _handleTap() async {
if (_starting || !widget.enabled) return;
setState(() => _starting = true);
try {
await widget.onPressed();
} finally {
if (mounted) setState(() => _starting = false);
}
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final iconSize = widget.size * 0.5;
return Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkResponse(
onTap: widget.enabled ? _handleTap : null,
radius: widget.size / 2,
containedInkWell: true,
customBorder: const CircleBorder(),
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: fs.accent.withValues(alpha: widget.enabled ? 1.0 : 0.5),
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 6,
offset: Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: _starting
? SizedBox(
width: iconSize,
height: iconSize,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(fs.parchment),
),
)
: Icon(
Icons.play_arrow,
color: fs.parchment,
size: iconSize,
),
),
),
);
}
}
@@ -1,21 +1,29 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../library/widgets/play_circle_button.dart';
import '../../models/playlist.dart'; import '../../models/playlist.dart';
import '../../models/track.dart';
import '../../player/player_provider.dart';
import '../../shared/widgets/server_image.dart'; import '../../shared/widgets/server_image.dart';
import '../../theme/theme_extension.dart'; import '../../theme/theme_extension.dart';
import '../playlists_provider.dart';
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp), /// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
/// name + optional system-variant badge below. Tap pushes /// name + optional system-variant badge below. Tap pushes
/// `/playlists/{id}`. /// `/playlists/{id}`. The bottom-right play button fetches the
class PlaylistCard extends StatelessWidget { /// playlist and starts playback from track 0; disabled when the
/// playlist has no tracks.
class PlaylistCard extends ConsumerWidget {
const PlaylistCard({super.key, required this.playlist}); const PlaylistCard({super.key, required this.playlist});
final Playlist playlist; final Playlist playlist;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!; final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final hasTracks = playlist.trackCount > 0;
return SizedBox( return SizedBox(
width: 176, width: 176,
child: Material( child: Material(
@@ -26,26 +34,39 @@ class PlaylistCard extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect( // Stack: collage + overlaid play button at bottom-right.
borderRadius: BorderRadius.circular(6), Stack(
child: Container( children: [
width: 144, ClipRRect(
height: 144, borderRadius: BorderRadius.circular(6),
color: fs.slate, child: Container(
// coverUrl is deterministic per playlist (see width: 144,
// CachedPlaylistAdapter.toRef). When the server's height: 144,
// collage isn't built yet, ServerImage's color: fs.slate,
// errorBuilder shows the queue_music icon over the // coverUrl is deterministic per playlist (see
// slate background. // CachedPlaylistAdapter.toRef). When the server's
child: playlist.coverUrl.isEmpty // collage isn't built yet, ServerImage's
? Icon(Icons.queue_music, color: fs.ash, size: 56) // errorBuilder shows the queue_music icon over the
: ServerImage( // slate background.
url: playlist.coverUrl, child: playlist.coverUrl.isEmpty
fit: BoxFit.cover, ? Icon(Icons.queue_music, color: fs.ash, size: 56)
fallback: : ServerImage(
Icon(Icons.queue_music, color: fs.ash, size: 56), url: playlist.coverUrl,
), fit: BoxFit.cover,
), fallback:
Icon(Icons.queue_music, color: fs.ash, size: 56),
),
),
),
Positioned(
bottom: 6,
right: 6,
child: PlayCircleButton(
enabled: hasTracks,
onPressed: () => _playPlaylist(ref),
),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -76,4 +97,29 @@ class PlaylistCard extends StatelessWidget {
), ),
); );
} }
/// Fetches the playlist via /api/playlists/{id}, materializes each
/// PlaylistTrack into a TrackRef (filtering out unavailable rows
/// whose `trackId` is null after a track-delete), and plays from
/// index 0. Mirrors the web PlaylistCard's onPlayClick.
Future<void> _playPlaylist(WidgetRef ref) async {
final api = await ref.read(playlistsApiProvider.future);
final detail = await api.get(playlist.id);
final refs = <TrackRef>[];
for (final t in detail.tracks) {
if (t.trackId == null) continue;
refs.add(TrackRef(
id: t.trackId!,
title: t.title,
albumId: t.albumId ?? '',
albumTitle: t.albumTitle,
artistId: t.artistId ?? '',
artistName: t.artistName,
durationSec: t.durationSec,
streamUrl: t.streamUrl,
));
}
if (refs.isEmpty) return;
await ref.read(playerActionsProvider).playTracks(refs, initialIndex: 0);
}
} }