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: Column(
  55. mainAxisSize: MainAxisSize.min,
  56. crossAxisAlignment: CrossAxisAlignment.stretch,
  57. children: [
  58. Row(
  59. crossAxisAlignment: CrossAxisAlignment.center,
  60. children: [
  61. Pressable(
  62. onTap: widget.onPlayPause,
  63. semanticLabel: widget.isMusicPlaying
  64. ? 'Pause music'
  65. : 'Play music',
  66. pressedScale: 0.94,
  67. child: _VolumeRing(
  68. progress: widget.media.progress,
  69. isPlaying: widget.isMusicPlaying,
  70. size: compact ? 78 : 92,
  71. ),
  72. ),
  73. SizedBox(width: compact ? 18 : 24),
  74. Expanded(
  75. child: Column(
  76. mainAxisSize: MainAxisSize.min,
  77. crossAxisAlignment: CrossAxisAlignment.start,
  78. children: [
  79. Row(
  80. children: [
  81. Text(
  82. 'Media volume',
  83. style: theme.textTheme.labelLarge?.copyWith(
  84. color: colors.inkMuted,
  85. fontSize: compact ? 12 : 13,
  86. ),
  87. ),
  88. const Spacer(),
  89. Text(
  90. '$_percent%',
  91. style: theme.textTheme.headlineMedium?.copyWith(
  92. fontSize: compact ? 24 : 28,
  93. fontFeatures: const [FontFeature.tabularFigures()],
  94. ),
  95. ),
  96. ],
  97. ),
  98. const SizedBox(height: 2),
  99. SliderTheme(
  100. data: SliderTheme.of(context).copyWith(
  101. trackHeight: compact ? 5 : 6,
  102. thumbShape: RoundSliderThumbShape(
  103. enabledThumbRadius: compact ? 9 : 10,
  104. ),
  105. ),
  106. child: Slider(
  107. value: displayValue,
  108. min: 0,
  109. max: _max.toDouble(),
  110. divisions: _max <= 30 ? _max : null,
  111. onChanged: (next) {
  112. setState(() => _dragValue = next);
  113. widget.onSetVolume(next.round());
  114. },
  115. onChangeEnd: (_) => setState(() => _dragValue = null),
  116. semanticFormatterCallback: (value) =>
  117. '${(value / _max * 100).round()} percent',
  118. ),
  119. ),
  120. SizedBox(height: compact ? 4 : 8),
  121. Row(
  122. children: [
  123. _StepButton(
  124. icon: Icons.remove_rounded,
  125. semanticLabel: 'Volume down',
  126. onTap: () => widget.onSetVolume(_value - 1),
  127. compact: compact,
  128. ),
  129. SizedBox(width: compact ? 8 : 10),
  130. Expanded(
  131. child: Pressable(
  132. onTap: widget.onToggleMute,
  133. semanticLabel: _muted ? 'Unmute' : 'Mute',
  134. pressedScale: 0.97,
  135. child: Container(
  136. height: compact ? 36 : 40,
  137. decoration: BoxDecoration(
  138. color: _muted
  139. ? colors.accentSoft
  140. : colors.surfaceStrong,
  141. borderRadius: BorderRadius.circular(999),
  142. border: Border.all(
  143. color: _muted
  144. ? colors.accent.withValues(alpha: 0.25)
  145. : colors.border,
  146. ),
  147. ),
  148. child: Row(
  149. mainAxisAlignment: MainAxisAlignment.center,
  150. children: [
  151. AppIcon(
  152. _muted
  153. ? 'assets/icons/volume-x.svg'
  154. : 'assets/icons/volume-2.svg',
  155. size: compact ? 16 : 18,
  156. color: _muted
  157. ? colors.accent
  158. : colors.inkMuted,
  159. ),
  160. const SizedBox(width: 8),
  161. Text(
  162. _muted ? 'Unmute' : 'Mute',
  163. style: theme.textTheme.labelMedium
  164. ?.copyWith(
  165. color: _muted
  166. ? colors.accent
  167. : colors.inkMuted,
  168. ),
  169. ),
  170. ],
  171. ),
  172. ),
  173. ),
  174. ),
  175. SizedBox(width: compact ? 8 : 10),
  176. _StepButton(
  177. icon: Icons.add_rounded,
  178. semanticLabel: 'Volume up',
  179. onTap: () => widget.onSetVolume(_value + 1),
  180. compact: compact,
  181. ),
  182. ],
  183. ),
  184. ],
  185. ),
  186. ),
  187. ],
  188. ),
  189. SizedBox(height: compact ? 8 : 10),
  190. SizedBox(
  191. height: compact ? 18 : 20,
  192. child: Center(
  193. child: MarqueeText(
  194. text: _nowPlayingLabel,
  195. animate:
  196. widget.isMusicPlaying && widget.media.displayTitle != null,
  197. style: theme.textTheme.bodySmall?.copyWith(
  198. color: widget.media.displayTitle == null
  199. ? colors.inkMuted
  200. : colors.ink,
  201. fontSize: compact ? 11 : 12,
  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. }