From 2111afa46820397b4229897423112faf336cda3c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:16:17 +0700 Subject: [PATCH] feat(player): share volume controls across Material and Mornye --- .../mornye_artwork_performance_test.dart | 2 +- .../settings/playback_settings_page.dart | 4 +- lib/widgets/app_slider.dart | 6 + lib/widgets/material_player_layout.dart | 15 +- lib/widgets/mornye_player_layout.dart | 6 +- lib/widgets/mornye_volume_control.dart | 162 ------------- lib/widgets/player_volume_control.dart | 228 ++++++++++++++++++ test/now_playing_lyrics_transition_test.dart | 30 +-- ...t.dart => player_volume_control_test.dart} | 71 +++++- 9 files changed, 336 insertions(+), 188 deletions(-) delete mode 100644 lib/widgets/mornye_volume_control.dart create mode 100644 lib/widgets/player_volume_control.dart rename test/{mornye_volume_control_test.dart => player_volume_control_test.dart} (52%) diff --git a/integration_test/mornye_artwork_performance_test.dart b/integration_test/mornye_artwork_performance_test.dart index 48f8e9ba..36b4acd9 100644 --- a/integration_test/mornye_artwork_performance_test.dart +++ b/integration_test/mornye_artwork_performance_test.dart @@ -17,7 +17,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/playback_seek_preview.dart'; import 'package:spotiflac_android/widgets/mornye_player_artwork.dart'; import 'package:spotiflac_android/widgets/mornye_player_layout.dart'; -import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/player_volume_control.dart'; import 'performance_probe.dart'; diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart index 872242c6..0d53b4a6 100644 --- a/lib/screens/settings/playback_settings_page.dart +++ b/lib/screens/settings/playback_settings_page.dart @@ -12,7 +12,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; -import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/player_volume_control.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/discord_presence_settings.dart'; import 'package:spotiflac_android/widgets/automix_settings.dart'; @@ -354,7 +354,7 @@ class _PlaybackSettingsPageState extends ConsumerState { : context.l10n.usbVolumeUnavailable, ), if (volume?.available == true) - MornyeVolumeControl( + PlayerVolumeControl( foreground: colorScheme.onSurface, ), ], diff --git a/lib/widgets/app_slider.dart b/lib/widgets/app_slider.dart index 601a5894..5e054ec5 100644 --- a/lib/widgets/app_slider.dart +++ b/lib/widgets/app_slider.dart @@ -15,6 +15,7 @@ class AppSlider extends StatelessWidget { this.divisions, this.label, this.semanticFormatterCallback, + this.trackThickness, }); final double value; @@ -26,6 +27,10 @@ class AppSlider extends StatelessWidget { final String? label; final SemanticFormatterCallback? semanticFormatterCallback; + /// Expressive track height; a secondary control can sit thinner than the + /// default extra-small track. + final double? trackThickness; + @override Widget build(BuildContext context) { if (!materialExpressiveEnabled(context)) { @@ -48,6 +53,7 @@ class AppSlider extends StatelessWidget { divisions: divisions, label: label, semanticFormatterCallback: semanticFormatterCallback, + trackThickness: trackThickness, enabled: onChanged != null, onChanged: onChanged, onChangeEnd: onChangeEnd, diff --git a/lib/widgets/material_player_layout.dart b/lib/widgets/material_player_layout.dart index 2ca5ecaa..9a96c4b3 100644 --- a/lib/widgets/material_player_layout.dart +++ b/lib/widgets/material_player_layout.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/player_gesture_regions.dart'; import 'package:spotiflac_android/widgets/player_metadata_title.dart'; import 'package:spotiflac_android/widgets/player_playback_controls.dart'; +import 'package:spotiflac_android/widgets/player_volume_control.dart'; class MaterialPlayerLayout extends StatelessWidget { const MaterialPlayerLayout({ @@ -166,7 +167,12 @@ class MaterialPlayerLayout extends StatelessWidget { onOpenQueue: onOpenQueue, child: Column( mainAxisSize: MainAxisSize.min, - children: _metadataAndControls(context), + // Short phones keep the room for artwork and lyrics; the + // hardware buttons still set the volume there. + children: _metadataAndControls( + context, + showVolume: constraints.maxHeight >= 600, + ), ), ), ], @@ -176,11 +182,12 @@ class MaterialPlayerLayout extends StatelessWidget { ); } - /// Title/artist, seek slider, and transport buttons — shared by the + /// Title/artist, seek slider, transport buttons and volume — shared by the /// portrait column and the landscape right pane. List _metadataAndControls( BuildContext context, { bool compact = false, + bool showVolume = true, }) { final fallback = AppMotionSpec( MediaQuery.disableAnimationsOf(context) @@ -269,6 +276,10 @@ class MaterialPlayerLayout extends StatelessWidget { metadata: metadata, compact: compact, ), + if (showVolume) ...[ + SizedBox(height: compact ? 8 : 12), + const PlayerVolumeControl(), + ], ]; } } diff --git a/lib/widgets/mornye_player_layout.dart b/lib/widgets/mornye_player_layout.dart index bfb94ad9..412bb338 100644 --- a/lib/widgets/mornye_player_layout.dart +++ b/lib/widgets/mornye_player_layout.dart @@ -12,7 +12,7 @@ import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; import 'package:spotiflac_android/widgets/mornye_landscape_player.dart'; import 'package:spotiflac_android/widgets/mornye_player_favorite_button.dart'; import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; -import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/player_volume_control.dart'; import 'package:spotiflac_android/widgets/overflow_marquee.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/player_gesture_regions.dart'; @@ -389,7 +389,7 @@ class MornyePlayerLayout extends ConsumerWidget { lyricsOptions: showLyrics ? lyricsOptions : null, queue: MornyePlayerQueue(colorScheme: colorScheme), controls: controls(), - volume: const MornyeVolumeControl(), + volume: const PlayerVolumeControl(), ); } @@ -441,7 +441,7 @@ class MornyePlayerLayout extends ConsumerWidget { SizedBox(height: volumeGap - transportShift), AnimatedBuilder( animation: artworkForeground, - builder: (context, _) => MornyeVolumeControl( + builder: (context, _) => PlayerVolumeControl( key: artworkKeys.volume, foreground: foreground('volume').onSurface, ), diff --git a/lib/widgets/mornye_volume_control.dart b/lib/widgets/mornye_volume_control.dart deleted file mode 100644 index 505e13c0..00000000 --- a/lib/widgets/mornye_volume_control.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; -import 'package:spotiflac_android/utils/logger.dart'; -import 'package:volume_controller/volume_controller.dart'; -import 'package:spotiflac_android/services/music_playback_deck.dart'; -import 'package:spotiflac_android/services/system_volume_service.dart'; - -final usbVolumeProvider = StreamProvider.autoDispose((ref) { - final events = StreamController(); - void changed() => events.add(usbHardwareVolume.value); - usbHardwareVolume.addListener(changed); - changed(); - ref.onDispose(() { - usbHardwareVolume.removeListener(changed); - unawaited(events.close()); - }); - return events.stream; -}); - -/// A single shared subscription for the visible player, including hardware -/// volume-button changes. This controls system volume, not ReplayGain gain. -final systemVolumeProvider = StreamProvider.autoDispose( - (ref) => SystemVolumeService.instance.changes, -); - -final systemVolumeWriterProvider = Provider Function(double)>(( - ref, -) { - return (value) { - if (usbHardwareVolume.value != null) return setUsbHardwareVolume(value); - final volume = VolumeController.instance; - volume.showSystemUI = false; - return volume.setVolume(value.clamp(0, 1)); - }; -}); - -class MornyeVolumeControl extends ConsumerStatefulWidget { - const MornyeVolumeControl({super.key, this.foreground = Colors.white}); - - final Color foreground; - - @override - ConsumerState createState() => - _MornyeVolumeControlState(); -} - -class _MornyeVolumeControlState extends ConsumerState { - double? _preview; - double? _pendingVolume; - bool _dragging = false; - bool _writing = false; - - void _setVolume(double value) { - setState(() => _preview = value); - _pendingVolume = value; - if (!_writing) unawaited(_flushVolume()); - } - - Future _flushVolume() async { - _writing = true; - final write = ref.read(systemVolumeWriterProvider); - final usbToken = usbHardwareVolume.value?.token; - try { - // Send changes during the gesture. If the platform is still processing - // a write, keep only the newest value instead of queuing stale positions. - while (mounted && _pendingVolume != null) { - if (usbHardwareVolume.value?.token != usbToken) { - _pendingVolume = null; - break; - } - final value = _pendingVolume!; - _pendingVolume = null; - try { - await write(value); - } catch (error) { - AppLogger('PlayerVolume').w('Could not set system volume: $error'); - } - } - } finally { - _writing = false; - if (mounted && !_dragging) setState(() => _preview = null); - } - } - - @override - Widget build(BuildContext context) { - final usb = ref.watch(usbVolumeProvider).value ?? usbHardwareVolume.value; - final volume = usb == null - ? ref.watch(systemVolumeProvider).value - : usb.available - ? usb.fraction - : null; - final value = (_preview ?? volume ?? 0).clamp(0.0, 1.0); - String percentage(double volume) => '${(volume * 100).round()}%'; - return Semantics( - label: usb == null - ? context.l10n.nowPlayingVolume - : context.l10n.usbHardwareVolume, - slider: true, - enabled: volume != null, - excludeSemantics: true, - value: volume == null ? null : percentage(value), - increasedValue: volume == null - ? null - : percentage((value + 0.05).clamp(0, 1)), - decreasedValue: volume == null - ? null - : percentage((value - 0.05).clamp(0, 1)), - onIncrease: volume == null - ? null - : () => _setVolume((value + 0.05).clamp(0, 1)), - onDecrease: volume == null - ? null - : () => _setVolume((value - 0.05).clamp(0, 1)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Row( - children: [ - Icon( - CupertinoIcons.speaker_fill, - size: 16, - color: widget.foreground.withValues(alpha: 0.54), - ), - const SizedBox(width: 10), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) => IgnorePointer( - ignoring: volume == null, - child: Opacity( - opacity: volume == null ? 0.4 : 1, - child: MornyePlayerSlider( - value: value, - activeColor: widget.foreground.withValues(alpha: 0.70), - inactiveColor: widget.foreground.withValues(alpha: 0.12), - onChangeStart: (_) => _dragging = true, - onChanged: _setVolume, - onChangeEnd: (value) { - _dragging = false; - _setVolume(value); - }, - ), - ), - ), - ), - ), - const SizedBox(width: 10), - Icon( - CupertinoIcons.speaker_3_fill, - size: 16, - color: widget.foreground.withValues(alpha: 0.54), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/player_volume_control.dart b/lib/widgets/player_volume_control.dart new file mode 100644 index 00000000..f7a90339 --- /dev/null +++ b/lib/widgets/player_volume_control.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_icon.dart'; +import 'package:spotiflac_android/widgets/app_slider.dart'; +import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:volume_controller/volume_controller.dart'; +import 'package:spotiflac_android/services/music_playback_deck.dart'; +import 'package:spotiflac_android/services/system_volume_service.dart'; + +final usbVolumeProvider = StreamProvider.autoDispose((ref) { + final events = StreamController(); + void changed() => events.add(usbHardwareVolume.value); + usbHardwareVolume.addListener(changed); + changed(); + ref.onDispose(() { + usbHardwareVolume.removeListener(changed); + unawaited(events.close()); + }); + return events.stream; +}); + +/// A single shared subscription for the visible player, including hardware +/// volume-button changes. This controls system volume, not ReplayGain gain. +final systemVolumeProvider = StreamProvider.autoDispose( + (ref) => SystemVolumeService.instance.changes, +); + +final systemVolumeWriterProvider = Provider Function(double)>(( + ref, +) { + return (value) { + if (usbHardwareVolume.value != null) return setUsbHardwareVolume(value); + final volume = VolumeController.instance; + volume.showSystemUI = false; + return volume.setVolume(value.clamp(0, 1)); + }; +}); + +/// System (or USB hardware) volume slider for the player and USB settings. +/// Mornye draws its glass slider between speaker glyphs; Material draws the +/// expressive slider between volume icons. +class PlayerVolumeControl extends ConsumerStatefulWidget { + const PlayerVolumeControl({super.key, this.foreground = Colors.white}); + + /// Mornye only: the tint of the slider and glyphs over the player artwork. + final Color foreground; + + @override + ConsumerState createState() => + _PlayerVolumeControlState(); +} + +class _PlayerVolumeControlState extends ConsumerState { + double? _preview; + double? _pendingVolume; + bool _dragging = false; + bool _writing = false; + + void _setVolume(double value) { + setState(() => _preview = value); + _pendingVolume = value; + if (!_writing) unawaited(_flushVolume()); + } + + Future _flushVolume() async { + _writing = true; + final write = ref.read(systemVolumeWriterProvider); + final usbToken = usbHardwareVolume.value?.token; + try { + // Send changes during the gesture. If the platform is still processing + // a write, keep only the newest value instead of queuing stale positions. + while (mounted && _pendingVolume != null) { + if (usbHardwareVolume.value?.token != usbToken) { + _pendingVolume = null; + break; + } + final value = _pendingVolume!; + _pendingVolume = null; + try { + await write(value); + } catch (error) { + AppLogger('PlayerVolume').w('Could not set system volume: $error'); + } + } + } finally { + _writing = false; + if (mounted && !_dragging) setState(() => _preview = null); + } + } + + @override + Widget build(BuildContext context) { + final usb = ref.watch(usbVolumeProvider).value ?? usbHardwareVolume.value; + final volume = usb == null + ? ref.watch(systemVolumeProvider).value + : usb.available + ? usb.fraction + : null; + final value = (_preview ?? volume ?? 0).clamp(0.0, 1.0); + String percentage(double volume) => '${(volume * 100).round()}%'; + return Semantics( + label: usb == null + ? context.l10n.nowPlayingVolume + : context.l10n.usbHardwareVolume, + slider: true, + enabled: volume != null, + excludeSemantics: true, + value: volume == null ? null : percentage(value), + increasedValue: volume == null + ? null + : percentage((value + 0.05).clamp(0, 1)), + decreasedValue: volume == null + ? null + : percentage((value - 0.05).clamp(0, 1)), + onIncrease: volume == null + ? null + : () => _setVolume((value + 0.05).clamp(0, 1)), + onDecrease: volume == null + ? null + : () => _setVolume((value - 0.05).clamp(0, 1)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: !context.isMornye + ? _materialSlider(context, value, enabled: volume != null) + : Row( + children: [ + Icon( + CupertinoIcons.speaker_fill, + size: 16, + color: widget.foreground.withValues(alpha: 0.54), + ), + const SizedBox(width: 10), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) => IgnorePointer( + ignoring: volume == null, + child: Opacity( + opacity: volume == null ? 0.4 : 1, + child: MornyePlayerSlider( + value: value, + activeColor: widget.foreground.withValues( + alpha: 0.70, + ), + inactiveColor: widget.foreground.withValues( + alpha: 0.12, + ), + onChangeStart: (_) => _dragging = true, + onChanged: _setVolume, + onChangeEnd: (value) { + _dragging = false; + _setVolume(value); + }, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + Icon( + CupertinoIcons.speaker_3_fill, + size: 16, + color: widget.foreground.withValues(alpha: 0.54), + ), + ], + ), + ), + ); + } + + /// The expressive slider has no drag-start callback, so the pointer marks + /// the drag that keeps system-volume echoes from resetting the thumb. + Widget _materialSlider( + BuildContext context, + double value, { + required bool enabled, + }) { + final colors = Theme.of(context).colorScheme; + void release() { + if (!_dragging) return; + _dragging = false; + if (!_writing && mounted) setState(() => _preview = null); + } + + return Row( + children: [ + AppIcon( + value <= 0 ? Icons.volume_mute_rounded : Icons.volume_down_rounded, + size: 20, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: IgnorePointer( + ignoring: !enabled, + child: Opacity( + opacity: enabled ? 1 : 0.4, + child: Listener( + onPointerDown: (_) => _dragging = true, + onPointerUp: (_) => release(), + onPointerCancel: (_) => release(), + child: AppSlider( + // Thinner than the default track: volume stays secondary + // to the seek bar above it. + trackThickness: 8, + value: value, + onChanged: enabled ? _setVolume : null, + onChangeEnd: _setVolume, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + AppIcon( + Icons.volume_up_rounded, + size: 20, + color: colors.onSurfaceVariant, + ), + ], + ); + } +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 2c055e82..9ede1366 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -32,7 +32,7 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; import 'package:spotiflac_android/widgets/expressive_playback_transport.dart'; import 'package:spotiflac_android/widgets/expressive_seek_track.dart'; -import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/player_volume_control.dart'; import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; @@ -534,7 +534,7 @@ void main() { .hitTestable(at: Alignment.centerLeft) .first; final title = tester.getRect(titleFinder); - final volume = tester.getRect(find.byType(MornyeVolumeControl)); + final volume = tester.getRect(find.byType(PlayerVolumeControl)); expect(playingCover.width, playingCover.height); expect(playingCover.width, greaterThanOrEqualTo(393 * 0.84)); expect(playingCover.left, greaterThan(0)); @@ -563,7 +563,7 @@ void main() { expect(pausedCover.center.dx, closeTo(playingCover.center.dx, 0.01)); expect(pausedCover.center.dy, closeTo(playingCover.center.dy, 0.01)); expect(tester.getRect(titleFinder), title); - expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); + expect(tester.getRect(find.byType(PlayerVolumeControl)), volume); playbackEvents.add(PlaybackState(playing: true)); await settlePlayer(tester); @@ -621,7 +621,7 @@ void main() { ); expect( tester - .widget(find.byType(MornyeVolumeControl)) + .widget(find.byType(PlayerVolumeControl)) .foreground, color, ); @@ -1149,7 +1149,7 @@ void main() { MornyePlaybackButton, CupertinoIcons.pause_fill, ); - final volume = find.byType(MornyeVolumeControl); + final volume = find.byType(PlayerVolumeControl); await tester.tap(lyricsButton); await tester.pump(); await completeLyrics(tester); @@ -1301,7 +1301,7 @@ void main() { await tester.tap(findAppIcon(CupertinoIcons.quote_bubble)); await settlePlayer(tester); await tester.pump(const Duration(seconds: 6)); - expect(find.byType(MornyeVolumeControl).hitTestable(), findsOneWidget); + expect(find.byType(PlayerVolumeControl).hitTestable(), findsOneWidget); expect( findAppIcon(CupertinoIcons.quote_bubble).hitTestable(), findsOneWidget, @@ -1937,12 +1937,12 @@ void main() { await tester.tap(findAppIcon(CupertinoIcons.quote_bubble)); await settlePlayer(tester, const Duration(milliseconds: 16)); expect(find.text('First lyric').hitTestable(), findsOneWidget); - final volume = tester.getRect(find.byType(MornyeVolumeControl)); + final volume = tester.getRect(find.byType(PlayerVolumeControl)); await tester.tap(findAppIcon(CupertinoIcons.list_bullet)); await settlePlayer(tester, const Duration(milliseconds: 16)); expect(find.byType(MornyePlayerQueue), findsOneWidget); expect(find.byType(BottomSheet), findsNothing); - expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); + expect(tester.getRect(find.byType(PlayerVolumeControl)), volume); expect(tester.takeException(), isNull); await tester.tap(findAppIcon(CupertinoIcons.list_bullet)); await tester.pump(); @@ -1953,7 +1953,7 @@ void main() { expect(find.byType(MornyePlayerQueue).hitTestable(), findsNothing); await settlePlayer(tester, const Duration(milliseconds: 16)); expect(find.byType(MornyePlayerQueue), findsNothing); - expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); + expect(tester.getRect(find.byType(PlayerVolumeControl)), volume); }, ); @@ -1984,7 +1984,7 @@ void main() { final next = findAppIcon(CupertinoIcons.forward_fill); final previousBounds = tester.getRect(previous); final nextBounds = tester.getRect(next); - final volumeBounds = tester.getRect(find.byType(MornyeVolumeControl)); + final volumeBounds = tester.getRect(find.byType(PlayerVolumeControl)); playback.add(PlaybackState(playing: false)); await settlePlayer(tester); @@ -1998,7 +1998,7 @@ void main() { expect(tester.getRect(play), pauseBounds); expect(tester.getRect(previous), previousBounds); expect(tester.getRect(next), nextBounds); - expect(tester.getRect(find.byType(MornyeVolumeControl)), volumeBounds); + expect(tester.getRect(find.byType(PlayerVolumeControl)), volumeBounds); playback.add(PlaybackState(playing: true)); await settlePlayer(tester); @@ -2195,7 +2195,7 @@ void main() { expect(findAppIcon(CupertinoIcons.play_fill), findsOneWidget); expect(findAppIcon(CupertinoIcons.backward_fill), findsOneWidget); expect(findAppIcon(CupertinoIcons.forward_fill), findsOneWidget); - expect(find.byType(MornyeVolumeControl), findsOneWidget); + expect(find.byType(PlayerVolumeControl), findsOneWidget); expect(tester.takeException(), isNull); final nextButton = find.widgetWithIcon( @@ -2307,7 +2307,7 @@ void main() { mediaItems.add(item('first')); await settlePlayer(tester); - final volume = find.byType(MornyeVolumeControl); + final volume = find.byType(PlayerVolumeControl); final lyricsButton = findAppIcon(CupertinoIcons.quote_bubble); final slider = find.descendant(of: volume, matching: find.byType(Slider)); expect( @@ -2345,7 +2345,7 @@ void main() { await settlePlayer(tester); final artwork = find.byType(Hero); - final volume = find.byType(MornyeVolumeControl); + final volume = find.byType(PlayerVolumeControl); final artRect = tester.getRect(artwork); expect(artRect.width, closeTo(artRect.height, 0.1)); expect(artRect.width, greaterThan(140)); @@ -2939,7 +2939,7 @@ void main() { final artwork = find.byType(Hero); final fullArtwork = tester.getRect(artwork); expect(fullArtwork.height, fullArtwork.width); - final volume = find.byType(MornyeVolumeControl); + final volume = find.byType(PlayerVolumeControl); final volumeRect = tester.getRect(volume); final play = findAppIcon(CupertinoIcons.play_fill); final playRect = tester.getRect(play); diff --git a/test/mornye_volume_control_test.dart b/test/player_volume_control_test.dart similarity index 52% rename from test/mornye_volume_control_test.dart rename to test/player_volume_control_test.dart index 3683239f..e1d57464 100644 --- a/test/mornye_volume_control_test.dart +++ b/test/player_volume_control_test.dart @@ -5,7 +5,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_slider.dart'; +import 'package:spotiflac_android/widgets/player_volume_control.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -47,10 +49,12 @@ void main() { }, ), ], - child: const MaterialApp( + child: MaterialApp( + // The Mornye glass slider, which reports drag start itself. + theme: MornyeTheme.build(Brightness.dark), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: MornyeVolumeControl()), + home: const Scaffold(body: PlayerVolumeControl()), ), ), ); @@ -93,4 +97,65 @@ void main() { expect(slider().value, 0.3); expect(tester.takeException(), isNull); }); + + for (final reducedMotion in [false, true]) { + testWidgets( + 'Material holds dragged volume (reduced motion: $reducedMotion)', + (tester) async { + final writes = []; + final volumes = StreamController(); + addTearDown(volumes.close); + await tester.pumpWidget( + ProviderScope( + overrides: [ + systemVolumeProvider.overrideWith((ref) => volumes.stream), + systemVolumeWriterProvider.overrideWith( + (ref) => + (value) async => writes.add(value), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MediaQuery( + data: MediaQueryData(disableAnimations: reducedMotion), + child: const Scaffold(body: PlayerVolumeControl()), + ), + ), + ), + ); + volumes.add(0.5); + await tester.pumpAndSettle(); + AppSlider slider() => tester.widget(find.byType(AppSlider)); + expect(slider().value, 0.5); + + // A finger on the track: the write finishes and the system echoes an + // older level, but the thumb stays where the user put it. + final finger = await tester.startGesture( + tester.getCenter(find.byType(AppSlider)), + ); + await finger.moveBy(const Offset(20, 0)); + await tester.pump(); + await finger.moveBy(const Offset(100, 0)); + await tester.pump(); + final draggedVolume = slider().value; + expect(draggedVolume, greaterThan(0.5)); + expect(writes.last, draggedVolume); + volumes.add(0.6); + await tester.pump(); + expect(slider().value, draggedVolume); + + // Once the finger lifts the slider follows the system again. + await finger.up(); + expect(writes.last, draggedVolume); + volumes.add(draggedVolume); + await tester.pumpAndSettle(); + expect(slider().value, draggedVolume); + volumes.add(0.2); + await tester.pumpAndSettle(); + expect(slider().value, 0.2); + expect(tester.takeException(), isNull); + }, + ); + } }