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/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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user