872b0de304
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>
97 lines
3.0 KiB
Dart
97 lines
3.0 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|