Accessibility: - Add Semantics to player control buttons (previous, play/pause, next) - Include semantic labels and hints for screen readers - Play/pause button includes "long press to stop" hint Touch Targets: - Increase 28dp touch targets to 44dp in player_card.dart - Increase 28dp touch targets to 44dp in expandable_player.dart - Comments note icons remain 28dp for visual balance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
193 lines
5.6 KiB
Dart
193 lines
5.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../animated_icon_button.dart';
|
|
import '../../theme/design_tokens.dart';
|
|
|
|
/// Playback control buttons (shuffle, previous, play/pause, next, repeat)
|
|
class PlayerControls extends StatelessWidget {
|
|
final bool isPlaying;
|
|
final bool? shuffle;
|
|
final String? repeatMode;
|
|
final Color textColor;
|
|
final Color primaryColor;
|
|
final Color backgroundColor;
|
|
final double skipButtonSize;
|
|
final double playButtonSize;
|
|
final double playButtonContainerSize;
|
|
final double progress; // Animation progress 0-1
|
|
final double expandedElementsOpacity;
|
|
final bool isLoadingQueue;
|
|
final VoidCallback onPrevious;
|
|
final VoidCallback onPlayPause;
|
|
final VoidCallback onNext;
|
|
final VoidCallback? onStop;
|
|
final VoidCallback? onToggleShuffle;
|
|
final VoidCallback? onCycleRepeat;
|
|
|
|
const PlayerControls({
|
|
super.key,
|
|
required this.isPlaying,
|
|
this.shuffle,
|
|
this.repeatMode,
|
|
required this.textColor,
|
|
required this.primaryColor,
|
|
required this.backgroundColor,
|
|
required this.skipButtonSize,
|
|
required this.playButtonSize,
|
|
required this.playButtonContainerSize,
|
|
required this.progress,
|
|
required this.expandedElementsOpacity,
|
|
required this.isLoadingQueue,
|
|
required this.onPrevious,
|
|
required this.onPlayPause,
|
|
required this.onNext,
|
|
this.onStop,
|
|
this.onToggleShuffle,
|
|
this.onCycleRepeat,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = progress;
|
|
final isExpanded = t > 0.5;
|
|
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
|
|
children: [
|
|
// Shuffle (expanded only)
|
|
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
|
|
if (isExpanded && expandedElementsOpacity > 0.1)
|
|
_buildSecondaryButton(
|
|
icon: Icons.shuffle_rounded,
|
|
// Fix: compute final opacity once to avoid multiplicative opacity bug
|
|
color: shuffle == true
|
|
? primaryColor.withOpacity(expandedElementsOpacity)
|
|
: textColor.withOpacity(0.5 * expandedElementsOpacity),
|
|
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
|
),
|
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
|
|
|
// Previous
|
|
Semantics(
|
|
button: true,
|
|
label: 'Previous track',
|
|
child: _buildControlButton(
|
|
icon: Icons.skip_previous_rounded,
|
|
color: textColor,
|
|
size: skipButtonSize,
|
|
onPressed: onPrevious,
|
|
useAnimation: isExpanded,
|
|
),
|
|
),
|
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
|
|
|
// Play/Pause
|
|
_buildPlayButton(),
|
|
SizedBox(width: _lerpDouble(0, 20, t)),
|
|
|
|
// Next
|
|
Semantics(
|
|
button: true,
|
|
label: 'Next track',
|
|
child: _buildControlButton(
|
|
icon: Icons.skip_next_rounded,
|
|
color: textColor,
|
|
size: skipButtonSize,
|
|
onPressed: onNext,
|
|
useAnimation: isExpanded,
|
|
),
|
|
),
|
|
|
|
// Repeat (expanded only)
|
|
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
|
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
|
if (isExpanded && expandedElementsOpacity > 0.1)
|
|
_buildSecondaryButton(
|
|
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
|
// Fix: compute final opacity once to avoid multiplicative opacity bug
|
|
color: (repeatMode != null && repeatMode != 'off')
|
|
? primaryColor.withOpacity(expandedElementsOpacity)
|
|
: textColor.withOpacity(0.5 * expandedElementsOpacity),
|
|
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildControlButton({
|
|
required IconData icon,
|
|
required Color color,
|
|
required double size,
|
|
required VoidCallback onPressed,
|
|
required bool useAnimation,
|
|
}) {
|
|
if (useAnimation) {
|
|
return AnimatedIconButton(
|
|
icon: icon,
|
|
color: color,
|
|
iconSize: size,
|
|
onPressed: onPressed,
|
|
);
|
|
}
|
|
return IconButton(
|
|
icon: Icon(icon),
|
|
color: color,
|
|
iconSize: size,
|
|
onPressed: onPressed,
|
|
padding: Spacing.paddingAll4,
|
|
constraints: const BoxConstraints(),
|
|
);
|
|
}
|
|
|
|
Widget _buildSecondaryButton({
|
|
required IconData icon,
|
|
required Color color,
|
|
VoidCallback? onPressed,
|
|
}) {
|
|
return SizedBox(
|
|
width: 44,
|
|
height: 44,
|
|
child: IconButton(
|
|
icon: Icon(icon),
|
|
color: color,
|
|
iconSize: 22,
|
|
onPressed: onPressed,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlayButton() {
|
|
final bgColor = Color.lerp(Colors.transparent, primaryColor, progress);
|
|
final iconColor = Color.lerp(textColor, backgroundColor, progress);
|
|
|
|
return Semantics(
|
|
button: true,
|
|
label: isPlaying ? 'Pause' : 'Play',
|
|
hint: 'Long press to stop',
|
|
child: GestureDetector(
|
|
onLongPress: onStop,
|
|
child: Container(
|
|
width: playButtonContainerSize,
|
|
height: playButtonContainerSize,
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded),
|
|
color: iconColor,
|
|
iconSize: playButtonSize,
|
|
onPressed: onPlayPause,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
double _lerpDouble(double a, double b, double t) {
|
|
return a + (b - a) * t;
|
|
}
|
|
}
|