fix(player): restore playback position reliably

This commit is contained in:
zarzet
2026-07-29 21:03:11 +07:00
parent 43a8d08851
commit 04a8700a65
6 changed files with 213 additions and 53 deletions
+19 -2
View File
@@ -28,6 +28,16 @@ final playbackPlayingProvider = Provider<bool>((ref) {
); );
}); });
final playbackLoadingProvider = Provider<bool>((ref) {
return ref.watch(
playbackStateProvider.select((state) {
final processingState = state.value?.processingState;
return processingState == AudioProcessingState.loading ||
processingState == AudioProcessingState.buffering;
}),
);
});
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) { final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
return musicPlayerQueueEvents(); return musicPlayerQueueEvents();
}); });
@@ -89,10 +99,17 @@ class MusicPlayerController {
Future<void> previous() async => _handler?.skipToPrevious(); Future<void> previous() async => _handler?.skipToPrevious();
Future<void> togglePlayPause(bool isPlaying) async { Future<void> togglePlayPause(bool isPlaying) async {
final handler = _handler;
if (handler == null) return;
final processingState = handler.playbackState.value.processingState;
if (processingState == AudioProcessingState.loading ||
processingState == AudioProcessingState.buffering) {
return;
}
if (isPlaying) { if (isPlaying) {
await pause(); await handler.pause();
} else { } else {
await play(); await handler.play();
} }
} }
+2
View File
@@ -133,6 +133,8 @@ class _MainShellState extends ConsumerState<MainShell>
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _initialSafRepairComplete) { if (state == AppLifecycleState.resumed && _initialSafRepairComplete) {
unawaited(_repairSafAccessIfNeeded()); unawaited(_repairSafAccessIfNeeded());
} else if (state == AppLifecycleState.paused) {
unawaited(persistCurrentPlaybackSession());
} }
} }
+10 -2
View File
@@ -1005,6 +1005,7 @@ class _PlaybackControls extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch(playbackPositionProvider); final position = ref.watch(playbackPositionProvider);
final isPlaying = ref.watch(playbackPlayingProvider); final isPlaying = ref.watch(playbackPlayingProvider);
final isLoading = ref.watch(playbackLoadingProvider);
final shuffleOn = ref.watch( final shuffleOn = ref.watch(
playbackStateProvider.select( playbackStateProvider.select(
(s) => s.value?.shuffleMode == AudioServiceShuffleMode.all, (s) => s.value?.shuffleMode == AudioServiceShuffleMode.all,
@@ -1116,8 +1117,15 @@ class _PlaybackControls extends ConsumerWidget {
iconSize: 44, iconSize: 44,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
color: colorScheme.onPrimary, color: colorScheme.onPrimary,
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow), icon: isLoading
onPressed: () => controller.togglePlayPause(isPlaying), ? const SizedBox.square(
dimension: 32,
child: CircularProgressIndicator(strokeWidth: 3),
)
: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: isLoading
? null
: () => controller.togglePlayPause(isPlaying),
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
+148 -47
View File
@@ -176,6 +176,19 @@ Map<String, dynamic> mergePlaybackFileMetadata(
return merged; return merged;
} }
/// Returns a safe source-start position for restored playback. A completed
/// snapshot starts over instead of immediately completing again on resume.
Duration normalizedPlaybackResumePosition(
Duration position, {
Duration? duration,
}) {
if (position <= Duration.zero) return Duration.zero;
if (duration != null && duration > Duration.zero && position >= duration) {
return Duration.zero;
}
return position;
}
class MusicPlayerHandler extends BaseAudioHandler class MusicPlayerHandler extends BaseAudioHandler
with QueueHandler, SeekHandler { with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer(playerId: 'music-player'); final AudioPlayer _player = AudioPlayer(playerId: 'music-player');
@@ -192,6 +205,8 @@ class MusicPlayerHandler extends BaseAudioHandler
String? _activeResolvedPath; String? _activeResolvedPath;
bool _disposed = false; bool _disposed = false;
bool _initialized = false; bool _initialized = false;
bool _sourceReady = false;
Future<void>? _activePlayOperation;
bool _shuffle = false; bool _shuffle = false;
AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none; AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none;
@@ -209,9 +224,12 @@ class MusicPlayerHandler extends BaseAudioHandler
int _switchingGeneration = 0; int _switchingGeneration = 0;
Duration _lastBroadcastPosition = Duration.zero; Duration _lastBroadcastPosition = Duration.zero;
DateTime? _lastPositionBroadcastAt; DateTime? _lastPositionBroadcastAt;
DateTime? _lastPeriodicPersistAt;
Future<void> _sessionWriteTail = Future<void>.value();
static const Duration _positionBroadcastInterval = Duration( static const Duration _positionBroadcastInterval = Duration(
milliseconds: 500, milliseconds: 500,
); );
static const Duration _positionPersistInterval = Duration(seconds: 10);
static const int _maxResolvedPathCacheEntries = 64; static const int _maxResolvedPathCacheEntries = 64;
MusicPlayerHandler() { MusicPlayerHandler() {
@@ -243,7 +261,7 @@ class MusicPlayerHandler extends BaseAudioHandler
} }
_broadcastState(playerState: state); _broadcastState(playerState: state);
}), }),
_player.onPositionChanged.listen(_broadcastPosition), _player.onPositionChanged.listen(_handlePositionChanged),
_player.onDurationChanged.listen((duration) { _player.onDurationChanged.listen((duration) {
final current = mediaItem.value; final current = mediaItem.value;
if (current != null && duration > Duration.zero) { if (current != null && duration > Duration.zero) {
@@ -329,7 +347,7 @@ class MusicPlayerHandler extends BaseAudioHandler
// Force the UI/notification to reflect the pause even if the engine does // Force the UI/notification to reflect the pause even if the engine does
// not emit a state-change event on focus loss. // not emit a state-change event on focus loss.
_broadcastState(playerState: PlayerState.paused); _broadcastState(playerState: PlayerState.paused);
_persistSession(position: await _currentPositionForPersist()); await _persistSession(position: await _currentPositionForPersist());
} }
Future<void> _activateAudioSession() async { Future<void> _activateAudioSession() async {
@@ -405,6 +423,26 @@ class MusicPlayerHandler extends BaseAudioHandler
playbackState.add(playbackState.value.copyWith(updatePosition: position)); playbackState.add(playbackState.value.copyWith(updatePosition: position));
} }
void _handlePositionChanged(Duration position) {
_broadcastPosition(position);
if (_restoringSession ||
_player.state != PlayerState.playing ||
_media.isEmpty ||
_index < 0 ||
_index >= _media.length) {
return;
}
final now = DateTime.now();
final lastPersistAt = _lastPeriodicPersistAt;
if (lastPersistAt != null &&
now.difference(lastPersistAt) < _positionPersistInterval) {
return;
}
_lastPeriodicPersistAt = now;
unawaited(_persistSession(position: position));
}
// ReplayGain normalization: resolved path -> volume multiplier. // ReplayGain normalization: resolved path -> volume multiplier.
final Map<String, double> _normalizationVolumeCache = {}; final Map<String, double> _normalizationVolumeCache = {};
@@ -533,18 +571,27 @@ class MusicPlayerHandler extends BaseAudioHandler
} }
} }
Future<void> _enqueueSessionWrite(Future<void> Function() operation) {
final queued = _sessionWriteTail.then((_) async {
try {
await operation();
} catch (e) {
_log.w('Failed to update persisted playback session: $e');
}
});
_sessionWriteTail = queued;
return queued;
}
/// Persists queue, current index, and position so a killed process can /// Persists queue, current index, and position so a killed process can
/// restore the session paused on next launch. Writes only on discrete /// restore the session paused on next launch. Position updates are throttled
/// events (pause, track change, queue mutation), never on position ticks. /// by [_handlePositionChanged], while lifecycle/pause writes flush exactly.
void _persistSession({Duration? position}) { Future<void> _persistSession({Duration? position}) {
if (_restoringSession) return; if (_restoringSession) return Future<void>.value();
if (_media.isEmpty || _index < 0 || _index >= _media.length) { if (_media.isEmpty || _index < 0 || _index >= _media.length) {
unawaited( return _enqueueSessionWrite(
AppStateDatabase.instance.clearPlaybackSession().catchError((Object e) { AppStateDatabase.instance.clearPlaybackSession,
_log.w('Failed to clear playback session: $e');
}),
); );
return;
} }
final session = <String, dynamic>{ final session = <String, dynamic>{
'version': 1, 'version': 1,
@@ -554,16 +601,16 @@ class MusicPlayerHandler extends BaseAudioHandler
'shuffle': _shuffle, 'shuffle': _shuffle,
'repeat': _repeatMode.name, 'repeat': _repeatMode.name,
}; };
unawaited( return _enqueueSessionWrite(
AppStateDatabase.instance.savePlaybackSession(session).catchError(( () => AppStateDatabase.instance.savePlaybackSession(session),
Object e,
) {
_log.w('Failed to persist playback session: $e');
}),
); );
} }
Future<Duration> _currentPositionForPersist() async { Future<Duration> _currentPositionForPersist() async {
// During restore/source preparation the engine may report a stale zero (or
// the previous source). The broadcast position already represents the
// intended start point for the current queue item.
if (!_sourceReady) return playbackState.value.position;
try { try {
final position = await _player.getCurrentPosition(); final position = await _player.getCurrentPosition();
if (position != null) return position; if (position != null) return position;
@@ -571,9 +618,19 @@ class MusicPlayerHandler extends BaseAudioHandler
return playbackState.value.position; return playbackState.value.position;
} }
/// Flushes the live engine position and every older queued session write.
/// Called when the app leaves the foreground so a normal restart resumes at
/// the latest audible point rather than the beginning of the track.
Future<void> persistCurrentSession() async {
if (_media.isEmpty || _index < 0 || _index >= _media.length) return;
final position = await _currentPositionForPersist();
_lastPeriodicPersistAt = DateTime.now();
await _persistSession(position: position);
}
/// Restores a persisted session paused: queue and current track become /// Restores a persisted session paused: queue and current track become
/// visible (mini player included) without loading any source; the first /// visible (mini player included) without loading any source; the first
/// play() starts the track and seeks to the saved position. /// play() prepares the source directly at the saved position.
Future<void> restoreSession({ Future<void> restoreSession({
required List<PlayableMedia> items, required List<PlayableMedia> items,
required int index, required int index,
@@ -596,6 +653,8 @@ class MusicPlayerHandler extends BaseAudioHandler
_shuffle = shuffle; _shuffle = shuffle;
_repeatMode = repeatMode; _repeatMode = repeatMode;
_pendingRestorePosition = position > Duration.zero ? position : null; _pendingRestorePosition = position > Duration.zero ? position : null;
_sourceReady = false;
_lastPeriodicPersistAt = null;
queue.add(List<MediaItem>.unmodifiable(_queueItems)); queue.add(List<MediaItem>.unmodifiable(_queueItems));
mediaItem.add(_media[_index].toMediaItem()); mediaItem.add(_media[_index].toMediaItem());
if (position > Duration.zero) { if (position > Duration.zero) {
@@ -655,7 +714,7 @@ class MusicPlayerHandler extends BaseAudioHandler
queue.add(List<MediaItem>.unmodifiable(_queueItems)); queue.add(List<MediaItem>.unmodifiable(_queueItems));
_broadcastState(); _broadcastState();
_persistSession(position: playbackState.value.position); unawaited(_persistSession(position: playbackState.value.position));
} }
Future<void> enqueueAll( Future<void> enqueueAll(
@@ -681,7 +740,7 @@ class MusicPlayerHandler extends BaseAudioHandler
} }
queue.add(List<MediaItem>.unmodifiable(_queueItems)); queue.add(List<MediaItem>.unmodifiable(_queueItems));
_broadcastState(); _broadcastState();
_persistSession(position: playbackState.value.position); unawaited(_persistSession(position: playbackState.value.position));
} }
void moveQueueItem(int oldIndex, int newIndex) { void moveQueueItem(int oldIndex, int newIndex) {
@@ -712,13 +771,21 @@ class MusicPlayerHandler extends BaseAudioHandler
queue.add(List<MediaItem>.unmodifiable(_queueItems)); queue.add(List<MediaItem>.unmodifiable(_queueItems));
_broadcastState(); _broadcastState();
_persistSession(position: playbackState.value.position); unawaited(_persistSession(position: playbackState.value.position));
} }
Future<void> _playIndex(int index, {bool recordHistory = true}) async { Future<void> _playIndex(
int index, {
bool recordHistory = true,
Duration startPosition = Duration.zero,
}) async {
if (index < 0 || index >= _media.length) return; if (index < 0 || index >= _media.length) return;
// Any explicit track start supersedes a pending restore position. // A normal track change supersedes a pending restore. A restored start
_pendingRestorePosition = null; // keeps it until the source is actually ready so a transient failure can
// be retried from the same position.
if (startPosition <= Duration.zero) {
_pendingRestorePosition = null;
}
final generation = ++_playRequestGeneration; final generation = ++_playRequestGeneration;
_index = index; _index = index;
_pausedByInterruption = false; _pausedByInterruption = false;
@@ -739,9 +806,14 @@ class MusicPlayerHandler extends BaseAudioHandler
} }
final media = _media[index]; final media = _media[index];
final effectiveStartPosition = normalizedPlaybackResumePosition(
startPosition,
duration: media.duration,
);
mediaItem.add(media.toMediaItem()); mediaItem.add(media.toMediaItem());
_lastBroadcastPosition = Duration.zero; _lastBroadcastPosition = Duration.zero;
_lastPositionBroadcastAt = null; _lastPositionBroadcastAt = null;
_broadcastPosition(effectiveStartPosition, force: true);
// Claim the playing state up front (while the app is still in the // Claim the playing state up front (while the app is still in the
// foreground window) so audio_service can start its foreground service // foreground window) so audio_service can start its foreground service
// before the async source resolve below. // before the async source resolve below.
@@ -772,11 +844,19 @@ class MusicPlayerHandler extends BaseAudioHandler
await _activateAudioSession(); await _activateAudioSession();
if (!_isCurrentPlayRequest(generation, media)) return; if (!_isCurrentPlayRequest(generation, media)) return;
await _player.stop(); await _player.stop();
_sourceReady = false;
if (!_isCurrentPlayRequest(generation, media)) return; if (!_isCurrentPlayRequest(generation, media)) return;
await _player.setVolume(normalizationVolume); await _player.setVolume(normalizationVolume);
if (!_isCurrentPlayRequest(generation, media)) return; if (!_isCurrentPlayRequest(generation, media)) return;
await _player.play(DeviceFileSource(resolved)); await _player.play(
DeviceFileSource(resolved),
position: effectiveStartPosition > Duration.zero
? effectiveStartPosition
: null,
);
if (!_isCurrentPlayRequest(generation, media)) return; if (!_isCurrentPlayRequest(generation, media)) return;
_sourceReady = true;
_pendingRestorePosition = null;
_activeResolvedPath = media.isContentUri ? resolved : null; _activeResolvedPath = media.isContentUri ? resolved : null;
await _cleanupPendingResolvedPaths(); await _cleanupPendingResolvedPaths();
// Plain file paths were already published before loading. Re-publishing // Plain file paths were already published before loading. Re-publishing
@@ -786,15 +866,17 @@ class MusicPlayerHandler extends BaseAudioHandler
if (media.isContentUri) { if (media.isContentUri) {
mediaItem.add(media.toMediaItem(resolvedSource: resolved)); mediaItem.add(media.toMediaItem(resolvedSource: resolved));
} }
_broadcastPosition(Duration.zero, force: true); _broadcastPosition(effectiveStartPosition, force: true);
_broadcastState(playerState: PlayerState.playing); _broadcastState(playerState: PlayerState.playing);
_persistSession(); _lastPeriodicPersistAt = DateTime.now();
unawaited(_persistSession(position: effectiveStartPosition));
_log.i('Playing: ${media.title}'); _log.i('Playing: ${media.title}');
// Some files do not emit onDurationChanged reliably (stuck at 0:00); // Some files do not emit onDurationChanged reliably (stuck at 0:00);
// poll the engine for the real duration as a fallback. // poll the engine for the real duration as a fallback.
unawaited(_ensureDurationKnown(index, generation)); unawaited(_ensureDurationKnown(index, generation));
} catch (e) { } catch (e) {
if (!_isCurrentPlayRequest(generation, media)) return; if (!_isCurrentPlayRequest(generation, media)) return;
_sourceReady = false;
_log.e('Playback failed for ${media.title}: $e'); _log.e('Playback failed for ${media.title}: $e');
_broadcastState(playerState: PlayerState.stopped); _broadcastState(playerState: PlayerState.stopped);
} finally { } finally {
@@ -886,23 +968,37 @@ class MusicPlayerHandler extends BaseAudioHandler
@override @override
Future<void> play() async { Future<void> play() async {
final activeOperation = _activePlayOperation;
if (activeOperation != null) {
await activeOperation;
return;
}
final operation = _playInternal();
_activePlayOperation = operation;
try {
await operation;
} finally {
if (identical(_activePlayOperation, operation)) {
_activePlayOperation = null;
}
}
}
Future<void> _playInternal() async {
_pausedByInterruption = false; _pausedByInterruption = false;
_interruptionActive = false; _interruptionActive = false;
_userPaused = false; _userPaused = false;
if ((_player.state == PlayerState.stopped || if ((!_sourceReady ||
_player.state == PlayerState.stopped ||
_player.state == PlayerState.completed) && _player.state == PlayerState.completed) &&
_index >= 0 && _index >= 0 &&
_index < _media.length) { _index < _media.length) {
final restoreIndex = _index; await _playIndex(
final restorePosition = _pendingRestorePosition; _index,
await _playIndex(_index, recordHistory: false); recordHistory: false,
if (restorePosition != null && _index == restoreIndex) { startPosition: _pendingRestorePosition ?? Duration.zero,
try { );
await seek(restorePosition);
} catch (e) {
_log.w('Failed to seek to restored position: $e');
}
}
return; return;
} }
await _activateAudioSession(); await _activateAudioSession();
@@ -919,7 +1015,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_pausedByInterruption = false; _pausedByInterruption = false;
await _player.pause(); await _player.pause();
_broadcastState(playerState: PlayerState.paused); _broadcastState(playerState: PlayerState.paused);
_persistSession(position: await _currentPositionForPersist()); await _persistSession(position: await _currentPositionForPersist());
} }
@override @override
@@ -933,7 +1029,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_shuffle = shuffleMode == AudioServiceShuffleMode.all; _shuffle = shuffleMode == AudioServiceShuffleMode.all;
_broadcastState(); _broadcastState();
if (_media.isNotEmpty && _index >= 0) { if (_media.isNotEmpty && _index >= 0) {
_persistSession(position: playbackState.value.position); unawaited(_persistSession(position: playbackState.value.position));
} }
} }
@@ -945,7 +1041,7 @@ class MusicPlayerHandler extends BaseAudioHandler
: repeatMode; : repeatMode;
_broadcastState(); _broadcastState();
if (_media.isNotEmpty && _index >= 0) { if (_media.isNotEmpty && _index >= 0) {
_persistSession(position: playbackState.value.position); unawaited(_persistSession(position: playbackState.value.position));
} }
} }
@@ -955,6 +1051,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_switchingGeneration = 0; _switchingGeneration = 0;
_userPaused = true; _userPaused = true;
await _player.stop(); await _player.stop();
_sourceReady = false;
_activeResolvedPath = null; _activeResolvedPath = null;
await _cleanupPendingResolvedPaths(); await _cleanupPendingResolvedPaths();
_index = -1; _index = -1;
@@ -965,11 +1062,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_playHistory.clear(); _playHistory.clear();
_pendingRestorePosition = null; _pendingRestorePosition = null;
// An explicit stop ends the session for good; nothing to restore later. // An explicit stop ends the session for good; nothing to restore later.
unawaited( await _enqueueSessionWrite(AppStateDatabase.instance.clearPlaybackSession);
AppStateDatabase.instance.clearPlaybackSession().catchError((Object e) {
_log.w('Failed to clear playback session: $e');
}),
);
// A stopped session has no current item; this also hides the mini player. // A stopped session has no current item; this also hides the mini player.
mediaItem.add(null); mediaItem.add(null);
_broadcastState(playerState: PlayerState.stopped); _broadcastState(playerState: PlayerState.stopped);
@@ -1092,7 +1185,7 @@ class MusicPlayerHandler extends BaseAudioHandler
} else { } else {
_index = (_index - removedBeforeCurrent).clamp(0, _media.length - 1); _index = (_index - removedBeforeCurrent).clamp(0, _media.length - 1);
_broadcastState(); _broadcastState();
_persistSession(position: playbackState.value.position); unawaited(_persistSession(position: playbackState.value.position));
} }
} }
@@ -1103,6 +1196,7 @@ class MusicPlayerHandler extends BaseAudioHandler
await sub.cancel(); await sub.cancel();
} }
_subscriptions.clear(); _subscriptions.clear();
_sourceReady = false;
await _player.dispose(); await _player.dispose();
_activeResolvedPath = null; _activeResolvedPath = null;
final tempPaths = <String>{ final tempPaths = <String>{
@@ -1127,6 +1221,13 @@ MusicPlayerHandler? get musicPlayerHandler => _handler;
Future<void> Function()? musicPlayerExclusiveAudioHook; Future<void> Function()? musicPlayerExclusiveAudioHook;
/// Flushes the current playback position if the player has been initialized.
Future<void> persistCurrentPlaybackSession() async {
final handler = _handler;
if (handler == null) return;
await handler.persistCurrentSession();
}
Future<MusicPlayerHandler> initMusicPlayer() async { Future<MusicPlayerHandler> initMusicPlayer() async {
if (_handler != null) return _handler!; if (_handler != null) return _handler!;
final existingFuture = _initFuture; final existingFuture = _initFuture;
+12 -2
View File
@@ -24,6 +24,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
if (mediaItem == null) return const SizedBox.shrink(); if (mediaItem == null) return const SizedBox.shrink();
final isPlaying = ref.watch(playbackPlayingProvider); final isPlaying = ref.watch(playbackPlayingProvider);
final isLoading = ref.watch(playbackLoadingProvider);
if (mediaItem.id == _dismissedItemId && !isPlaying) { if (mediaItem.id == _dismissedItemId && !isPlaying) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -112,8 +113,17 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
), ),
), ),
IconButton( IconButton(
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow), icon: isLoading
onPressed: () => controller.togglePlayPause(isPlaying), ? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
),
)
: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: isLoading
? null
: () => controller.togglePlayPause(isPlaying),
), ),
IconButton( IconButton(
icon: const Icon(Icons.skip_next), icon: const Icon(Icons.skip_next),
@@ -45,4 +45,26 @@ void main() {
expect(merged['format'], 'flac'); expect(merged['format'], 'flac');
expect(merged['title'], 'Track'); expect(merged['title'], 'Track');
}); });
test('restored playback starts at its persisted position', () {
const savedPosition = Duration(minutes: 1, seconds: 23);
expect(
normalizedPlaybackResumePosition(
savedPosition,
duration: const Duration(minutes: 4),
),
savedPosition,
);
});
test('a completed persisted position restarts safely from zero', () {
expect(
normalizedPlaybackResumePosition(
const Duration(minutes: 4),
duration: const Duration(minutes: 4),
),
Duration.zero,
);
});
} }