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,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