Harden TTS player and add playback speed with replay caching.
Prevent premature player dismiss during chunked synthesis, disable unreliable system-TTS seek controls, fix chunk-local seeking, and cache model-TTS audio for immediate replay of the same message. Co-authored-by: Cursor <cursoragent@cursor.com>
onlypuppy7 committed
Jul 1, 2026 at 15:01 UTC
0fa510c3e9b38d0012bd74777a84030026d74b1f
2 files changed
+275
-129
lib/features/tts/providers/tts_providers.dart
+245
-123
@@ -36,6 +36,7 @@ class TtsState {
36
final Duration position;
37
final Duration duration;
38
final bool canSeek;
39
+ final double playbackSpeed;
40
41
const TtsState({
42
this.isSpeaking = false,
@@ -50,6 +51,7 @@ class TtsState {
51
this.position = Duration.zero,
52
this.duration = Duration.zero,
53
this.canSeek = false,
54
+ this.playbackSpeed = 1.0,
55
});
56
57
TtsState copyWith({
@@ -65,6 +67,7 @@ class TtsState {
67
Duration? position,
68
Duration? duration,
69
bool? canSeek,
70
+ double? playbackSpeed,
71
bool clearPlayingTarget = false,
72
bool resetProgress = false,
73
}) {
@@ -84,6 +87,7 @@ class TtsState {
87
position: resetProgress ? Duration.zero : (position ?? this.position),
88
duration: resetProgress ? Duration.zero : (duration ?? this.duration),
89
canSeek: canSeek ?? this.canSeek,
90
+ playbackSpeed: playbackSpeed ?? this.playbackSpeed,
91
);
92
}
93
}
@@ -136,6 +140,11 @@ class TtsNotifier extends Notifier<TtsState> {
140
String? _systemFullText;
141
Duration _systemSkippedOffset = Duration.zero;
142
DateTime? _systemSpeakStart;
143
+ int _systemSpeakGeneration = 0;
144
+
145
+ _MessageAudioCache? _messageAudioCache;
146
+
147
+ static const playbackSpeedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
148
149
AudioPlayer get player => _player;
150
Stream<Duration> get onPositionChanged => _player.positionStream;
@@ -156,9 +165,12 @@ class TtsNotifier extends Notifier<TtsState> {
165
_player.setAudioSources([]);
166
167
_playerCompleteSubscription = _player.playerStateStream.listen((playerState) {
159
- if (playerState.processingState == ProcessingState.completed) {
160
- _onPlaybackFinished();
161
- }
168
+ if (playerState.processingState != ProcessingState.completed) return;
169
+ if (_isStopping || state.isInitializing) return;
170
+ if (_currentSessionId != _playbackSessionId) return;
171
+ if (_nextPlaylistIndexToAdd < _chunks.length) return;
172
+ if (_lastEnqueuedChunk < _chunks.length - 1) return;
173
+ _onPlaybackFinished();
174
});
175
176
_currentIndexSubscription = _player.currentIndexStream.listen((index) {
@@ -308,12 +320,6 @@ class TtsNotifier extends Notifier<TtsState> {
320
if (!state.isSpeaking || state.isPreview) return;
321
322
if (state.activeEngine == EngineId.system) {
311
- final duration = _systemEstimatedDuration();
312
- state = state.copyWith(
313
- position: _systemEstimatedPosition(),
314
- duration: duration,
315
- canSeek: duration > Duration.zero,
316
- );
323
return;
324
}
325
@@ -322,7 +328,9 @@ class TtsNotifier extends Notifier<TtsState> {
328
state = state.copyWith(
329
position: position,
330
duration: duration,
325
- canSeek: !state.isInitializing && duration > Duration.zero,
331
+ canSeek: !state.isInitializing &&
332
+ duration > Duration.zero &&
333
+ _nextPlaylistIndexToAdd >= _chunks.length,
334
);
335
}
336
@@ -342,14 +350,6 @@ class TtsNotifier extends Notifier<TtsState> {
350
return pos > total ? total : pos;
351
}
352
345
- void _startSystemProgressTimer() {
346
- _systemProgressTimer?.cancel();
347
- _systemProgressTimer = Timer.periodic(
348
- const Duration(milliseconds: 250),
349
- (_) => _updatePlaybackProgress(),
350
- );
351
- }
352
-
353
void _stopSystemProgressTimer() {
354
_systemProgressTimer?.cancel();
355
_systemProgressTimer = null;
@@ -579,15 +579,13 @@ class TtsNotifier extends Notifier<TtsState> {
579
if (engineId == EngineId.system) {
580
_flutterTts ??= FlutterTts();
581
_resetSystemSeekState();
582
+ final speakGeneration = ++_systemSpeakGeneration;
583
_systemFullText = text;
584
_systemSkippedOffset = Duration.zero;
585
_systemSpeakStart = DateTime.now();
585
- // Clear any previous handlers to prevent stale callbacks
586
- _flutterTts!.setCompletionHandler(() {});
587
- _flutterTts!.setErrorHandler((_) {});
588
- _flutterTts!.setCancelHandler(() {});
586
state = state.copyWith(
587
isSpeaking: true,
588
+ isPaused: false,
589
isInitializing: false,
590
error: null,
591
activeEngine: EngineId.system,
@@ -595,51 +593,39 @@ class TtsNotifier extends Notifier<TtsState> {
593
playingMessageId: isPreview ? null : messageId,
594
playingConversationId: isPreview ? null : conversationId,
595
isPreview: isPreview,
598
- canSeek: !isPreview,
599
- duration: _systemEstimatedDuration(),
596
+ canSeek: false,
597
+ resetProgress: true,
598
);
601
- _startSystemProgressTimer();
599
try {
600
await _flutterTts!.setLanguage('en-US');
604
- await _flutterTts!.setSpeechRate(
605
- (settings.ttsSpeed * 0.5).clamp(0.0, 1.0),
606
- );
601
+ await _applySystemSpeechRate();
602
_flutterTts!.setCompletionHandler(() {
608
- if (!_isStopping) {
609
- _resetSystemSeekState();
610
- state = state.copyWith(
611
- isSpeaking: false,
612
- playingContent: null,
613
- clearPlayingTarget: true,
614
- resetProgress: true,
615
- canSeek: false,
616
- );
617
- }
603
+ if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
604
+ _resetSystemSeekState();
605
+ state = state.copyWith(
606
+ isSpeaking: false,
607
+ isPaused: false,
608
+ playingContent: null,
609
+ clearPlayingTarget: true,
610
+ resetProgress: true,
611
+ canSeek: false,
612
+ );
613
});
614
_flutterTts!.setErrorHandler((msg) {
620
- if (!_isStopping) {
621
- _resetSystemSeekState();
622
- state = state.copyWith(
623
- isSpeaking: false,
624
- playingContent: null,
625
- error: msg.toString(),
626
- clearPlayingTarget: true,
627
- resetProgress: true,
628
- canSeek: false,
629
- );
630
- }
615
+ if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
616
+ _resetSystemSeekState();
617
+ state = state.copyWith(
618
+ isSpeaking: false,
619
+ isPaused: false,
620
+ playingContent: null,
621
+ error: msg.toString(),
622
+ clearPlayingTarget: true,
623
+ resetProgress: true,
624
+ canSeek: false,
625
+ );
626
});
627
_flutterTts!.setCancelHandler(() {
633
- if (!_isStopping) {
634
- _resetSystemSeekState();
635
- state = state.copyWith(
636
- isSpeaking: false,
637
- playingContent: null,
638
- clearPlayingTarget: true,
639
- resetProgress: true,
640
- canSeek: false,
641
- );
642
- }
628
+ if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
629
});
630
await _flutterTts!.awaitSpeakCompletion(true);
631
await _flutterTts!.speak(text);
@@ -647,6 +633,7 @@ class TtsNotifier extends Notifier<TtsState> {
633
_resetSystemSeekState();
634
state = state.copyWith(
635
isSpeaking: false,
636
+ isPaused: false,
637
error: 'System TTS unavailable',
638
playingContent: null,
639
clearPlayingTarget: true,
@@ -658,6 +645,23 @@ class TtsNotifier extends Notifier<TtsState> {
645
return;
646
}
647
648
+ if (!isPreview &&
649
+ messageId != null &&
650
+ _messageAudioCache != null &&
651
+ _messageAudioCache!.messageId == messageId &&
652
+ _messageAudioCache!.text == text &&
653
+ _messageAudioCache!.engine == engineId &&
654
+ _messageAudioCache!.voice == resolvedVoiceId) {
655
+ await _playFromCache(
656
+ isPreview: isPreview,
657
+ messageId: messageId,
658
+ conversationId: conversationId,
659
+ engineId: engineId,
660
+ text: text,
661
+ );
662
+ return;
663
+ }
664
+
665
_resetProgress();
666
_chunks = _splitText(text);
667
_currentChunkIndex = 0;
@@ -694,9 +698,10 @@ class TtsNotifier extends Notifier<TtsState> {
698
699
try {
700
await _initEngine(engineId, voiceId: resolvedVoiceId);
701
+ await _player.setSpeed(state.playbackSpeed);
702
state = state.copyWith(
703
isInitializing: false,
699
- canSeek: true,
704
+ canSeek: false,
705
duration: _estimatedTotalDuration(),
706
);
707
_enqueueNextChunks();
@@ -734,16 +739,23 @@ class TtsNotifier extends Notifier<TtsState> {
739
740
_playlistBuffer[chunkIndex] = source;
741
while (_playlistBuffer.containsKey(_nextPlaylistIndexToAdd)) {
737
- final src = _playlistBuffer.remove(_nextPlaylistIndexToAdd)!;
742
+ final indexBeingAdded = _nextPlaylistIndexToAdd;
743
+ final src = _playlistBuffer.remove(indexBeingAdded)!;
744
await _player.addAudioSource(src);
745
740
- if (_nextPlaylistIndexToAdd == 0) {
741
- if (!state.isPaused) {
742
- _player.play();
743
- }
746
+ if (indexBeingAdded == 0 && !state.isPaused) {
747
+ await _player.setSpeed(state.playbackSpeed);
748
+ _player.play();
749
}
750
751
_nextPlaylistIndexToAdd++;
752
+
753
+ if (_nextPlaylistIndexToAdd >= _chunks.length) {
754
+ state = state.copyWith(isInitializing: false, canSeek: true);
755
+ _saveMessageAudioCache();
756
+ } else {
757
+ state = state.copyWith(isInitializing: false);
758
+ }
759
}
760
}
761
@@ -799,10 +811,10 @@ class TtsNotifier extends Notifier<TtsState> {
811
}
812
813
Future<void> seekTo(Duration target) async {
802
- if (!state.isSpeaking || state.isPreview) return;
803
-
804
- if (state.activeEngine == EngineId.system) {
805
- await _systemSeekTo(target);
814
+ if (!state.isSpeaking ||
815
+ state.isPreview ||
816
+ state.isInitializing ||
817
+ state.activeEngine == EngineId.system) {
818
return;
819
}
820
@@ -815,64 +827,32 @@ class TtsNotifier extends Notifier<TtsState> {
827
828
final targetChunk = _chunkIndexForPosition(clamped);
829
await _ensurePlaylistThrough(targetChunk);
818
- await _player.seek(clamped);
819
- _currentChunkIndex = targetChunk;
820
- _updateChunkOffset();
821
- _updatePlaybackProgress();
822
- }
830
824
- Future<void> _systemSeekTo(Duration target) async {
825
- final full = _systemFullText;
826
- if (full == null || _flutterTts == null) return;
827
-
828
- final total = _systemEstimatedDuration();
829
- var clamped = target;
830
- if (clamped.isNegative) clamped = Duration.zero;
831
- if (clamped > total) clamped = total;
832
-
833
- final settings = ref.read(settingsProvider);
834
- final cps = 12.0 * settings.ttsSpeed;
835
- final charIndex =
836
- (clamped.inMilliseconds / 1000 * cps).round().clamp(0, full.length);
837
-
838
- var start = charIndex;
839
- if (start > 0 && start < full.length) {
840
- while (start > 0 && full[start - 1] != ' ') {
841
- start--;
842
- }
831
+ var chunkStart = Duration.zero;
832
+ for (var i = 0; i < targetChunk; i++) {
833
+ chunkStart += _chunkDurations[i] ?? _averageChunkDuration();
834
}
835
+ final localPosition = clamped - chunkStart;
836
845
- final remainder = full.substring(start.clamp(0, full.length));
846
- if (remainder.trim().isEmpty) {
847
- _resetSystemSeekState();
848
- state = state.copyWith(
849
- isSpeaking: false,
850
- playingContent: null,
851
- clearPlayingTarget: true,
852
- resetProgress: true,
853
- canSeek: false,
854
- );
855
- return;
837
+ if (_player.currentIndex != targetChunk) {
838
+ await _player.seek(Duration.zero, index: targetChunk);
839
}
857
-
858
- _isStopping = true;
859
- await _flutterTts!.stop();
860
- _isStopping = false;
861
- _systemSkippedOffset = Duration(
862
- milliseconds: (start / cps * 1000).round(),
863
- );
864
- _systemSpeakStart = DateTime.now();
865
-
866
- await _flutterTts!.setSpeechRate(
867
- (settings.ttsSpeed * 0.5).clamp(0.0, 1.0),
868
- );
869
- state = state.copyWith(isSpeaking: true, canSeek: true);
870
- await _flutterTts!.speak(remainder);
840
+ if (localPosition > Duration.zero) {
841
+ await _player.seek(localPosition);
842
+ } else {
843
+ await _player.seek(Duration.zero);
844
+ }
845
+ _currentChunkIndex = targetChunk;
846
+ _updateChunkOffset();
847
_updatePlaybackProgress();
848
}
849
850
Future<void> skipForward() async {
875
- if (!state.canSeek && !state.isSpeaking) return;
851
+ if (state.isInitializing ||
852
+ state.activeEngine == EngineId.system ||
853
+ (!state.canSeek && !state.isSpeaking)) {
854
+ return;
855
+ }
856
final skip = Duration(
857
seconds: ref.read(settingsProvider).ttsSkipSeconds,
858
);
@@ -880,7 +860,11 @@ class TtsNotifier extends Notifier<TtsState> {
860
}
861
862
Future<void> skipBackward() async {
883
- if (!state.canSeek && !state.isSpeaking) return;
863
+ if (state.isInitializing ||
864
+ state.activeEngine == EngineId.system ||
865
+ (!state.canSeek && !state.isSpeaking)) {
866
+ return;
867
+ }
868
final skip = Duration(
869
seconds: ref.read(settingsProvider).ttsSkipSeconds,
870
);
@@ -918,13 +902,21 @@ class TtsNotifier extends Notifier<TtsState> {
902
903
void pause() {
904
if (state.isPaused || !state.isSpeaking) return;
921
- _player.pause();
905
+ if (state.activeEngine == EngineId.system) {
906
+ _flutterTts?.pause();
907
+ } else {
908
+ _player.pause();
909
+ }
910
state = state.copyWith(isPaused: true);
911
}
912
913
void resume() {
914
if (!state.isPaused) return;
927
- _player.play();
915
+ if (state.activeEngine == EngineId.system) {
916
+ unawaited(_flutterTts?.speak(_systemFullText ?? state.playingContent ?? ''));
917
+ } else {
918
+ _player.play();
919
+ }
920
state = state.copyWith(isPaused: false);
921
}
922
@@ -938,7 +930,11 @@ class TtsNotifier extends Notifier<TtsState> {
930
931
Future<void> stop() async {
932
_isStopping = true;
941
- if (_isPlayerDisposed) return;
933
+ _systemSpeakGeneration++;
934
+ if (_isPlayerDisposed) {
935
+ _isStopping = false;
936
+ return;
937
+ }
938
try {
939
await _player.stop();
940
await _player.clearAudioSources();
@@ -956,6 +952,112 @@ class TtsNotifier extends Notifier<TtsState> {
952
resetProgress: true,
953
canSeek: false,
954
);
955
+ _isStopping = false;
956
+ }
957
+
958
+ Future<void> setPlaybackSpeed(double speed) async {
959
+ if (!playbackSpeedOptions.contains(speed)) return;
960
+ state = state.copyWith(playbackSpeed: speed);
961
+ if (state.activeEngine == EngineId.system) {
962
+ await _applySystemSpeechRate();
963
+ return;
964
+ }
965
+ if (!state.isSpeaking && !state.isPaused) return;
966
+ try {
967
+ await _player.setSpeed(speed);
968
+ } catch (e) {
969
+ Log.error('Failed to set playback speed: $e');
970
+ }
971
+ }
972
+
973
+ void cyclePlaybackSpeed() {
974
+ final options = playbackSpeedOptions;
975
+ final currentIndex = options.indexOf(state.playbackSpeed);
976
+ final nextIndex = currentIndex < 0 ? 2 : (currentIndex + 1) % options.length;
977
+ unawaited(setPlaybackSpeed(options[nextIndex]));
978
+ }
979
+
980
+ Future<void> _applySystemSpeechRate() async {
981
+ if (_flutterTts == null) return;
982
+ final settings = ref.read(settingsProvider);
983
+ await _flutterTts!.setSpeechRate(
984
+ (settings.ttsSpeed * state.playbackSpeed * 0.5).clamp(0.0, 1.0),
985
+ );
986
+ }
987
+
988
+ Future<void> _playFromCache({
989
+ required bool isPreview,
990
+ required String messageId,
991
+ required String? conversationId,
992
+ required EngineId engineId,
993
+ required String text,
994
+ }) async {
995
+ final cache = _messageAudioCache!;
996
+ _resetProgress();
997
+ _chunks = List<String>.from(cache.chunks);
998
+ _currentChunkIndex = 0;
999
+ _currentVoice = cache.voice;
1000
+ _synthesizedChunks.clear();
1001
+ _synthesizedChunks.addAll(cache.synthesizedChunks);
1002
+ _chunkDurations.clear();
1003
+ _chunkDurations.addAll(cache.chunkDurations);
1004
+ _totalDuration = Duration.zero;
1005
+ for (final duration in _chunkDurations.values) {
1006
+ _totalDuration += duration;
1007
+ }
1008
+ _lastEnqueuedChunk = _chunks.length - 1;
1009
+ _currentSessionId++;
1010
+ _playbackSessionId = _currentSessionId;
1011
+ _isPreview = isPreview;
1012
+ _playlistBuffer.clear();
1013
+ _nextPlaylistIndexToAdd = 0;
1014
+ await _player.clearAudioSources();
1015
+
1016
+ state = state.copyWith(
1017
+ isSpeaking: true,
1018
+ isPaused: false,
1019
+ isInitializing: true,
1020
+ error: null,
1021
+ activeEngine: engineId,
1022
+ playingContent: text,
1023
+ playingMessageId: messageId,
1024
+ playingConversationId: conversationId,
1025
+ isPreview: isPreview,
1026
+ duration: _totalDuration,
1027
+ );
1028
+
1029
+ for (var i = 0; i < _chunks.length; i++) {
1030
+ final bytes = _synthesizedChunks[i];
1031
+ if (bytes != null && bytes.isNotEmpty) {
1032
+ await _addChunkToPlaylist(i, bytes);
1033
+ }
1034
+ }
1035
+
1036
+ state = state.copyWith(
1037
+ isInitializing: false,
1038
+ canSeek: true,
1039
+ );
1040
+ }
1041
+
1042
+ void _saveMessageAudioCache() {
1043
+ final messageId = state.playingMessageId;
1044
+ final text = state.playingContent;
1045
+ if (state.isPreview || messageId == null || text == null) return;
1046
+ if (_chunks.isEmpty || _synthesizedChunks.length < _chunks.length) return;
1047
+ for (var i = 0; i < _chunks.length; i++) {
1048
+ final bytes = _synthesizedChunks[i];
1049
+ if (bytes == null || bytes.isEmpty) return;
1050
+ }
1051
+
1052
+ _messageAudioCache = _MessageAudioCache(
1053
+ messageId: messageId,
1054
+ text: text,
1055
+ engine: state.activeEngine ?? _currentEngine ?? EngineId.kitten,
1056
+ voice: _currentVoice,
1057
+ chunks: List<String>.from(_chunks),
1058
+ synthesizedChunks: Map<int, Uint8List>.from(_synthesizedChunks),
1059
+ chunkDurations: Map<int, Duration>.from(_chunkDurations),
1060
+ );
1061
}
1062
1063
Future<bool> downloadCurrentAudio() async {
@@ -999,3 +1101,23 @@ class TtsNotifier extends Notifier<TtsState> {
1101
}
1102
1103
const ttsPreviewSample = "Hello, this is a preview of the selected voice";
1104
+
1105
+class _MessageAudioCache {
1106
+ const _MessageAudioCache({
1107
+ required this.messageId,
1108
+ required this.text,
1109
+ required this.engine,
1110
+ required this.voice,
1111
+ required this.chunks,
1112
+ required this.synthesizedChunks,
1113
+ required this.chunkDurations,
1114
+ });
1115
+
1116
+ final String messageId;
1117
+ final String text;
1118
+ final EngineId engine;
1119
+ final String? voice;
1120
+ final List<String> chunks;
1121
+ final Map<int, Uint8List> synthesizedChunks;
1122
+ final Map<int, Duration> chunkDurations;
1123
+}
lib/features/tts/views/components/tts_player_bar.dart
+30
-6
@@ -25,7 +25,8 @@ class TtsPlayerBar extends ConsumerWidget {
25
return '${content.substring(0, 47)}...';
26
}
27
28
- String _formatDuration(Duration d) {
28
+ String _formatDuration(Duration d, {bool unknown = false}) {
29
+ if (unknown || d <= Duration.zero) return '--:--';
30
if (d.inHours > 0) {
31
final h = d.inHours.toString();
32
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
@@ -105,11 +106,17 @@ class TtsPlayerBar extends ConsumerWidget {
106
final preview = _truncateContent(ttsState.playingContent);
107
final canJumpToMessage = ttsState.playingMessageId != null;
108
final skipSeconds = ref.watch(settingsProvider).ttsSkipSeconds;
108
- final hasTimeline = ttsState.duration > Duration.zero;
109
+ final isSystemTts = ttsState.activeEngine == EngineId.system;
110
+ final hasTimeline =
111
+ !isSystemTts &&
112
+ ttsState.duration > Duration.zero &&
113
+ ttsState.canSeek;
114
final progress = hasTimeline
115
? (ttsState.position.inMilliseconds / ttsState.duration.inMilliseconds)
116
.clamp(0.0, 1.0)
117
: null;
118
+ final speedLabel =
119
+ '${ttsState.playbackSpeed.toStringAsFixed(2).replaceAll(RegExp(r'\.?0+$'), '')}x';
120
121
return AnimatedSize(
122
duration: const Duration(milliseconds: 200),
@@ -206,6 +213,23 @@ class TtsPlayerBar extends ConsumerWidget {
213
const SizedBox(width: 8),
214
if (!ttsState.isInitializing)
215
_WaveformIndicator(isPlaying: !ttsState.isPaused),
216
+ if (!ttsState.isInitializing)
217
+ TextButton(
218
+ onPressed: notifier.cyclePlaybackSpeed,
219
+ style: TextButton.styleFrom(
220
+ minimumSize: Size.zero,
221
+ padding: const EdgeInsets.symmetric(horizontal: 8),
222
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
223
+ ),
224
+ child: Text(
225
+ speedLabel,
226
+ style: TextStyle(
227
+ fontSize: 12,
228
+ fontWeight: FontWeight.w600,
229
+ color: theme.colorScheme.primary,
230
+ ),
231
+ ),
232
+ ),
233
if (!ttsState.isInitializing &&
234
ttsState.activeEngine != EngineId.system)
235
IconButton(
@@ -247,7 +271,7 @@ class TtsPlayerBar extends ConsumerWidget {
271
),
272
],
273
),
250
- if (!ttsState.isInitializing && (ttsState.canSeek || hasTimeline)) ...[
274
+ if (!ttsState.isInitializing && hasTimeline) ...[
275
const SizedBox(height: 8),
276
Row(
277
children: [
@@ -255,14 +279,14 @@ class TtsPlayerBar extends ConsumerWidget {
279
icon: const Icon(Icons.fast_rewind_rounded, size: 22),
280
tooltip: '-${skipSeconds}s',
281
visualDensity: VisualDensity.compact,
258
- onPressed: () => notifier.skipBackward(),
282
+ onPressed: ttsState.canSeek ? notifier.skipBackward : null,
283
),
284
Expanded(
285
child: _TtsSeekSlider(
286
progress: progress ?? 0,
287
position: ttsState.position,
288
duration: ttsState.duration,
265
- enabled: hasTimeline && ttsState.canSeek,
289
+ enabled: hasTimeline,
290
onSeek: notifier.seekTo,
291
formatDuration: _formatDuration,
292
),
@@ -271,7 +295,7 @@ class TtsPlayerBar extends ConsumerWidget {
295
icon: const Icon(Icons.fast_forward_rounded, size: 22),
296
tooltip: '+${skipSeconds}s',
297
visualDensity: VisualDensity.compact,
274
- onPressed: () => notifier.skipForward(),
298
+ onPressed: ttsState.canSeek ? notifier.skipForward : null,
299
),
300
],
301
),