mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(player): animate Material seek bar with expressive waves
This commit is contained in:
@@ -2724,6 +2724,7 @@ class _PlaybackControls extends ConsumerWidget {
|
||||
duration: duration,
|
||||
onSeek: controller.seek,
|
||||
preview: seekPreview,
|
||||
playing: isPlaying && !isLoading,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_3_expressive/components/sliders/components/m3e_slider_track_painter.dart';
|
||||
import 'package:material_3_expressive/material_3_expressive.dart';
|
||||
|
||||
/// Expressive track motion around the native slider's seeking and accessibility.
|
||||
class ExpressiveSeekTrack extends StatefulWidget {
|
||||
const ExpressiveSeekTrack({
|
||||
super.key,
|
||||
required this.playing,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool playing;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<ExpressiveSeekTrack> createState() => _ExpressiveSeekTrackState();
|
||||
}
|
||||
|
||||
class _ExpressiveSeekTrackState extends State<ExpressiveSeekTrack>
|
||||
with TickerProviderStateMixin {
|
||||
late final _phase = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1800),
|
||||
);
|
||||
late final _amplitude =
|
||||
AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
)..addStatusListener((status) {
|
||||
if (status == AnimationStatus.dismissed) _phase.stop();
|
||||
});
|
||||
late final _animation = Listenable.merge([_phase, _amplitude]);
|
||||
bool _motionEnabled = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_motionEnabled =
|
||||
!MediaQuery.disableAnimationsOf(context) &&
|
||||
TickerMode.valuesOf(context).enabled;
|
||||
_syncAnimation();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ExpressiveSeekTrack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.playing != widget.playing) _syncAnimation();
|
||||
}
|
||||
|
||||
void _syncAnimation() {
|
||||
if (!_motionEnabled) {
|
||||
_phase.stop();
|
||||
_amplitude.value = 0;
|
||||
} else if (widget.playing) {
|
||||
if (!_phase.isAnimating) _phase.repeat();
|
||||
_amplitude.forward();
|
||||
} else {
|
||||
_amplitude.reverse();
|
||||
if (_amplitude.isDismissed) _phase.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amplitude.dispose();
|
||||
_phase.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animation,
|
||||
child: widget.child,
|
||||
builder: (context, child) => SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 4,
|
||||
trackShape: ExpressiveSeekTrackShape(
|
||||
phase: _phase.value * 2 * math.pi,
|
||||
amplitude: Curves.easeInOutCubic.transform(_amplitude.value),
|
||||
),
|
||||
thumbShape: const HandleThumbShape(),
|
||||
thumbSize: const WidgetStatePropertyAll(Size(4, 24)),
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Uses the package's wave renderer without changing the slider gesture cycle.
|
||||
class ExpressiveSeekTrackShape extends RoundedRectSliderTrackShape {
|
||||
const ExpressiveSeekTrackShape({
|
||||
required this.phase,
|
||||
required this.amplitude,
|
||||
});
|
||||
|
||||
final double phase;
|
||||
final double amplitude;
|
||||
|
||||
@override
|
||||
void paint(
|
||||
PaintingContext context,
|
||||
Offset offset, {
|
||||
required RenderBox parentBox,
|
||||
required SliderThemeData sliderTheme,
|
||||
required Animation<double> enableAnimation,
|
||||
required TextDirection textDirection,
|
||||
required Offset thumbCenter,
|
||||
Offset? secondaryOffset,
|
||||
bool isDiscrete = false,
|
||||
bool isEnabled = false,
|
||||
double additionalActiveTrackHeight = 2,
|
||||
}) {
|
||||
final track = getPreferredRect(
|
||||
parentBox: parentBox,
|
||||
offset: offset,
|
||||
sliderTheme: sliderTheme,
|
||||
isEnabled: isEnabled,
|
||||
isDiscrete: isDiscrete,
|
||||
);
|
||||
if (track.isEmpty) return;
|
||||
final rtl = textDirection == TextDirection.rtl;
|
||||
final fraction =
|
||||
((rtl ? track.right - thumbCenter.dx : thumbCenter.dx - track.left) /
|
||||
track.width)
|
||||
.clamp(0.0, 1.0);
|
||||
final active = Color.lerp(
|
||||
sliderTheme.disabledActiveTrackColor,
|
||||
sliderTheme.activeTrackColor,
|
||||
enableAnimation.value,
|
||||
)!;
|
||||
final inactive = Color.lerp(
|
||||
sliderTheme.disabledInactiveTrackColor,
|
||||
sliderTheme.inactiveTrackColor,
|
||||
enableAnimation.value,
|
||||
)!;
|
||||
final painter = M3ESliderTrackPainter(
|
||||
mode: M3ESliderPaintMode.single,
|
||||
trackKind: M3ESliderTrackKind.standard,
|
||||
activeStartFraction: 0,
|
||||
activeEndFraction: fraction,
|
||||
tickFractions: const [],
|
||||
colors: M3ESliderColors(
|
||||
thumb: active,
|
||||
activeTrack: active,
|
||||
inactiveTrack: inactive,
|
||||
activeTick: inactive,
|
||||
inactiveTick: active,
|
||||
stopIndicator: active,
|
||||
valueIndicator: active,
|
||||
valueIndicatorLabel: inactive,
|
||||
),
|
||||
trackHeight: track.height,
|
||||
handleGap: 6,
|
||||
handleThickness: 4,
|
||||
insideCornerSize: 2,
|
||||
cornerRadius: 2,
|
||||
stopIndicatorSize: 4,
|
||||
tickSize: 0,
|
||||
edgeInset: 2,
|
||||
axis: Axis.horizontal,
|
||||
// Mirror the complete logical track, including waves and the end marker.
|
||||
textDirection: TextDirection.ltr,
|
||||
isWavy: amplitude > 0,
|
||||
waveAmplitude: 3,
|
||||
wavelength: 40,
|
||||
phase: phase,
|
||||
amplitudeFactor: amplitude,
|
||||
);
|
||||
final canvas = context.canvas;
|
||||
canvas.save();
|
||||
canvas.translate(rtl ? track.right : track.left, track.top);
|
||||
if (rtl) canvas.scale(-1, 1);
|
||||
painter.paint(canvas, track.size);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
|
||||
import 'package:spotiflac_android/widgets/expressive_seek_track.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_player_slider.dart';
|
||||
|
||||
/// Previews scrubbing locally and seeks once when the gesture ends.
|
||||
@@ -9,6 +10,7 @@ class PlaybackSeekSlider extends StatefulWidget {
|
||||
final Duration duration;
|
||||
final Future<void> Function(Duration) onSeek;
|
||||
final PlaybackSeekPreview? preview;
|
||||
final bool playing;
|
||||
|
||||
const PlaybackSeekSlider({
|
||||
super.key,
|
||||
@@ -16,6 +18,7 @@ class PlaybackSeekSlider extends StatefulWidget {
|
||||
required this.duration,
|
||||
required this.onSeek,
|
||||
this.preview,
|
||||
this.playing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -118,17 +121,24 @@ class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return Slider(
|
||||
value: enabled
|
||||
? (_previewMs ?? widget.position.inMilliseconds.toDouble()).clamp(
|
||||
0,
|
||||
maxMs,
|
||||
)
|
||||
: 0,
|
||||
max: maxMs,
|
||||
onChangeStart: enabled ? _start : null,
|
||||
onChanged: enabled ? _preview : null,
|
||||
onChangeEnd: enabled ? _commit : null,
|
||||
return ExpressiveSeekTrack(
|
||||
playing:
|
||||
widget.playing &&
|
||||
enabled &&
|
||||
widget.position < widget.duration &&
|
||||
_previewMs == null,
|
||||
child: Slider(
|
||||
value: enabled
|
||||
? (_previewMs ?? widget.position.inMilliseconds.toDouble()).clamp(
|
||||
0,
|
||||
maxMs,
|
||||
)
|
||||
: 0,
|
||||
max: maxMs,
|
||||
onChangeStart: enabled ? _start : null,
|
||||
onChanged: enabled ? _preview : null,
|
||||
onChangeEnd: enabled ? _commit : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: PlaybackSeekSlider(
|
||||
preview: sharedPreview,
|
||||
playing: true,
|
||||
position: position,
|
||||
duration: const Duration(seconds: 100),
|
||||
onSeek: (target) {
|
||||
|
||||
Reference in New Issue
Block a user