@tduncan / Cortana / commits / 0fa510c

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 ),