Files
Ensemble/lib/widgets/player/player_controls.dart
Your NameandClaude Opus 4.5 a2788d6815 Improve accessibility and touch targets
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>
2026-01-01 22:58:12 +00:00

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;
}
}