perf(player): commit seek gestures once after scrubbing

This commit is contained in:
zarzet
2026-09-17 02:59:21 +07:00
parent a064e96c37
commit fd777c34d9
3 changed files with 175 additions and 15 deletions
+7 -15
View File
@@ -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<NowPlayingScreen> {
),
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(
+53
View File
@@ -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<void> Function(Duration) onSeek;
const PlaybackSeekSlider({
super.key,
required this.position,
required this.duration,
required this.onSeek,
});
@override
State<PlaybackSeekSlider> createState() => _PlaybackSeekSliderState();
}
class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
double? _previewMs;
int _gestureGeneration = 0;
Future<void> _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,
);
}
}
+115
View File
@@ -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 = <Duration>[];
final completion = Completer<void>();
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<Slider>(find.byType(Slider)).value;
expect(preview, greaterThan(50000));
expect(seeks, isEmpty);
await tester.pumpWidget(app(const Duration(seconds: 11)));
expect(tester.widget<Slider>(find.byType(Slider)).value, preview);
await gesture.up();
await tester.pump();
expect(seeks, [Duration(milliseconds: preview.round())]);
expect(tester.widget<Slider>(find.byType(Slider)).value, preview);
await tester.pumpWidget(app(seeks.single));
completion.complete();
await tester.pump();
await tester.pump();
expect(
tester.widget<Slider>(find.byType(Slider)).value,
seeks.single.inMilliseconds.toDouble(),
);
});
testWidgets('an earlier seek completion does not erase a new drag', (
tester,
) async {
final completions = <Completer<void>>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: PlaybackSeekSlider(
position: Duration.zero,
duration: const Duration(seconds: 100),
onSeek: (_) {
final completion = Completer<void>();
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<Slider>(find.byType(Slider)).value;
completions.first.complete();
await tester.pump();
await tester.pump();
expect(tester.widget<Slider>(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 = <Duration>[];
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<Slider>(find.byType(Slider));
expect(slider.value, 0);
expect(slider.onChanged, isNull);
await tester.tap(find.byType(Slider));
expect(seeks, isEmpty);
});
}