diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index d8939d12..f997b44f 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -22,6 +22,7 @@ import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; final _log = AppLogger('NowPlaying'); @@ -661,6 +662,7 @@ class _NowPlayingScreenState extends ConsumerState { ), const SizedBox(height: 24), _PlaybackControls( + key: ValueKey(mediaItem.id), duration: mediaItem.duration ?? Duration.zero, controller: controller, colorScheme: colorScheme, @@ -1195,6 +1197,7 @@ class _PlaybackControls extends ConsumerWidget { final String? qualityLabel; const _PlaybackControls({ + super.key, required this.duration, required this.controller, required this.colorScheme, @@ -1216,13 +1219,6 @@ class _PlaybackControls extends ConsumerWidget { (s) => s.value?.repeatMode ?? AudioServiceRepeatMode.none, ), ); - final maxMs = duration.inMilliseconds > 0 - ? duration.inMilliseconds.toDouble() - : 1.0; - final posMs = position.inMilliseconds - .clamp(0, duration.inMilliseconds > 0 ? duration.inMilliseconds : 0) - .toDouble(); - return Column( children: [ Padding( @@ -1246,14 +1242,10 @@ class _PlaybackControls extends ConsumerWidget { overlayRadius: 24, ), ), - child: Slider( - value: posMs.clamp(0, maxMs), - max: maxMs, - onChanged: duration.inMilliseconds > 0 - ? (value) => controller.seek( - Duration(milliseconds: value.round()), - ) - : null, + child: PlaybackSeekSlider( + position: position, + duration: duration, + onSeek: controller.seek, ), ), Padding( diff --git a/lib/widgets/playback_seek_slider.dart b/lib/widgets/playback_seek_slider.dart new file mode 100644 index 00000000..b871bf14 --- /dev/null +++ b/lib/widgets/playback_seek_slider.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +/// Previews scrubbing locally and seeks once when the gesture ends. +class PlaybackSeekSlider extends StatefulWidget { + final Duration position; + final Duration duration; + final Future Function(Duration) onSeek; + + const PlaybackSeekSlider({ + super.key, + required this.position, + required this.duration, + required this.onSeek, + }); + + @override + State createState() => _PlaybackSeekSliderState(); +} + +class _PlaybackSeekSliderState extends State { + double? _previewMs; + int _gestureGeneration = 0; + + Future _commit(double value) async { + final generation = _gestureGeneration; + try { + await widget.onSeek(Duration(milliseconds: value.round())); + } finally { + if (mounted && generation == _gestureGeneration) { + setState(() => _previewMs = null); + } + } + } + + @override + Widget build(BuildContext context) { + final durationMs = widget.duration.inMilliseconds; + final enabled = durationMs > 0; + final maxMs = enabled ? durationMs.toDouble() : 1.0; + return Slider( + value: enabled + ? (_previewMs ?? widget.position.inMilliseconds.toDouble()).clamp( + 0, + maxMs, + ) + : 0, + max: maxMs, + onChangeStart: enabled ? (_) => _gestureGeneration++ : null, + onChanged: enabled ? (value) => setState(() => _previewMs = value) : null, + onChangeEnd: enabled ? _commit : null, + ); + } +} diff --git a/test/playback_seek_slider_test.dart b/test/playback_seek_slider_test.dart new file mode 100644 index 00000000..39b4f1d8 --- /dev/null +++ b/test/playback_seek_slider_test.dart @@ -0,0 +1,115 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; + +void main() { + testWidgets('scrubbing previews locally and commits only the final target', ( + tester, + ) async { + final seeks = []; + final completion = Completer(); + Widget app(Duration position) => MaterialApp( + home: Scaffold( + body: PlaybackSeekSlider( + position: position, + duration: const Duration(seconds: 100), + onSeek: (target) { + seeks.add(target); + return completion.future; + }, + ), + ), + ); + + await tester.pumpWidget(app(const Duration(seconds: 10))); + final bounds = tester.getRect(find.byType(Slider)); + final gesture = await tester.startGesture( + Offset(bounds.left + bounds.width * 0.3, bounds.center.dy), + ); + await gesture.moveBy(Offset(bounds.width * 0.3, 0)); + await tester.pump(); + final preview = tester.widget(find.byType(Slider)).value; + expect(preview, greaterThan(50000)); + expect(seeks, isEmpty); + + await tester.pumpWidget(app(const Duration(seconds: 11))); + expect(tester.widget(find.byType(Slider)).value, preview); + await gesture.up(); + await tester.pump(); + expect(seeks, [Duration(milliseconds: preview.round())]); + expect(tester.widget(find.byType(Slider)).value, preview); + + await tester.pumpWidget(app(seeks.single)); + completion.complete(); + await tester.pump(); + await tester.pump(); + expect( + tester.widget(find.byType(Slider)).value, + seeks.single.inMilliseconds.toDouble(), + ); + }); + + testWidgets('an earlier seek completion does not erase a new drag', ( + tester, + ) async { + final completions = >[]; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlaybackSeekSlider( + position: Duration.zero, + duration: const Duration(seconds: 100), + onSeek: (_) { + final completion = Completer(); + completions.add(completion); + return completion.future; + }, + ), + ), + ), + ); + final bounds = tester.getRect(find.byType(Slider)); + await tester.tapAt(Offset(bounds.center.dx, bounds.center.dy)); + await tester.pump(); + expect(completions, hasLength(1)); + + final gesture = await tester.startGesture( + Offset(bounds.left + bounds.width * 0.8, bounds.center.dy), + ); + await tester.pump(); + final preview = tester.widget(find.byType(Slider)).value; + completions.first.complete(); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byType(Slider)).value, preview); + await gesture.up(); + expect(completions, hasLength(2)); + completions.last.complete(); + await tester.pump(); + await tester.pump(); + }); + + testWidgets('unknown duration disables seek and clamps stale position', ( + tester, + ) async { + final seeks = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlaybackSeekSlider( + position: const Duration(minutes: 2), + duration: Duration.zero, + onSeek: (target) async => seeks.add(target), + ), + ), + ), + ); + final slider = tester.widget(find.byType(Slider)); + expect(slider.value, 0); + expect(slider.onChanged, isNull); + await tester.tap(find.byType(Slider)); + expect(seeks, isEmpty); + }); +}