diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index f23a9f5d..54777e4c 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -2724,6 +2724,7 @@ class _PlaybackControls extends ConsumerWidget { duration: duration, onSeek: controller.seek, preview: seekPreview, + playing: isPlaying && !isLoading, ), ), Padding( diff --git a/lib/widgets/expressive_seek_track.dart b/lib/widgets/expressive_seek_track.dart new file mode 100644 index 00000000..e0513839 --- /dev/null +++ b/lib/widgets/expressive_seek_track.dart @@ -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 createState() => _ExpressiveSeekTrackState(); +} + +class _ExpressiveSeekTrackState extends State + 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 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(); + } +} diff --git a/lib/widgets/playback_seek_slider.dart b/lib/widgets/playback_seek_slider.dart index aba7aad5..d81bfe1e 100644 --- a/lib/widgets/playback_seek_slider.dart +++ b/lib/widgets/playback_seek_slider.dart @@ -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 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 { ), ); } - 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, + ), ); } } diff --git a/test/expressive_seek_track_test.dart b/test/expressive_seek_track_test.dart new file mode 100644 index 00000000..999a9eca --- /dev/null +++ b/test/expressive_seek_track_test.dart @@ -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 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(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 = []; + 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(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()); + 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 = []; + 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(); + }); +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index c526fadd..6af968cb 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -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.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( + 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, diff --git a/test/playback_seek_slider_test.dart b/test/playback_seek_slider_test.dart index d7bb7eaf..3a92aa20 100644 --- a/test/playback_seek_slider_test.dart +++ b/test/playback_seek_slider_test.dart @@ -18,6 +18,7 @@ void main() { home: Scaffold( body: PlaybackSeekSlider( preview: sharedPreview, + playing: true, position: position, duration: const Duration(seconds: 100), onSeek: (target) {