feat(lyrics): animate live previews while scrubbing playback

This commit is contained in:
zarzet committed 2026-09-26 01:31:31 +07:00
1 parent 55a390d679
commit 8e7e71827c
6 files changed
+639 -113

No files matched your search

+3 -1
View File
@@ -11,16 +11,18 @@ class LyricGapIndicator extends ConsumerWidget {
required this.start,
required this.end,
required this.color,
this.position,
});
final Duration start;
final Duration end;
final Color color;
final Duration? position;
@override
Widget build(BuildContext context, WidgetRef ref) {
final progress = syncedLyricSegmentProgress(
position: ref.watch(playbackPositionProvider),
position: position ?? ref.watch(playbackPositionProvider),
start: start,
end: end,
);
+44 -6
View File
@@ -1,5 +1,6 @@
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/mornye_player_slider.dart';
/// Previews scrubbing locally and seeks once when the gesture ends.
@@ -7,12 +8,14 @@ class PlaybackSeekSlider extends StatefulWidget {
final Duration position;
final Duration duration;
final Future<void> Function(Duration) onSeek;
final PlaybackSeekPreview? preview;
const PlaybackSeekSlider({
super.key,
required this.position,
required this.duration,
required this.onSeek,
this.preview,
});
@override
@@ -22,18 +25,53 @@ class PlaybackSeekSlider extends StatefulWidget {
class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
double? _previewMs;
int _gestureGeneration = 0;
Object? _previewSession;
void _start(double value) {
_gestureGeneration++;
_previewSession = widget.preview?.begin(
Duration(milliseconds: value.round()),
);
}
void _preview(double value) {
setState(() => _previewMs = value);
final session = _previewSession;
if (session != null) {
widget.preview?.update(session, Duration(milliseconds: value.round()));
}
}
Future<void> _adjust(double value) {
_start(value);
_preview(value);
return _commit(value);
}
Future<void> _commit(double value) async {
final generation = _gestureGeneration;
final preview = widget.preview;
final session = _previewSession;
try {
await widget.onSeek(Duration(milliseconds: value.round()));
} finally {
preview?.end(session);
if (mounted && generation == _gestureGeneration) {
setState(() => _previewMs = null);
}
}
}
@override
void dispose() {
final preview = widget.preview;
final session = _previewSession;
// Rotation or track replacement can remove this slider during layout.
// Notify surviving lyrics/time labels after that frame has finished.
WidgetsBinding.instance.addPostFrameCallback((_) => preview?.end(session));
super.dispose();
}
@override
Widget build(BuildContext context) {
final durationMs = widget.duration.inMilliseconds;
@@ -58,10 +96,10 @@ class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
? percentage((currentMs - 5000).clamp(0, maxMs).toDouble())
: null,
onIncrease: enabled
? () => _commit((currentMs + 5000).clamp(0, maxMs).toDouble())
? () => _adjust((currentMs + 5000).clamp(0, maxMs).toDouble())
: null,
onDecrease: enabled
? () => _commit((currentMs - 5000).clamp(0, maxMs).toDouble())
? () => _adjust((currentMs - 5000).clamp(0, maxMs).toDouble())
: null,
child: IgnorePointer(
ignoring: !enabled,
@@ -72,8 +110,8 @@ class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
SliderTheme.of(context).inactiveTrackColor ?? Colors.white12,
value: enabled ? currentMs : 0,
max: maxMs,
onChangeStart: (_) => _gestureGeneration++,
onChanged: (value) => setState(() => _previewMs = value),
onChangeStart: _start,
onChanged: _preview,
onChangeEnd: _commit,
),
),
@@ -88,8 +126,8 @@ class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
)
: 0,
max: maxMs,
onChangeStart: enabled ? (_) => _gestureGeneration++ : null,
onChanged: enabled ? (value) => setState(() => _previewMs = value) : null,
onChangeStart: enabled ? _start : null,
onChanged: enabled ? _preview : null,
onChangeEnd: enabled ? _commit : null,
);
}