volume_card.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import 'dart:math' as math;
  2. import 'package:flutter/material.dart';
  3. import '../../../core/theme/app_colors.dart';
  4. import '../../../core/widgets/app_icon.dart';
  5. import '../../../core/widgets/pressable.dart';
  6. import '../../../core/widgets/surface_card.dart';
  7. import '../../../data/models/media_playback.dart';
  8. import '../../../data/models/system_status.dart';
  9. import 'marquee_text.dart';
  10. /// The primary media control: a play/pause ring, a slider and step/mute
  11. /// actions. The ring remains a volume gauge; its centre is the playback button.
  12. class VolumeCard extends StatefulWidget {
  13. const VolumeCard({
  14. super.key,
  15. required this.volume,
  16. required this.onSetVolume,
  17. required this.onToggleMute,
  18. required this.isMusicPlaying,
  19. required this.onPlayPause,
  20. required this.media,
  21. this.compact = false,
  22. });
  23. final VolumeInfo volume;
  24. final ValueChanged<int> onSetVolume;
  25. final VoidCallback onToggleMute;
  26. final bool isMusicPlaying;
  27. final VoidCallback onPlayPause;
  28. final MediaPlayback media;
  29. final bool compact;
  30. @override
  31. State<VolumeCard> createState() => _VolumeCardState();
  32. }
  33. class _VolumeCardState extends State<VolumeCard> {
  34. double? _dragValue;
  35. int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max;
  36. int get _value => widget.volume.index.clamp(0, _max);
  37. bool get _muted => _value == 0;
  38. int get _percent => ((_value / _max) * 100).round();
  39. String get _nowPlayingLabel {
  40. return widget.media.displayTitle ??
  41. (widget.isMusicPlaying ? 'Playing' : 'Nothing playing');
  42. }
  43. @override
  44. Widget build(BuildContext context) {
  45. final colors = context.colors;
  46. final theme = Theme.of(context);
  47. final compact = widget.compact;
  48. final displayValue = (_dragValue ?? _value.toDouble())
  49. .clamp(0, _max.toDouble())
  50. .toDouble();
  51. return SurfaceCard(
  52. padding: EdgeInsets.all(compact ? 20 : 26),
  53. radius: 30,
  54. child: Row(
  55. crossAxisAlignment: CrossAxisAlignment.center,
  56. children: [
  57. Pressable(
  58. onTap: widget.onPlayPause,
  59. semanticLabel: widget.isMusicPlaying ? 'Pause music' : 'Play music',
  60. pressedScale: 0.94,
  61. child: _VolumeRing(
  62. progress: widget.media.progress,
  63. isPlaying: widget.isMusicPlaying,
  64. size: compact ? 78 : 92,
  65. ),
  66. ),
  67. SizedBox(width: compact ? 18 : 24),
  68. Expanded(
  69. child: Column(
  70. mainAxisSize: MainAxisSize.min,
  71. crossAxisAlignment: CrossAxisAlignment.start,
  72. children: [
  73. Row(
  74. children: [
  75. Text(
  76. 'Media volume',
  77. style: theme.textTheme.labelLarge?.copyWith(
  78. color: colors.inkMuted,
  79. fontSize: compact ? 12 : 13,
  80. ),
  81. ),
  82. const Spacer(),
  83. Text(
  84. '$_percent%',
  85. style: theme.textTheme.headlineMedium?.copyWith(
  86. fontSize: compact ? 24 : 28,
  87. fontFeatures: const [FontFeature.tabularFigures()],
  88. ),
  89. ),
  90. ],
  91. ),
  92. const SizedBox(height: 2),
  93. SliderTheme(
  94. data: SliderTheme.of(context).copyWith(
  95. trackHeight: compact ? 5 : 6,
  96. thumbShape: RoundSliderThumbShape(
  97. enabledThumbRadius: compact ? 9 : 10,
  98. ),
  99. ),
  100. child: Slider(
  101. value: displayValue,
  102. min: 0,
  103. max: _max.toDouble(),
  104. divisions: _max <= 30 ? _max : null,
  105. onChanged: (next) {
  106. setState(() => _dragValue = next);
  107. widget.onSetVolume(next.round());
  108. },
  109. onChangeEnd: (_) => setState(() => _dragValue = null),
  110. semanticFormatterCallback: (value) =>
  111. '${(value / _max * 100).round()} percent',
  112. ),
  113. ),
  114. SizedBox(height: compact ? 4 : 8),
  115. Row(
  116. children: [
  117. _StepButton(
  118. icon: Icons.remove_rounded,
  119. semanticLabel: 'Volume down',
  120. onTap: () => widget.onSetVolume(_value - 1),
  121. compact: compact,
  122. ),
  123. SizedBox(width: compact ? 8 : 10),
  124. Expanded(
  125. child: Pressable(
  126. onTap: widget.onToggleMute,
  127. semanticLabel: _muted ? 'Unmute' : 'Mute',
  128. pressedScale: 0.97,
  129. child: Container(
  130. height: compact ? 36 : 40,
  131. decoration: BoxDecoration(
  132. color: _muted
  133. ? colors.accentSoft
  134. : colors.surfaceStrong,
  135. borderRadius: BorderRadius.circular(999),
  136. border: Border.all(
  137. color: _muted
  138. ? colors.accent.withValues(alpha: 0.25)
  139. : colors.border,
  140. ),
  141. ),
  142. child: Row(
  143. mainAxisAlignment: MainAxisAlignment.center,
  144. children: [
  145. AppIcon(
  146. _muted
  147. ? 'assets/icons/volume-x.svg'
  148. : 'assets/icons/volume-2.svg',
  149. size: compact ? 16 : 18,
  150. color: _muted ? colors.accent : colors.inkMuted,
  151. ),
  152. const SizedBox(width: 8),
  153. Text(
  154. _muted ? 'Unmute' : 'Mute',
  155. style: theme.textTheme.labelMedium?.copyWith(
  156. color: _muted
  157. ? colors.accent
  158. : colors.inkMuted,
  159. ),
  160. ),
  161. ],
  162. ),
  163. ),
  164. ),
  165. ),
  166. SizedBox(width: compact ? 8 : 10),
  167. _StepButton(
  168. icon: Icons.add_rounded,
  169. semanticLabel: 'Volume up',
  170. onTap: () => widget.onSetVolume(_value + 1),
  171. compact: compact,
  172. ),
  173. ],
  174. ),
  175. SizedBox(height: compact ? 8 : 10),
  176. SizedBox(
  177. height: compact ? 18 : 20,
  178. child: Row(
  179. children: [
  180. Icon(
  181. Icons.music_note_rounded,
  182. size: compact ? 14 : 15,
  183. color: colors.inkMuted,
  184. ),
  185. const SizedBox(width: 6),
  186. Expanded(
  187. child: MarqueeText(
  188. text: _nowPlayingLabel,
  189. animate:
  190. widget.isMusicPlaying &&
  191. widget.media.displayTitle != null,
  192. style: theme.textTheme.bodySmall?.copyWith(
  193. color: widget.media.displayTitle == null
  194. ? colors.inkMuted
  195. : colors.ink,
  196. fontSize: compact ? 11 : 12,
  197. ),
  198. ),
  199. ),
  200. ],
  201. ),
  202. ),
  203. ],
  204. ),
  205. ),
  206. ],
  207. ),
  208. );
  209. }
  210. }
  211. class _StepButton extends StatelessWidget {
  212. const _StepButton({
  213. required this.icon,
  214. required this.semanticLabel,
  215. required this.onTap,
  216. required this.compact,
  217. });
  218. final IconData icon;
  219. final String semanticLabel;
  220. final VoidCallback onTap;
  221. final bool compact;
  222. @override
  223. Widget build(BuildContext context) {
  224. final colors = context.colors;
  225. return Pressable(
  226. onTap: onTap,
  227. semanticLabel: semanticLabel,
  228. pressedScale: 0.92,
  229. child: Container(
  230. width: compact ? 36 : 40,
  231. height: compact ? 36 : 40,
  232. decoration: BoxDecoration(
  233. color: colors.surfaceStrong,
  234. shape: BoxShape.circle,
  235. border: Border.all(color: colors.border),
  236. ),
  237. child: Icon(icon, size: compact ? 18 : 20, color: colors.inkMuted),
  238. ),
  239. );
  240. }
  241. }
  242. class _VolumeRing extends StatelessWidget {
  243. const _VolumeRing({
  244. required this.progress,
  245. required this.isPlaying,
  246. required this.size,
  247. });
  248. final double progress;
  249. final bool isPlaying;
  250. final double size;
  251. @override
  252. Widget build(BuildContext context) {
  253. final colors = context.colors;
  254. return TweenAnimationBuilder<double>(
  255. tween: Tween(end: progress.clamp(0.0, 1.0)),
  256. duration: Duration(milliseconds: isPlaying ? 850 : 250),
  257. curve: Curves.easeOutCubic,
  258. builder: (context, value, _) {
  259. return SizedBox(
  260. width: size,
  261. height: size,
  262. child: Stack(
  263. alignment: Alignment.center,
  264. children: [
  265. CustomPaint(
  266. size: Size.square(size),
  267. painter: _RingPainter(
  268. value: value,
  269. trackColor: colors.border,
  270. activeColor: colors.accent,
  271. ),
  272. ),
  273. Container(
  274. width: size * 0.62,
  275. height: size * 0.62,
  276. decoration: BoxDecoration(
  277. color: colors.accentSoft,
  278. shape: BoxShape.circle,
  279. ),
  280. alignment: Alignment.center,
  281. child: Icon(
  282. isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
  283. size: size * 0.34,
  284. color: colors.accent,
  285. ),
  286. ),
  287. ],
  288. ),
  289. );
  290. },
  291. );
  292. }
  293. }
  294. class _RingPainter extends CustomPainter {
  295. _RingPainter({
  296. required this.value,
  297. required this.trackColor,
  298. required this.activeColor,
  299. });
  300. final double value;
  301. final Color trackColor;
  302. final Color activeColor;
  303. @override
  304. void paint(Canvas canvas, Size size) {
  305. final stroke = size.width * 0.055;
  306. final rect = Offset.zero & size;
  307. final center = rect.center;
  308. final radius = (size.width - stroke) / 2;
  309. final track = Paint()
  310. ..color = trackColor
  311. ..style = PaintingStyle.stroke
  312. ..strokeWidth = stroke
  313. ..strokeCap = StrokeCap.round;
  314. final active = Paint()
  315. ..color = activeColor
  316. ..style = PaintingStyle.stroke
  317. ..strokeWidth = stroke
  318. ..strokeCap = StrokeCap.round;
  319. canvas.drawCircle(center, radius, track);
  320. if (value <= 0) return;
  321. canvas.drawArc(
  322. Rect.fromCircle(center: center, radius: radius),
  323. -math.pi / 2,
  324. 2 * math.pi * value,
  325. false,
  326. active,
  327. );
  328. }
  329. @override
  330. bool shouldRepaint(covariant _RingPainter oldDelegate) {
  331. return oldDelegate.value != value ||
  332. oldDelegate.trackColor != trackColor ||
  333. oldDelegate.activeColor != activeColor;
  334. }
  335. }