import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/app_icon.dart'; import '../../../core/widgets/pressable.dart'; import '../../../core/widgets/surface_card.dart'; import '../../../data/models/media_playback.dart'; import '../../../data/models/system_status.dart'; import 'marquee_text.dart'; /// The primary media control: a play/pause ring, a slider and step/mute /// actions. The ring remains a volume gauge; its centre is the playback button. class VolumeCard extends StatefulWidget { const VolumeCard({ super.key, required this.volume, required this.onSetVolume, required this.onToggleMute, required this.isMusicPlaying, required this.onPlayPause, required this.media, this.compact = false, }); final VolumeInfo volume; final ValueChanged onSetVolume; final VoidCallback onToggleMute; final bool isMusicPlaying; final VoidCallback onPlayPause; final MediaPlayback media; final bool compact; @override State createState() => _VolumeCardState(); } class _VolumeCardState extends State { double? _dragValue; int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max; int get _value => widget.volume.index.clamp(0, _max); bool get _muted => _value == 0; int get _percent => ((_value / _max) * 100).round(); String get _nowPlayingLabel { return widget.media.displayTitle ?? (widget.isMusicPlaying ? 'Playing' : 'Nothing playing'); } @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final compact = widget.compact; final displayValue = (_dragValue ?? _value.toDouble()) .clamp(0, _max.toDouble()) .toDouble(); return SurfaceCard( padding: EdgeInsets.all(compact ? 20 : 26), radius: 30, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Pressable( onTap: widget.onPlayPause, semanticLabel: widget.isMusicPlaying ? 'Pause music' : 'Play music', pressedScale: 0.94, child: _VolumeRing( progress: widget.media.progress, isPlaying: widget.isMusicPlaying, size: compact ? 78 : 92, ), ), SizedBox(width: compact ? 18 : 24), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Media volume', style: theme.textTheme.labelLarge?.copyWith( color: colors.inkMuted, fontSize: compact ? 12 : 13, ), ), const Spacer(), Text( '$_percent%', style: theme.textTheme.headlineMedium?.copyWith( fontSize: compact ? 24 : 28, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), const SizedBox(height: 2), SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: compact ? 5 : 6, thumbShape: RoundSliderThumbShape( enabledThumbRadius: compact ? 9 : 10, ), ), child: Slider( value: displayValue, min: 0, max: _max.toDouble(), divisions: _max <= 30 ? _max : null, onChanged: (next) { setState(() => _dragValue = next); widget.onSetVolume(next.round()); }, onChangeEnd: (_) => setState(() => _dragValue = null), semanticFormatterCallback: (value) => '${(value / _max * 100).round()} percent', ), ), SizedBox(height: compact ? 4 : 8), Row( children: [ _StepButton( icon: Icons.remove_rounded, semanticLabel: 'Volume down', onTap: () => widget.onSetVolume(_value - 1), compact: compact, ), SizedBox(width: compact ? 8 : 10), Expanded( child: Pressable( onTap: widget.onToggleMute, semanticLabel: _muted ? 'Unmute' : 'Mute', pressedScale: 0.97, child: Container( height: compact ? 36 : 40, decoration: BoxDecoration( color: _muted ? colors.accentSoft : colors.surfaceStrong, borderRadius: BorderRadius.circular(999), border: Border.all( color: _muted ? colors.accent.withValues(alpha: 0.25) : colors.border, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AppIcon( _muted ? 'assets/icons/volume-x.svg' : 'assets/icons/volume-2.svg', size: compact ? 16 : 18, color: _muted ? colors.accent : colors.inkMuted, ), const SizedBox(width: 8), Text( _muted ? 'Unmute' : 'Mute', style: theme.textTheme.labelMedium ?.copyWith( color: _muted ? colors.accent : colors.inkMuted, ), ), ], ), ), ), ), SizedBox(width: compact ? 8 : 10), _StepButton( icon: Icons.add_rounded, semanticLabel: 'Volume up', onTap: () => widget.onSetVolume(_value + 1), compact: compact, ), ], ), ], ), ), ], ), SizedBox(height: compact ? 8 : 10), SizedBox( height: compact ? 18 : 20, child: Center( child: MarqueeText( text: _nowPlayingLabel, animate: widget.isMusicPlaying && widget.media.displayTitle != null, style: theme.textTheme.bodySmall?.copyWith( color: widget.media.displayTitle == null ? colors.inkMuted : colors.ink, fontSize: compact ? 11 : 12, ), ), ), ), ], ), ); } } class _StepButton extends StatelessWidget { const _StepButton({ required this.icon, required this.semanticLabel, required this.onTap, required this.compact, }); final IconData icon; final String semanticLabel; final VoidCallback onTap; final bool compact; @override Widget build(BuildContext context) { final colors = context.colors; return Pressable( onTap: onTap, semanticLabel: semanticLabel, pressedScale: 0.92, child: Container( width: compact ? 36 : 40, height: compact ? 36 : 40, decoration: BoxDecoration( color: colors.surfaceStrong, shape: BoxShape.circle, border: Border.all(color: colors.border), ), child: Icon(icon, size: compact ? 18 : 20, color: colors.inkMuted), ), ); } } class _VolumeRing extends StatelessWidget { const _VolumeRing({ required this.progress, required this.isPlaying, required this.size, }); final double progress; final bool isPlaying; final double size; @override Widget build(BuildContext context) { final colors = context.colors; return TweenAnimationBuilder( tween: Tween(end: progress.clamp(0.0, 1.0)), duration: Duration(milliseconds: isPlaying ? 850 : 250), curve: Curves.easeOutCubic, builder: (context, value, _) { return SizedBox( width: size, height: size, child: Stack( alignment: Alignment.center, children: [ CustomPaint( size: Size.square(size), painter: _RingPainter( value: value, trackColor: colors.border, activeColor: colors.accent, ), ), Container( width: size * 0.62, height: size * 0.62, decoration: BoxDecoration( color: colors.accentSoft, shape: BoxShape.circle, ), alignment: Alignment.center, child: Icon( isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, size: size * 0.34, color: colors.accent, ), ), ], ), ); }, ); } } class _RingPainter extends CustomPainter { _RingPainter({ required this.value, required this.trackColor, required this.activeColor, }); final double value; final Color trackColor; final Color activeColor; @override void paint(Canvas canvas, Size size) { final stroke = size.width * 0.055; final rect = Offset.zero & size; final center = rect.center; final radius = (size.width - stroke) / 2; final track = Paint() ..color = trackColor ..style = PaintingStyle.stroke ..strokeWidth = stroke ..strokeCap = StrokeCap.round; final active = Paint() ..color = activeColor ..style = PaintingStyle.stroke ..strokeWidth = stroke ..strokeCap = StrokeCap.round; canvas.drawCircle(center, radius, track); if (value <= 0) return; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), -math.pi / 2, 2 * math.pi * value, false, active, ); } @override bool shouldRepaint(covariant _RingPainter oldDelegate) { return oldDelegate.value != value || oldDelegate.trackColor != trackColor || oldDelegate.activeColor != activeColor; } }