feat(lyrics): animate live previews while scrubbing playback

This commit is contained in:
zarzet
2026-09-26 01:31:31 +07:00
parent 55a390d679
commit 8e7e71827c
6 changed files with 639 additions and 113 deletions
+206 -106
View File
@@ -4,6 +4,7 @@ import 'dart:ui' show BoxHeightStyle, ImageFilter;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/services.dart' show SystemUiOverlayStyle;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -27,6 +28,7 @@ import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/isrc_utils.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/lyrics_timeline.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart';
@@ -355,19 +357,23 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final _motionArtworkKey = GlobalKey();
Map<String, Color> _artworkForeground = {};
final _artworkColorsChanged = ValueNotifier(0);
final _seekPreview = PlaybackSeekPreview();
@override
void initState() {
super.initState();
_mediaItemSub = ref.listenManual<AsyncValue<MediaItem?>>(
currentMediaItemProvider,
(previous, next) => _loadMetadataForItem(
next.value,
// When automatic playback advances while Lyrics is already visible,
// onPageChanged will not run again. Inspect an unresolved SAF URI now
// instead of leaving the new track with an empty Lyrics page.
inspectUnresolvedContentUri: _currentPage == 1,
),
(previous, next) {
if (previous?.value?.id != next.value?.id) _seekPreview.reset();
_loadMetadataForItem(
next.value,
// When automatic playback advances while Lyrics is already visible,
// onPageChanged will not run again. Inspect an unresolved SAF URI now
// instead of leaving the new track with an empty Lyrics page.
inspectUnresolvedContentUri: _currentPage == 1,
);
},
);
_lyricsPlayingSub = ref.listenManual<bool>(playbackPlayingProvider, (
previous,
@@ -402,6 +408,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
_lyricsPlayingSub?.close();
_pageController.dispose();
_artworkColorsChanged.dispose();
_seekPreview.dispose();
super.dispose();
}
@@ -1405,6 +1412,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
mediaId: mediaItem.id,
duration: mediaItem.duration ?? Duration.zero,
controller: controller,
seekPreview: _seekPreview,
colorScheme: foreground('controls'),
qualityLabel: _qualityLabel(),
compact: landscape,
@@ -1665,6 +1673,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
mediaId: mediaItem.id,
duration: mediaItem.duration ?? Duration.zero,
controller: controller,
seekPreview: _seekPreview,
colorScheme: colorScheme,
qualityLabel: _qualityLabel(),
),
@@ -1713,6 +1722,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
children: [
_SyncedLyricsView(
lyrics: _lyrics,
seekPreview: _seekPreview,
credits: _lyricsCredits(),
colorScheme: colorScheme,
isActive: isActive,
@@ -2540,6 +2550,7 @@ class _PlaybackControls extends ConsumerWidget {
final String mediaId;
final Duration duration;
final MusicPlayerController controller;
final PlaybackSeekPreview seekPreview;
final ColorScheme colorScheme;
final String? qualityLabel;
final bool compact;
@@ -2550,6 +2561,7 @@ class _PlaybackControls extends ConsumerWidget {
required this.mediaId,
required this.duration,
required this.controller,
required this.seekPreview,
required this.colorScheme,
required this.qualityLabel,
this.compact = false,
@@ -2579,84 +2591,90 @@ class _PlaybackControls extends ConsumerWidget {
);
return Column(
children: [
Consumer(
builder: (context, ref, _) {
final position = ref.watch(playbackPositionProvider);
final elapsedSeconds = position.inSeconds;
return Padding(
padding: EdgeInsets.symmetric(horizontal: mornye ? 28 : 16),
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
inactiveTrackColor: colorScheme.onSurface.withValues(
alpha: 0.18,
ValueListenableBuilder<Duration?>(
valueListenable: seekPreview,
builder: (context, preview, _) => Consumer(
builder: (context, ref, _) {
final position = ref.watch(playbackPositionProvider);
final elapsedSeconds = (preview ?? position).inSeconds;
return Padding(
padding: EdgeInsets.symmetric(horizontal: mornye ? 28 : 16),
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
inactiveTrackColor: colorScheme.onSurface.withValues(
alpha: 0.18,
),
thumbColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
// A 7dp thumb was hard to grab; 10dp with a 24dp overlay
// gives the drag gesture a full-size target.
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 10,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 24,
),
),
thumbColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
// A 7dp thumb was hard to grab; 10dp with a 24dp overlay
// gives the drag gesture a full-size target.
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 10,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 24,
child: PlaybackSeekSlider(
key: ValueKey(mediaId),
position: position,
duration: duration,
onSeek: controller.seek,
preview: seekPreview,
),
),
child: PlaybackSeekSlider(
key: ValueKey(mediaId),
position: position,
duration: duration,
onSeek: controller.seek,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: mornye ? 8 : 12),
child: Row(
children: [
if (mornye)
MornyePlaybackTime(
key: ValueKey('elapsed:$mediaId'),
seconds: elapsedSeconds,
style: timeStyle,
)
else
Text(formatClock(elapsedSeconds), style: timeStyle),
Expanded(
child: Center(
child: _QualityBadge(
label: qualityLabel,
colorScheme: colorScheme,
Padding(
padding: EdgeInsets.symmetric(
horizontal: mornye ? 8 : 12,
),
child: Row(
children: [
if (mornye)
MornyePlaybackTime(
key: ValueKey('elapsed:$mediaId'),
seconds: elapsedSeconds,
style: timeStyle,
)
else
Text(formatClock(elapsedSeconds), style: timeStyle),
Expanded(
child: Center(
child: _QualityBadge(
label: qualityLabel,
colorScheme: colorScheme,
),
),
),
),
if (mornye)
MornyePlaybackTime(
key: ValueKey('remaining:$mediaId'),
// Subtract whole seconds so both labels roll together,
// even when the track duration includes milliseconds.
seconds: (duration.inSeconds - elapsedSeconds)
.clamp(0, duration.inSeconds),
remaining: true,
style: timeStyle,
)
else
Text(
formatClock(duration.inSeconds),
style: timeStyle,
),
],
if (mornye)
MornyePlaybackTime(
key: ValueKey('remaining:$mediaId'),
// Subtract whole seconds so both labels roll together,
// even when the track duration includes milliseconds.
seconds: (duration.inSeconds - elapsedSeconds)
.clamp(0, duration.inSeconds),
remaining: true,
style: timeStyle,
)
else
Text(
formatClock(duration.inSeconds),
style: timeStyle,
),
],
),
),
),
],
),
);
},
],
),
);
},
),
),
SizedBox(height: compact ? 8 : transportTopPadding),
Padding(
@@ -2790,6 +2808,7 @@ class _PlaybackControls extends ConsumerWidget {
class _SyncedLyricsView extends ConsumerStatefulWidget {
final ParsedLyrics lyrics;
final ValueListenable<Duration?> seekPreview;
final _LyricsCredits? credits;
final ColorScheme colorScheme;
final bool isActive;
@@ -2798,6 +2817,7 @@ class _SyncedLyricsView extends ConsumerStatefulWidget {
const _SyncedLyricsView({
required this.lyrics,
required this.seekPreview,
this.credits,
required this.colorScheme,
required this.isActive,
@@ -2840,6 +2860,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
void initState() {
super.initState();
_resetLineKeys();
widget.seekPreview.addListener(_previewChanged);
}
@override
@@ -2851,6 +2872,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
@override
void didUpdateWidget(covariant _SyncedLyricsView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.seekPreview != widget.seekPreview) {
oldWidget.seekPreview.removeListener(_previewChanged);
widget.seekPreview.addListener(_previewChanged);
}
if (oldWidget.lyrics != widget.lyrics) {
_resetLineKeys();
}
@@ -2884,7 +2909,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_userScrollIdleTimer?.cancel();
_userScrolling = false;
final position = ref.read(playbackPositionProvider);
final position = _displayPosition;
_playing = ref.read(playbackPlayingProvider);
_loading = ref.read(playbackLoadingProvider);
_active = _activeIndexAt(position);
@@ -2896,6 +2921,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_positionSubscription = ref.listenManual<Duration>(
playbackPositionProvider,
(previous, next) {
if (widget.seekPreview.value != null) return;
final active = _activeIndexAt(next);
if (active != _active) _setActiveLine(active, position: next);
_scheduleNextLine(next);
@@ -2906,7 +2932,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
next,
) {
_playing = next;
final position = ref.read(playbackPositionProvider);
final position = _displayPosition;
_setActiveLine(_activeIndexAt(position), position: position);
_scheduleNextLine(position);
});
@@ -2915,13 +2941,34 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
next,
) {
_loading = next;
final position = ref.read(playbackPositionProvider);
final position = _displayPosition;
_setActiveLine(_activeIndexAt(position), position: position);
_scheduleNextLine(position);
});
}
Duration get _displayPosition =>
widget.seekPreview.value ?? ref.read(playbackPositionProvider);
void _previewChanged() {
if (!mounted || !widget.isActive) return;
final position = _displayPosition;
final wasUserScrolling = _userScrolling;
_userScrolling = false;
_userScrollIdleTimer?.cancel();
final active = _activeIndexAt(position);
if (active == _active && (wasUserScrolling || active < 0)) {
unawaited(_maybeAutoScroll(active));
}
_setActiveLine(active, position: position);
_scheduleNextLine(position);
}
int _activeIndexAt(Duration position) {
// Scrubbing is an explicit preview, even at zero or while audio buffers.
if (widget.seekPreview.value != null) {
return LyricsParser.activeIndex(_lines, position);
}
if (context.isMornye) {
// Read one transport snapshot: playing/loading derived providers can
// notify separately during the same playback event.
@@ -2944,13 +2991,18 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_activeTransitionPosition = position;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_maybeAutoScroll(active));
if (mounted && active == _active) unawaited(_maybeAutoScroll(active));
});
}
void _scheduleNextLine(Duration position) {
_lineBoundaryTimer?.cancel();
if (!widget.isActive || !_playing || _loading) return;
if (!widget.isActive ||
!_playing ||
_loading ||
widget.seekPreview.value != null) {
return;
}
final lines = _lines;
final dueIndex = syncedLyricsDueLineIndex(
@@ -2973,6 +3025,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
@override
void dispose() {
widget.seekPreview.removeListener(_previewChanged);
_positionSubscription?.close();
_playingSubscription?.close();
_loadingSubscription?.close();
@@ -3042,7 +3095,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
}
Future<void> _maybeAutoScroll(int index, {bool immediate = false}) async {
// A short intro may have no countdown row. Still return to the first
// upcoming lyric when the user scrubs back before any vocals.
if (index < 0 && widget.seekPreview.value != null) index = 0;
if (_userScrolling || index < 0 || !_scroll.hasClients) return;
final duration = immediate || MediaQuery.disableAnimationsOf(context)
? Duration.zero
: Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380);
final extents = _lineExtents;
if (context.isMornye && extents != null && index < extents.length) {
final position = _scroll.position;
@@ -3053,12 +3112,12 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
position.minScrollExtent,
position.maxScrollExtent,
);
if (immediate || MediaQuery.disableAnimationsOf(context)) {
if (duration == Duration.zero) {
_scroll.jumpTo(offset);
} else {
await _scroll.animateTo(
offset,
duration: const Duration(milliseconds: 380),
duration: duration,
curve: Curves.easeOutCubic,
);
}
@@ -3071,9 +3130,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
lineContext,
alignment: 0.5,
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
duration: immediate
? Duration.zero
: const Duration(milliseconds: 380),
duration: duration,
curve: Curves.easeOutCubic,
);
return;
@@ -3089,19 +3146,30 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
position.minScrollExtent,
position.maxScrollExtent,
);
await _scroll.animateTo(
clamped.toDouble(),
duration: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
);
if (!mounted || _userScrolling || index >= _lineKeys.length) return;
if (duration == Duration.zero) {
_scroll.jumpTo(clamped.toDouble());
} else {
await _scroll.animateTo(
clamped.toDouble(),
duration: duration,
curve: Curves.easeOutCubic,
);
}
if (!mounted ||
_userScrolling ||
index != _active ||
index >= _lineKeys.length) {
return;
}
final lineContext = _lineKeys[index].currentContext;
if (lineContext != null && lineContext.mounted) {
await Scrollable.ensureVisible(
lineContext,
alignment: 0.5,
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
duration: const Duration(milliseconds: 180),
duration: duration == Duration.zero
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
}
@@ -3228,12 +3296,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
alignment: mornye
? Alignment.centerLeft
: Alignment.center,
child: isActive && widget.isActive && !loading
? LyricGapIndicator(
key: ValueKey(line.time),
start: line.time,
end: line.end!,
color: color,
child:
isActive &&
widget.isActive &&
(!loading || widget.seekPreview.value != null)
? ValueListenableBuilder<Duration?>(
valueListenable: widget.seekPreview,
builder: (context, preview, _) =>
LyricGapIndicator(
key: ValueKey(line.time),
start: line.time,
end: line.end!,
color: color,
position: preview,
),
)
: null,
),
@@ -3251,6 +3327,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
colorScheme: widget.colorScheme,
animate: widget.isActive,
initialPosition: _activeTransitionPosition,
seekPreview: widget.seekPreview,
supplementVisibility: visibility,
);
} else {
@@ -3506,6 +3583,7 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
final ColorScheme colorScheme;
final bool animate;
final Duration initialPosition;
final ValueListenable<Duration?> seekPreview;
final Offset supplementVisibility;
const _WordHighlightedLyricLine({
@@ -3513,6 +3591,7 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
required this.colorScheme,
required this.animate,
required this.initialPosition,
required this.seekPreview,
required this.supplementVisibility,
});
@@ -3535,7 +3614,11 @@ class _WordHighlightedLyricLineState
bool _playing = false;
bool _loading = false;
bool get _shouldAnimate => widget.animate && _playing && !_loading;
bool get _shouldAnimate =>
widget.animate &&
_playing &&
!_loading &&
widget.seekPreview.value == null;
Duration _positionAt({required bool advance}) {
return interpolatedSyncedLyricsPosition(
@@ -3557,6 +3640,7 @@ class _WordHighlightedLyricLineState
vsync: this,
duration: const Duration(seconds: 1),
);
widget.seekPreview.addListener(_previewChanged);
_positionSubscription = ref.listenManual<Duration>(
playbackPositionProvider,
(previous, next) => _updateReportedPosition(next),
@@ -3575,6 +3659,11 @@ class _WordHighlightedLyricLineState
@override
void didUpdateWidget(covariant _WordHighlightedLyricLine oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.seekPreview != widget.seekPreview) {
oldWidget.seekPreview.removeListener(_previewChanged);
widget.seekPreview.addListener(_previewChanged);
_previewChanged();
}
if (oldWidget.line != widget.line ||
oldWidget.initialPosition != widget.initialPosition) {
_anchorAt(widget.initialPosition);
@@ -3588,7 +3677,14 @@ class _WordHighlightedLyricLineState
}
Duration _currentPosition() {
return _positionAt(advance: _shouldAnimate);
return widget.seekPreview.value ?? _positionAt(advance: _shouldAnimate);
}
void _previewChanged() {
if (!mounted) return;
_anchorAt(widget.seekPreview.value ?? ref.read(playbackPositionProvider));
_syncAnimationClock();
setState(() {});
}
void _anchorAt(Duration position) {
@@ -3597,7 +3693,7 @@ class _WordHighlightedLyricLineState
}
void _updateReportedPosition(Duration position) {
if (!mounted) return;
if (!mounted || widget.seekPreview.value != null) return;
final predicted = _currentPosition();
_anchorAt(
_shouldAnimate
@@ -3615,7 +3711,10 @@ class _WordHighlightedLyricLineState
final position = _currentPosition();
if (playing != null) _playing = playing;
if (loading != null) _loading = loading;
_anchorAt(_shouldAnimate ? position : ref.read(playbackPositionProvider));
_anchorAt(
widget.seekPreview.value ??
(_shouldAnimate ? position : ref.read(playbackPositionProvider)),
);
_syncAnimationClock();
setState(() {});
}
@@ -3645,6 +3744,7 @@ class _WordHighlightedLyricLineState
@override
void dispose() {
widget.seekPreview.removeListener(_previewChanged);
_positionSubscription?.close();
_playingSubscription?.close();
_loadingSubscription?.close();
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
/// A player-local display position while a seek gesture is in progress.
/// Session tokens prevent an old seek or removed slider clearing a newer drag.
class PlaybackSeekPreview extends ChangeNotifier
implements ValueListenable<Duration?> {
Duration? _value;
Object? _session;
bool _disposed = false;
@override
Duration? get value => _value;
Object begin(Duration position) {
final session = Object();
_session = session;
update(session, position);
return session;
}
void update(Object session, Duration position) {
if (_disposed || !identical(session, _session) || position == _value) {
return;
}
_value = position;
notifyListeners();
}
void end(Object? session) {
if (identical(session, _session)) reset();
}
void reset() {
if (_disposed) return;
_session = null;
if (_value == null) return;
_value = null;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+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,
);
}