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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.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 'play_circle_button.dart';
|
||||
|
||||
class ArtistCard extends StatelessWidget {
|
||||
class ArtistCard extends ConsumerWidget {
|
||||
const ArtistCard({required this.artist, required this.onTap, super.key});
|
||||
final ArtistRef artist;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return SizedBox(
|
||||
width: 140,
|
||||
@@ -22,16 +28,31 @@ class ArtistCard extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
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),
|
||||
),
|
||||
// Stack: circular avatar + overlaid play button at bottom-right.
|
||||
// The avatar is a circle (ClipOval) — bottom-right of its
|
||||
// bounding box still places the button inside the visible area
|
||||
// because the button is small relative to the radius.
|
||||
Stack(
|
||||
children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
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),
|
||||
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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../library/widgets/play_circle_button.dart';
|
||||
import '../../models/playlist.dart';
|
||||
import '../../models/track.dart';
|
||||
import '../../player/player_provider.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import '../playlists_provider.dart';
|
||||
|
||||
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
|
||||
/// name + optional system-variant badge below. Tap pushes
|
||||
/// `/playlists/{id}`.
|
||||
class PlaylistCard extends StatelessWidget {
|
||||
/// `/playlists/{id}`. The bottom-right play button fetches the
|
||||
/// 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});
|
||||
|
||||
final Playlist playlist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final hasTracks = playlist.trackCount > 0;
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: Material(
|
||||
@@ -26,26 +34,39 @@ class PlaylistCard extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 144,
|
||||
height: 144,
|
||||
color: fs.slate,
|
||||
// coverUrl is deterministic per playlist (see
|
||||
// CachedPlaylistAdapter.toRef). When the server's
|
||||
// collage isn't built yet, ServerImage's
|
||||
// errorBuilder shows the queue_music icon over the
|
||||
// slate background.
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback:
|
||||
Icon(Icons.queue_music, color: fs.ash, size: 56),
|
||||
),
|
||||
),
|
||||
// Stack: collage + overlaid play button at bottom-right.
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 144,
|
||||
height: 144,
|
||||
color: fs.slate,
|
||||
// coverUrl is deterministic per playlist (see
|
||||
// CachedPlaylistAdapter.toRef). When the server's
|
||||
// collage isn't built yet, ServerImage's
|
||||
// errorBuilder shows the queue_music icon over the
|
||||
// slate background.
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||
: ServerImage(
|
||||
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),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user