feat: Add Text-to-Speech settings and customization options
- Introduced new preference keys for TTS settings: voice, speech rate, pitch, and volume. - Updated SettingsService to handle TTS settings and persist them. - Enhanced AppSettings to include TTS-related properties. - Implemented TTS settings UI in AppCustomizationPage, allowing users to select voice and adjust speech parameters. - Added localization support for TTS settings in multiple languages.
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/services/settings_service.dart';
|
||||
import '../../../core/utils/markdown_to_text.dart';
|
||||
import '../services/text_to_speech_service.dart';
|
||||
|
||||
@@ -58,6 +59,7 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
|
||||
@override
|
||||
TextToSpeechState build() {
|
||||
_service = ref.watch(textToSpeechServiceProvider);
|
||||
|
||||
if (!_handlersBound) {
|
||||
_handlersBound = true;
|
||||
_service.bindHandlers(
|
||||
@@ -73,6 +75,19 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
|
||||
unawaited(_service.stop());
|
||||
});
|
||||
}
|
||||
|
||||
// Listen to settings changes and update TTS when initialized
|
||||
ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
|
||||
if (_service.isInitialized && _service.isAvailable) {
|
||||
_service.updateSettings(
|
||||
voice: next.ttsVoice,
|
||||
speechRate: next.ttsSpeechRate,
|
||||
pitch: next.ttsPitch,
|
||||
volume: next.ttsVolume,
|
||||
);
|
||||
}
|
||||
}, fireImmediately: false);
|
||||
|
||||
return const TextToSpeechState();
|
||||
}
|
||||
|
||||
@@ -87,8 +102,14 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
|
||||
clearErrorMessage: true,
|
||||
);
|
||||
|
||||
final settings = ref.read(appSettingsProvider);
|
||||
final future = _service
|
||||
.initialize()
|
||||
.initialize(
|
||||
voice: settings.ttsVoice,
|
||||
speechRate: settings.ttsSpeechRate,
|
||||
pitch: settings.ttsPitch,
|
||||
volume: settings.ttsVolume,
|
||||
)
|
||||
.then((available) {
|
||||
if (!ref.mounted) {
|
||||
return available;
|
||||
|
||||
@@ -47,7 +47,12 @@ class TextToSpeechService {
|
||||
}
|
||||
|
||||
/// Initialize the native TTS engine lazily
|
||||
Future<bool> initialize() async {
|
||||
Future<bool> initialize({
|
||||
String? voice,
|
||||
double speechRate = 0.5,
|
||||
double pitch = 1.0,
|
||||
double volume = 1.0,
|
||||
}) async {
|
||||
if (_initialized) {
|
||||
return _available;
|
||||
}
|
||||
@@ -55,14 +60,14 @@ class TextToSpeechService {
|
||||
try {
|
||||
await _tts.awaitSpeakCompletion(false);
|
||||
|
||||
// Set volume to maximum
|
||||
await _tts.setVolume(1.0);
|
||||
// Set volume
|
||||
await _tts.setVolume(volume);
|
||||
|
||||
// Set speech rate (1.0 is normal)
|
||||
await _tts.setSpeechRate(0.5);
|
||||
// Set speech rate
|
||||
await _tts.setSpeechRate(speechRate);
|
||||
|
||||
// Set pitch (1.0 is normal)
|
||||
await _tts.setPitch(1.0);
|
||||
// Set pitch
|
||||
await _tts.setPitch(pitch);
|
||||
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
await _tts.setSharedInstance(true);
|
||||
@@ -74,7 +79,8 @@ class TextToSpeechService {
|
||||
]);
|
||||
}
|
||||
|
||||
await _configurePreferredVoice();
|
||||
// Set the voice (specific or default)
|
||||
await _setVoiceByName(voice);
|
||||
_available = true;
|
||||
} catch (e) {
|
||||
_available = false;
|
||||
@@ -140,6 +146,114 @@ class TextToSpeechService {
|
||||
await stop();
|
||||
}
|
||||
|
||||
/// Update TTS settings on-the-fly
|
||||
Future<void> updateSettings({
|
||||
String? voice,
|
||||
double? speechRate,
|
||||
double? pitch,
|
||||
double? volume,
|
||||
}) async {
|
||||
if (!_initialized || !_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (volume != null) {
|
||||
await _tts.setVolume(volume);
|
||||
}
|
||||
if (speechRate != null) {
|
||||
await _tts.setSpeechRate(speechRate);
|
||||
}
|
||||
if (pitch != null) {
|
||||
await _tts.setPitch(pitch);
|
||||
}
|
||||
// Set specific voice by name
|
||||
await _setVoiceByName(voice);
|
||||
} catch (e) {
|
||||
_onError?.call(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Set voice by name, or use system default if null
|
||||
Future<void> _setVoiceByName(String? voiceName) async {
|
||||
if (kIsWeb || (!Platform.isIOS && !Platform.isAndroid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (voiceName == null) {
|
||||
// Use system default - reset voice configuration
|
||||
_voiceConfigured = false;
|
||||
await _configurePreferredVoice();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all available voices
|
||||
final voicesRaw = await _tts.getVoices;
|
||||
if (voicesRaw is! List) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the voice by name
|
||||
Map<String, dynamic>? targetVoice;
|
||||
for (final entry in voicesRaw) {
|
||||
if (entry is Map) {
|
||||
final normalized = _normalizeVoiceEntry(entry);
|
||||
final name = normalized['name'] as String?;
|
||||
if (name == voiceName) {
|
||||
targetVoice = normalized;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the voice if found
|
||||
if (targetVoice != null) {
|
||||
await _tts.setVoice(_voiceCommandFrom(targetVoice));
|
||||
_voiceConfigured = true;
|
||||
} else {
|
||||
// Voice not found, fall back to default
|
||||
_voiceConfigured = false;
|
||||
await _configurePreferredVoice();
|
||||
}
|
||||
} catch (e) {
|
||||
_onError?.call(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get available voices from the TTS engine
|
||||
Future<List<Map<String, dynamic>>> getAvailableVoices() async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
if (!_available) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final voicesRaw = await _tts.getVoices;
|
||||
if (voicesRaw is! List) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final parsedVoices = <Map<String, dynamic>>[];
|
||||
for (final entry in voicesRaw) {
|
||||
if (entry is Map) {
|
||||
final normalized = _normalizeVoiceEntry(entry);
|
||||
if (normalized.isNotEmpty) {
|
||||
parsedVoices.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedVoices;
|
||||
} catch (e) {
|
||||
_onError?.call(e.toString());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _configurePreferredVoice() async {
|
||||
if (_voiceConfigured) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user