diff --git a/flutter_client/lib/library/widgets/album_card.dart b/flutter_client/lib/library/widgets/album_card.dart index 24698634..533b84a8 100644 --- a/flutter_client/lib/library/widgets/album_card.dart +++ b/flutter_client/lib/library/widgets/album_card.dart @@ -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()!; 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 _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); + } } diff --git a/flutter_client/lib/library/widgets/artist_card.dart b/flutter_client/lib/library/widgets/artist_card.dart index 218a30ba..3d6bd6b2 100644 --- a/flutter_client/lib/library/widgets/artist_card.dart +++ b/flutter_client/lib/library/widgets/artist_card.dart @@ -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()!; 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 _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); + } } diff --git a/flutter_client/lib/library/widgets/play_circle_button.dart b/flutter_client/lib/library/widgets/play_circle_button.dart new file mode 100644 index 00000000..6c76dac7 --- /dev/null +++ b/flutter_client/lib/library/widgets/play_circle_button.dart @@ -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 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 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 createState() => _PlayCircleButtonState(); +} + +class _PlayCircleButtonState extends State { + bool _starting = false; + + Future _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()!; + 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(fs.parchment), + ), + ) + : Icon( + Icons.play_arrow, + color: fs.parchment, + size: iconSize, + ), + ), + ), + ); + } +} diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart index 515bd55d..40aec905 100644 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ b/flutter_client/lib/playlists/widgets/playlist_card.dart @@ -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()!; + 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 _playPlaylist(WidgetRef ref) async { + final api = await ref.read(playlistsApiProvider.future); + final detail = await api.get(playlist.id); + final refs = []; + 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); + } }