feat(player): animate Material seek bar with expressive waves

This commit is contained in:
zarzet
2026-09-27 17:18:51 +07:00
parent d390a214c3
commit 472e549f8f
6 changed files with 402 additions and 11 deletions
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/expressive_seek_track.dart';
import 'package:spotiflac_android/widgets/playback_seek_slider.dart';
Widget _host({
bool playing = true,
bool reduceMotion = false,
bool visible = true,
bool mornye = false,
TextDirection direction = TextDirection.ltr,
Duration position = const Duration(seconds: 30),
Duration duration = const Duration(seconds: 100),
Future<void> Function(Duration)? onSeek,
}) => MaterialApp(
theme: mornye ? MornyeTheme.build(Brightness.dark) : null,
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduceMotion),
child: Directionality(
textDirection: direction,
child: TickerMode(
enabled: visible,
child: Scaffold(
body: Center(
child: PlaybackSeekSlider(
playing: playing,
position: position,
duration: duration,
onSeek: onSeek ?? (_) async {},
),
),
),
),
),
),
);
ExpressiveSeekTrackShape _track(WidgetTester tester) =>
SliderTheme.of(tester.element(find.byType(Slider))).trackShape!
as ExpressiveSeekTrackShape;
void main() {
testWidgets('wave travels during playback and settles smoothly on pause', (
tester,
) async {
await tester.pumpWidget(_host());
await tester.pump(const Duration(milliseconds: 400));
final phase = _track(tester).phase;
expect(_track(tester).amplitude, 1);
await tester.pump(const Duration(milliseconds: 100));
expect(_track(tester).phase, isNot(phase));
expect(tester.widget<Slider>(find.byType(Slider)).value, 30000);
await tester.pumpWidget(_host(playing: false));
await tester.pump(const Duration(milliseconds: 150));
expect(_track(tester).amplitude, greaterThan(0));
expect(_track(tester).amplitude, lessThan(1));
await tester.pumpAndSettle();
expect(_track(tester).amplitude, 0);
final pausedPhase = _track(tester).phase;
await tester.pump(const Duration(seconds: 2));
expect(_track(tester).phase, pausedPhase);
expect(tester.binding.hasScheduledFrame, isFalse);
await tester.pumpWidget(_host());
await tester.pump(const Duration(milliseconds: 400));
expect(_track(tester).amplitude, 1);
expect(_track(tester).phase, isNot(pausedPhase));
});
testWidgets('scrubbing flattens the track and playback restores the wave', (
tester,
) async {
final seeks = <Duration>[];
await tester.pumpWidget(_host(onSeek: (target) async => seeks.add(target)));
await tester.pump(const Duration(milliseconds: 400));
final bounds = tester.getRect(find.byType(Slider));
final gesture = await tester.startGesture(bounds.center);
await gesture.moveBy(Offset(bounds.width * 0.2, 0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(_track(tester).amplitude, 0);
expect(seeks, isEmpty);
final preview = tester.widget<Slider>(find.byType(Slider)).value;
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(seeks, [Duration(milliseconds: preview.round())]);
expect(_track(tester).amplitude, 1);
});
testWidgets('reduced motion and hidden routes stop the animation', (
tester,
) async {
for (final reduceMotion in [true, false]) {
await tester.pumpWidget(_host());
await tester.pump(const Duration(milliseconds: 400));
expect(_track(tester).amplitude, 1);
await tester.pumpWidget(
_host(reduceMotion: reduceMotion, visible: reduceMotion),
);
await tester.pumpAndSettle();
expect(_track(tester).amplitude, 0);
final phase = _track(tester).phase;
await tester.pump(const Duration(seconds: 2));
expect(_track(tester).phase, phase);
expect(tester.binding.hasScheduledFrame, isFalse);
}
});
testWidgets('unknown and completed tracks do not animate', (tester) async {
for (final duration in [Duration.zero, const Duration(seconds: 30)]) {
await tester.pumpWidget(_host(duration: duration));
await tester.pumpAndSettle();
expect(_track(tester).amplitude, 0);
expect(tester.binding.hasScheduledFrame, isFalse);
}
});
testWidgets('Mornye keeps its continuous track without a wave or handle', (
tester,
) async {
await tester.pumpWidget(_host(mornye: true));
await tester.pumpAndSettle();
expect(find.byType(ExpressiveSeekTrack), findsNothing);
final theme = SliderTheme.of(tester.element(find.byType(Slider)));
expect(theme.trackShape, isA<RoundedRectSliderTrackShape>());
expect(theme.thumbShape, SliderComponentShape.noThumb);
expect(tester.binding.hasScheduledFrame, isFalse);
});
testWidgets('RTL seeking and screen reader adjustment remain available', (
tester,
) async {
final semantics = tester.ensureSemantics();
final seeks = <Duration>[];
await tester.pumpWidget(
_host(
direction: TextDirection.rtl,
onSeek: (target) async => seeks.add(target),
),
);
final bounds = tester.getRect(find.byType(Slider));
await tester.tapAt(
Offset(bounds.left + bounds.width * 0.2, bounds.center.dy),
);
await tester.pump();
expect(seeks.single.inSeconds, greaterThan(70));
final node = tester.semantics.simulatedAccessibilityTraversal().singleWhere(
(node) => node.getSemanticsData().flagsCollection.isSlider,
);
expect(node.getSemanticsData().hasAction(SemanticsAction.increase), isTrue);
node.owner!.performAction(node.id, SemanticsAction.increase);
await tester.pump();
expect(seeks, hasLength(2));
expect(seeks.last.inSeconds, greaterThan(30));
semantics.dispose();
});
}
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
import 'package:spotiflac_android/widgets/expressive_seek_track.dart';
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart';
import 'package:spotiflac_android/widgets/mornye_player_queue.dart';
@@ -187,6 +188,42 @@ void main() {
);
}
testWidgets('Material seek wave follows playback, buffering and pause', (
tester,
) async {
final playback = StreamController<PlaybackState>.broadcast();
addTearDown(playback.close);
await pumpNowPlaying(tester, playbackEvents: playback.stream);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
for (final state in [
PlaybackState(playing: true, processingState: AudioProcessingState.ready),
PlaybackState(
playing: true,
processingState: AudioProcessingState.buffering,
),
PlaybackState(playing: true, processingState: AudioProcessingState.ready),
PlaybackState(
playing: false,
processingState: AudioProcessingState.ready,
),
]) {
playback.add(state);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
final track = tester.widget<ExpressiveSeekTrack>(
find.byType(ExpressiveSeekTrack),
);
expect(
track.playing,
state.playing && state.processingState == AudioProcessingState.ready,
);
}
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
for (final page in ['player', 'lyrics', 'queue']) {
testWidgets('cover swipe changes the track in the Mornye $page', (
tester,
+1
View File
@@ -18,6 +18,7 @@ void main() {
home: Scaffold(
body: PlaybackSeekSlider(
preview: sharedPreview,
playing: true,
position: position,
duration: const Duration(seconds: 100),
onSeek: (target) {