feat(player): share volume controls across Material and Mornye

This commit is contained in:
zarzet committed 2026-10-15 19:16:17 +07:00
1 parent 1d53bf9301
commit 2111afa468
9 files changed
+336 -188

No files matched your search

@@ -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';
@@ -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<PlaybackSettingsPage> {
: context.l10n.usbVolumeUnavailable,
),
if (volume?.available == true)
MornyeVolumeControl(
PlayerVolumeControl(
foreground: colorScheme.onSurface,
),
],
+6
View File
@@ -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,
+13 -2
View File
@@ -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<Widget> _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(),
],
];
}
}
+3 -3
View File
@@ -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,
),
-162
View File
@@ -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<UsbVolumeState?>((ref) {
final events = StreamController<UsbVolumeState?>();
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<double>(
(ref) => SystemVolumeService.instance.changes,
);
final systemVolumeWriterProvider = Provider<Future<void> 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<MornyeVolumeControl> createState() =>
_MornyeVolumeControlState();
}
class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
double? _preview;
double? _pendingVolume;
bool _dragging = false;
bool _writing = false;
void _setVolume(double value) {
setState(() => _preview = value);
_pendingVolume = value;
if (!_writing) unawaited(_flushVolume());
}
Future<void> _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),
),
],
),
),
);
}
}
+228
View File
@@ -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<UsbVolumeState?>((ref) {
final events = StreamController<UsbVolumeState?>();
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<double>(
(ref) => SystemVolumeService.instance.changes,
);
final systemVolumeWriterProvider = Provider<Future<void> 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<PlayerVolumeControl> createState() =>
_PlayerVolumeControlState();
}
class _PlayerVolumeControlState extends ConsumerState<PlayerVolumeControl> {
double? _preview;
double? _pendingVolume;
bool _dragging = false;
bool _writing = false;
void _setVolume(double value) {
setState(() => _preview = value);
_pendingVolume = value;
if (!_writing) unawaited(_flushVolume());
}
Future<void> _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,
),
],
);
}
}
+15 -15
View File
@@ -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<MornyeVolumeControl>(find.byType(MornyeVolumeControl))
.widget<PlayerVolumeControl>(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);
@@ -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 = <double>[];
final volumes = StreamController<double>();
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);
},
);
}
}