mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
feat(player): share volume controls across Material and Mornye
This commit is contained in:
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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,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(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user