mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
perf(player): commit seek gestures once after scrubbing
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user