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:
cogwheel0
2025-10-17 14:40:44 +05:30
parent c6acfa68e1
commit 6c81d68e59
18 changed files with 1185 additions and 9 deletions

View File

@@ -21,6 +21,10 @@ final class PreferenceKeys {
static const String localeCode = 'locale_code_v1'; static const String localeCode = 'locale_code_v1';
static const String onboardingSeen = 'onboarding_seen_v1'; static const String onboardingSeen = 'onboarding_seen_v1';
static const String reviewerMode = 'reviewer_mode_v1'; static const String reviewerMode = 'reviewer_mode_v1';
static const String ttsVoice = 'tts_voice';
static const String ttsSpeechRate = 'tts_speech_rate';
static const String ttsPitch = 'tts_pitch';
static const String ttsVolume = 'tts_volume';
} }
final class LegacyPreferenceKeys { final class LegacyPreferenceKeys {

View File

@@ -136,6 +136,12 @@ class SettingsService {
(box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[], (box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[],
), ),
sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false, sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false,
ttsVoice: box.get(PreferenceKeys.ttsVoice) as String?,
ttsSpeechRate:
(box.get(PreferenceKeys.ttsSpeechRate) as num?)?.toDouble() ?? 0.5,
ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0,
ttsVolume:
(box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0,
), ),
); );
} }
@@ -155,6 +161,9 @@ class SettingsService {
_socketTransportModeKey: settings.socketTransportMode, _socketTransportModeKey: settings.socketTransportMode,
_quickPillsKey: settings.quickPills.take(2).toList(), _quickPillsKey: settings.quickPills.take(2).toList(),
_sendOnEnterKey: settings.sendOnEnter, _sendOnEnterKey: settings.sendOnEnter,
PreferenceKeys.ttsSpeechRate: settings.ttsSpeechRate,
PreferenceKeys.ttsPitch: settings.ttsPitch,
PreferenceKeys.ttsVolume: settings.ttsVolume,
}; };
await box.putAll(updates); await box.putAll(updates);
@@ -170,6 +179,12 @@ class SettingsService {
} else { } else {
await box.delete(_voiceLocaleKey); await box.delete(_voiceLocaleKey);
} }
if (settings.ttsVoice != null && settings.ttsVoice!.isNotEmpty) {
await box.put(PreferenceKeys.ttsVoice, settings.ttsVoice);
} else {
await box.delete(PreferenceKeys.ttsVoice);
}
} }
// Voice input specific settings // Voice input specific settings
@@ -295,6 +310,10 @@ class AppSettings {
final String socketTransportMode; // 'auto' or 'ws' final String socketTransportMode; // 'auto' or 'ws'
final List<String> quickPills; // e.g., ['web','image'] final List<String> quickPills; // e.g., ['web','image']
final bool sendOnEnter; final bool sendOnEnter;
final String? ttsVoice;
final double ttsSpeechRate;
final double ttsPitch;
final double ttsVolume;
const AppSettings({ const AppSettings({
this.reduceMotion = false, this.reduceMotion = false,
this.animationSpeed = 1.0, this.animationSpeed = 1.0,
@@ -309,6 +328,10 @@ class AppSettings {
this.socketTransportMode = 'ws', this.socketTransportMode = 'ws',
this.quickPills = const [], this.quickPills = const [],
this.sendOnEnter = false, this.sendOnEnter = false,
this.ttsVoice,
this.ttsSpeechRate = 0.5,
this.ttsPitch = 1.0,
this.ttsVolume = 1.0,
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -325,6 +348,10 @@ class AppSettings {
String? socketTransportMode, String? socketTransportMode,
List<String>? quickPills, List<String>? quickPills,
bool? sendOnEnter, bool? sendOnEnter,
Object? ttsVoice = const _DefaultValue(),
double? ttsSpeechRate,
double? ttsPitch,
double? ttsVolume,
}) { }) {
return AppSettings( return AppSettings(
reduceMotion: reduceMotion ?? this.reduceMotion, reduceMotion: reduceMotion ?? this.reduceMotion,
@@ -344,6 +371,10 @@ class AppSettings {
socketTransportMode: socketTransportMode ?? this.socketTransportMode, socketTransportMode: socketTransportMode ?? this.socketTransportMode,
quickPills: quickPills ?? this.quickPills, quickPills: quickPills ?? this.quickPills,
sendOnEnter: sendOnEnter ?? this.sendOnEnter, sendOnEnter: sendOnEnter ?? this.sendOnEnter,
ttsVoice: ttsVoice is _DefaultValue ? this.ttsVoice : ttsVoice as String?,
ttsSpeechRate: ttsSpeechRate ?? this.ttsSpeechRate,
ttsPitch: ttsPitch ?? this.ttsPitch,
ttsVolume: ttsVolume ?? this.ttsVolume,
); );
} }
@@ -362,6 +393,10 @@ class AppSettings {
other.voiceHoldToTalk == voiceHoldToTalk && other.voiceHoldToTalk == voiceHoldToTalk &&
other.voiceAutoSendFinal == voiceAutoSendFinal && other.voiceAutoSendFinal == voiceAutoSendFinal &&
other.sendOnEnter == sendOnEnter && other.sendOnEnter == sendOnEnter &&
other.ttsVoice == ttsVoice &&
other.ttsSpeechRate == ttsSpeechRate &&
other.ttsPitch == ttsPitch &&
other.ttsVolume == ttsVolume &&
_listEquals(other.quickPills, quickPills); _listEquals(other.quickPills, quickPills);
// socketTransportMode intentionally not included in == to avoid frequent rebuilds // socketTransportMode intentionally not included in == to avoid frequent rebuilds
} }
@@ -381,6 +416,10 @@ class AppSettings {
voiceAutoSendFinal, voiceAutoSendFinal,
socketTransportMode, socketTransportMode,
sendOnEnter, sendOnEnter,
ttsVoice,
ttsSpeechRate,
ttsPitch,
ttsVolume,
Object.hashAllUnordered(quickPills), Object.hashAllUnordered(quickPills),
); );
} }
@@ -484,6 +523,26 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
await SettingsService.setSendOnEnter(value); await SettingsService.setSendOnEnter(value);
} }
Future<void> setTtsVoice(String? voice) async {
state = state.copyWith(ttsVoice: voice);
await SettingsService.saveSettings(state);
}
Future<void> setTtsSpeechRate(double rate) async {
state = state.copyWith(ttsSpeechRate: rate);
await SettingsService.saveSettings(state);
}
Future<void> setTtsPitch(double pitch) async {
state = state.copyWith(ttsPitch: pitch);
await SettingsService.saveSettings(state);
}
Future<void> setTtsVolume(double volume) async {
state = state.copyWith(ttsVolume: volume);
await SettingsService.saveSettings(state);
}
Future<void> resetToDefaults() async { Future<void> resetToDefaults() async {
const defaultSettings = AppSettings(); const defaultSettings = AppSettings();
await SettingsService.saveSettings(defaultSettings); await SettingsService.saveSettings(defaultSettings);

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/settings_service.dart';
import '../../../core/utils/markdown_to_text.dart'; import '../../../core/utils/markdown_to_text.dart';
import '../services/text_to_speech_service.dart'; import '../services/text_to_speech_service.dart';
@@ -58,6 +59,7 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
@override @override
TextToSpeechState build() { TextToSpeechState build() {
_service = ref.watch(textToSpeechServiceProvider); _service = ref.watch(textToSpeechServiceProvider);
if (!_handlersBound) { if (!_handlersBound) {
_handlersBound = true; _handlersBound = true;
_service.bindHandlers( _service.bindHandlers(
@@ -73,6 +75,19 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
unawaited(_service.stop()); 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(); return const TextToSpeechState();
} }
@@ -87,8 +102,14 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
clearErrorMessage: true, clearErrorMessage: true,
); );
final settings = ref.read(appSettingsProvider);
final future = _service final future = _service
.initialize() .initialize(
voice: settings.ttsVoice,
speechRate: settings.ttsSpeechRate,
pitch: settings.ttsPitch,
volume: settings.ttsVolume,
)
.then((available) { .then((available) {
if (!ref.mounted) { if (!ref.mounted) {
return available; return available;

View File

@@ -47,7 +47,12 @@ class TextToSpeechService {
} }
/// Initialize the native TTS engine lazily /// 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) { if (_initialized) {
return _available; return _available;
} }
@@ -55,14 +60,14 @@ class TextToSpeechService {
try { try {
await _tts.awaitSpeakCompletion(false); await _tts.awaitSpeakCompletion(false);
// Set volume to maximum // Set volume
await _tts.setVolume(1.0); await _tts.setVolume(volume);
// Set speech rate (1.0 is normal) // Set speech rate
await _tts.setSpeechRate(0.5); await _tts.setSpeechRate(speechRate);
// Set pitch (1.0 is normal) // Set pitch
await _tts.setPitch(1.0); await _tts.setPitch(pitch);
if (!kIsWeb && Platform.isIOS) { if (!kIsWeb && Platform.isIOS) {
await _tts.setSharedInstance(true); await _tts.setSharedInstance(true);
@@ -74,7 +79,8 @@ class TextToSpeechService {
]); ]);
} }
await _configurePreferredVoice(); // Set the voice (specific or default)
await _setVoiceByName(voice);
_available = true; _available = true;
} catch (e) { } catch (e) {
_available = false; _available = false;
@@ -140,6 +146,114 @@ class TextToSpeechService {
await stop(); 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 { Future<void> _configurePreferredVoice() async {
if (_voiceConfigured) { if (_voiceConfigured) {
return; return;

View File

@@ -13,6 +13,7 @@ import '../../../shared/widgets/conduit_components.dart';
import '../../../shared/utils/ui_utils.dart'; import '../../../shared/utils/ui_utils.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../chat/providers/text_to_speech_provider.dart';
class AppCustomizationPage extends ConsumerWidget { class AppCustomizationPage extends ConsumerWidget {
const AppCustomizationPage({super.key}); const AppCustomizationPage({super.key});
@@ -66,6 +67,8 @@ class AppCustomizationPage extends ConsumerWidget {
_buildQuickPillsSection(context, ref, settings), _buildQuickPillsSection(context, ref, settings),
const SizedBox(height: Spacing.xl), const SizedBox(height: Spacing.xl),
_buildChatSection(context, ref, settings), _buildChatSection(context, ref, settings),
const SizedBox(height: Spacing.xl),
_buildTtsSection(context, ref, settings),
], ],
), ),
), ),
@@ -484,6 +487,613 @@ class AppCustomizationPage extends ConsumerWidget {
); );
} }
Widget _buildTtsSection(
BuildContext context,
WidgetRef ref,
AppSettings settings,
) {
final theme = context.conduitTheme;
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.ttsSettings,
style:
theme.headingSmall?.copyWith(color: theme.textPrimary) ??
TextStyle(color: theme.textPrimary, fontSize: 18),
),
const SizedBox(height: Spacing.sm),
// Voice Selection
_CustomizationTile(
leading: _buildIconBadge(
context,
Platform.isIOS ? CupertinoIcons.speaker_3 : Icons.record_voice_over,
color: theme.buttonPrimary,
),
title: l10n.ttsVoice,
subtitle: _getDisplayVoiceName(
settings.ttsVoice,
l10n.ttsSystemDefault,
),
onTap: () => _showVoicePickerSheet(context, ref, settings),
),
const SizedBox(height: Spacing.xs),
// Speech Rate Slider
_buildSliderTile(
context,
ref,
icon: Platform.isIOS ? CupertinoIcons.speedometer : Icons.speed,
title: l10n.ttsSpeechRate,
value: settings.ttsSpeechRate,
min: 0.25,
max: 2.0,
divisions: 7,
label: '${(settings.ttsSpeechRate * 100).round()}%',
onChanged: (value) =>
ref.read(appSettingsProvider.notifier).setTtsSpeechRate(value),
),
const SizedBox(height: Spacing.xs),
// Pitch Slider
_buildSliderTile(
context,
ref,
icon: Platform.isIOS ? CupertinoIcons.waveform : Icons.graphic_eq,
title: l10n.ttsPitch,
value: settings.ttsPitch,
min: 0.5,
max: 2.0,
divisions: 6,
label: settings.ttsPitch.toStringAsFixed(1),
onChanged: (value) =>
ref.read(appSettingsProvider.notifier).setTtsPitch(value),
),
const SizedBox(height: Spacing.xs),
// Volume Slider
_buildSliderTile(
context,
ref,
icon: Platform.isIOS ? CupertinoIcons.volume_up : Icons.volume_up,
title: l10n.ttsVolume,
value: settings.ttsVolume,
min: 0.0,
max: 1.0,
divisions: 10,
label: '${(settings.ttsVolume * 100).round()}%',
onChanged: (value) =>
ref.read(appSettingsProvider.notifier).setTtsVolume(value),
),
const SizedBox(height: Spacing.xs),
// Preview Button
_CustomizationTile(
leading: _buildIconBadge(
context,
Platform.isIOS ? CupertinoIcons.play_fill : Icons.play_arrow,
color: theme.buttonSecondary,
),
title: l10n.ttsPreview,
subtitle: l10n.ttsPreviewText,
onTap: () => _previewTtsVoice(context, ref),
),
],
);
}
Widget _buildSliderTile(
BuildContext context,
WidgetRef ref, {
required IconData icon,
required String title,
required double value,
required double min,
required double max,
required int divisions,
required String label,
required ValueChanged<double> onChanged,
}) {
final theme = context.conduitTheme;
return ConduitCard(
padding: const EdgeInsets.all(Spacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildIconBadge(context, icon, color: theme.buttonSecondary),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
title,
style:
theme.bodyMedium?.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w500,
) ??
TextStyle(color: theme.textPrimary, fontSize: 14),
),
),
Text(
label,
style:
theme.bodyMedium?.copyWith(
color: theme.textSecondary,
fontWeight: FontWeight.w500,
) ??
TextStyle(color: theme.textSecondary, fontSize: 14),
),
],
),
Slider(
value: value,
min: min,
max: max,
divisions: divisions,
onChanged: onChanged,
),
],
),
);
}
Future<void> _showVoicePickerSheet(
BuildContext context,
WidgetRef ref,
AppSettings settings,
) async {
final l10n = AppLocalizations.of(context)!;
final theme = context.conduitTheme;
final ttsService = ref.read(textToSpeechServiceProvider);
// Fetch available voices
final allVoices = await ttsService.getAvailableVoices();
if (!context.mounted) return;
if (allVoices.isEmpty) {
// Show error if no voices available
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.ttsNoVoicesAvailable),
backgroundColor: theme.error,
),
);
return;
}
// Get the app's current locale
final appLocale = ref.read(appLocaleProvider);
final appLanguageCode =
appLocale?.languageCode ?? Localizations.localeOf(context).languageCode;
// Filter and sort voices: prioritize matching app language
final matchingVoices = <Map<String, dynamic>>[];
final otherVoices = <Map<String, dynamic>>[];
for (final voice in allVoices) {
final voiceName = voice['name'] as String? ?? '';
final voiceLocale = voice['locale'] as String? ?? '';
// Check if voice matches app language (e.g., 'en' matches 'en-us', 'en-gb')
final matchesLanguage =
voiceName.toLowerCase().startsWith(appLanguageCode) ||
voiceLocale.toLowerCase().startsWith(appLanguageCode);
if (matchesLanguage) {
matchingVoices.add(voice);
} else {
otherVoices.add(voice);
}
}
// Sort each group alphabetically by name
matchingVoices.sort((a, b) {
final nameA = a['name'] as String? ?? '';
final nameB = b['name'] as String? ?? '';
return nameA.compareTo(nameB);
});
otherVoices.sort((a, b) {
final nameA = a['name'] as String? ?? '';
final nameB = b['name'] as String? ?? '';
return nameA.compareTo(nameB);
});
// Combine: matching voices first, then others
final voices = [...matchingVoices, ...otherVoices];
showModalBottomSheet<void>(
context: context,
backgroundColor: theme.surfaceBackground,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (BuildContext sheetContext) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(Spacing.md),
child: Row(
children: [
Text(
l10n.ttsSelectVoice,
style:
theme.headingSmall?.copyWith(
color: theme.textPrimary,
) ??
TextStyle(
color: theme.textPrimary,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: Icon(Icons.close, color: theme.iconPrimary),
onPressed: () => Navigator.of(sheetContext).pop(),
),
],
),
),
const Divider(height: 1),
// System Default Option
ListTile(
leading: Icon(
Platform.isIOS
? CupertinoIcons.speaker_3
: Icons.record_voice_over,
color: theme.iconPrimary,
),
title: Text(
l10n.ttsSystemDefault,
style:
theme.bodyMedium?.copyWith(
color: theme.textPrimary,
fontWeight: settings.ttsVoice == null
? FontWeight.bold
: FontWeight.normal,
) ??
TextStyle(color: theme.textPrimary),
),
trailing: settings.ttsVoice == null
? Icon(Icons.check, color: theme.buttonPrimary)
: null,
onTap: () {
ref.read(appSettingsProvider.notifier).setTtsVoice(null);
Navigator.of(sheetContext).pop();
},
),
const Divider(height: 1),
// Voices List
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount:
voices.length +
(matchingVoices.isNotEmpty && otherVoices.isNotEmpty
? 2
: 0),
itemBuilder: (context, index) {
// Show section header for matching voices
if (index == 0 &&
matchingVoices.isNotEmpty &&
otherVoices.isNotEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Text(
l10n.ttsVoicesForLanguage(
appLanguageCode.toUpperCase(),
),
style:
theme.bodySmall?.copyWith(
color: theme.textSecondary,
fontWeight: FontWeight.bold,
) ??
TextStyle(
color: theme.textSecondary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
}
// Show section header for other voices
if (index == matchingVoices.length + 1 &&
matchingVoices.isNotEmpty &&
otherVoices.isNotEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Text(
l10n.ttsOtherVoices,
style:
theme.bodySmall?.copyWith(
color: theme.textSecondary,
fontWeight: FontWeight.bold,
) ??
TextStyle(
color: theme.textSecondary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
}
// Adjust index for headers
int voiceIndex = index;
if (matchingVoices.isNotEmpty && otherVoices.isNotEmpty) {
if (index == 0) return const SizedBox.shrink();
if (index <= matchingVoices.length) {
voiceIndex = index - 1;
} else {
voiceIndex = index - 2;
}
}
final voice = voices[voiceIndex];
final voiceId = _getVoiceIdentifier(voice);
final displayName = _formatVoiceName(voice);
final subtitle = _getVoiceSubtitle(voice);
final isSelected = settings.ttsVoice == voiceId;
return ListTile(
leading: Icon(
Platform.isIOS
? CupertinoIcons.person_fill
: Icons.person,
color: theme.iconSecondary,
),
title: Text(
displayName,
style:
theme.bodyMedium?.copyWith(
color: theme.textPrimary,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
) ??
TextStyle(color: theme.textPrimary),
),
subtitle: subtitle.isNotEmpty
? Text(
subtitle,
style:
theme.bodySmall?.copyWith(
color: theme.textSecondary,
) ??
TextStyle(
color: theme.textSecondary,
fontSize: 12,
),
)
: null,
trailing: isSelected
? Icon(Icons.check, color: theme.buttonPrimary)
: null,
onTap: () {
ref
.read(appSettingsProvider.notifier)
.setTtsVoice(voiceId);
Navigator.of(sheetContext).pop();
},
);
},
),
),
],
);
},
);
},
);
}
Future<void> _previewTtsVoice(BuildContext context, WidgetRef ref) async {
final l10n = AppLocalizations.of(context)!;
final theme = context.conduitTheme;
try {
final ttsController = ref.read(textToSpeechControllerProvider.notifier);
// Try to read the state, but handle if provider is in error
TextToSpeechState? ttsState;
try {
ttsState = ref.read(textToSpeechControllerProvider);
} catch (_) {
// Provider is in error state, proceed anyway to initialize it
ttsState = null;
}
// Don't preview if already speaking
if (ttsState != null && (ttsState.isSpeaking || ttsState.isBusy)) {
await ttsController.stop();
return;
}
// Use the preview text from localization
await ttsController.toggleForMessage(
messageId: 'tts_preview',
text: l10n.ttsPreviewText,
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${l10n.error}: $e'),
backgroundColor: theme.error,
),
);
}
}
String _getDisplayVoiceName(String? voiceName, String defaultLabel) {
if (voiceName == null || voiceName.isEmpty) {
return defaultLabel;
}
// Format Android-style voice names with # separator
if (voiceName.contains('#')) {
final parts = voiceName.split('#');
if (parts.length > 1) {
var friendlyName = parts[1]
.replaceAll('-local', '')
.replaceAll('-network', '')
.replaceAll('_', ' ')
.split(' ')
.map(
(word) =>
word.isEmpty ? '' : word[0].toUpperCase() + word.substring(1),
)
.join(' ');
final localeInfo = parts[0].toUpperCase().replaceAll('_', '-');
return '$localeInfo - $friendlyName';
}
}
// Handle Android-style voice IDs without # (e.g., "es-us-x-sfb-local")
if (voiceName.contains('-x-') ||
voiceName.endsWith('-local') ||
voiceName.endsWith('-network') ||
voiceName.endsWith('-language')) {
var localePart = '';
var qualityPart = '';
if (voiceName.contains('-x-')) {
final xParts = voiceName.split('-x-');
localePart = xParts[0];
qualityPart = xParts.length > 1 ? xParts[1] : '';
} else if (voiceName.contains('-language')) {
localePart = voiceName.replaceAll('-language', '');
} else {
final dashIndex = voiceName.indexOf('-', 3);
if (dashIndex > 0) {
localePart = voiceName.substring(0, dashIndex);
} else {
localePart = voiceName;
}
}
final formattedLocale = localePart.toUpperCase();
if (qualityPart.isNotEmpty) {
qualityPart = qualityPart
.replaceAll('-local', '')
.replaceAll('-network', '')
.toUpperCase();
return '$formattedLocale ($qualityPart)';
}
return formattedLocale;
}
// For iOS or other platforms with proper names, return as-is
return voiceName;
}
String _formatVoiceName(Map<String, dynamic> voice) {
final name = voice['name'] as String? ?? 'Unknown';
final locale = voice['locale'] as String? ?? '';
// Handle Android-style voice IDs with # separator (e.g., "en-us-x-sfg#male_1-local")
if (name.contains('#')) {
final parts = name.split('#');
if (parts.length > 1) {
var friendlyName = parts[1]
.replaceAll('-local', '')
.replaceAll('-network', '')
.replaceAll('_', ' ')
.split(' ')
.map(
(word) =>
word.isEmpty ? '' : word[0].toUpperCase() + word.substring(1),
)
.join(' ');
if (locale.isNotEmpty) {
final localeUpper = locale.toUpperCase().replaceAll('_', '-');
return '$localeUpper - $friendlyName';
}
return friendlyName;
}
}
// Handle Android-style voice IDs without # (e.g., "es-us-x-sfb-local", "ja-jp-x-htm-network")
if (name.contains('-x-') ||
name.endsWith('-local') ||
name.endsWith('-network') ||
name.endsWith('-language')) {
// Extract the main locale part (first 2-5 chars before -x- or other markers)
var localePart = '';
var qualityPart = '';
if (name.contains('-x-')) {
final xParts = name.split('-x-');
localePart = xParts[0];
qualityPart = xParts.length > 1 ? xParts[1] : '';
} else if (name.contains('-language')) {
localePart = name.replaceAll('-language', '');
} else {
// Try to extract locale (first 5 chars like "es-us" or "ja-jp")
final dashIndex = name.indexOf('-', 3);
if (dashIndex > 0) {
localePart = name.substring(0, dashIndex);
} else {
localePart = name;
}
}
// Format the locale part
final formattedLocale = localePart.toUpperCase();
// Format quality indicators
if (qualityPart.isNotEmpty) {
qualityPart = qualityPart
.replaceAll('-local', '')
.replaceAll('-network', '')
.toUpperCase();
return '$formattedLocale ($qualityPart)';
}
return formattedLocale;
}
// For iOS or other platforms with proper names, return as-is
return name;
}
String _getVoiceIdentifier(Map<String, dynamic> voice) {
// Use name as the unique identifier (this is what we set in settings)
return voice['name'] as String? ??
voice['identifier'] as String? ??
voice['id'] as String? ??
'unknown';
}
String _getVoiceSubtitle(Map<String, dynamic> voice) {
final locale = voice['locale'] as String? ?? '';
final name = voice['name'] as String? ?? '';
// If name contains technical info, show the locale part
if (name.contains('#')) {
final parts = name.split('#');
if (parts.isNotEmpty) {
final localeInfo = parts[0].toUpperCase().replaceAll('_', '-');
return localeInfo;
}
}
return locale.isNotEmpty ? locale : '';
}
String _resolveLanguageLabel(BuildContext context, String code) { String _resolveLanguageLabel(BuildContext context, String code) {
switch (code) { switch (code) {
case 'en': case 'en':

View File

@@ -316,6 +316,19 @@
"chatSettings": "Chat", "chatSettings": "Chat",
"sendOnEnter": "Mit Enter senden", "sendOnEnter": "Mit Enter senden",
"sendOnEnterDescription": "Enter sendet (Soft-Tastatur). Cmd/Ctrl+Enter ebenfalls verfügbar", "sendOnEnterDescription": "Enter sendet (Soft-Tastatur). Cmd/Ctrl+Enter ebenfalls verfügbar",
"ttsSettings": "Text zu Sprache",
"ttsVoice": "Stimme",
"ttsSpeechRate": "Sprechgeschwindigkeit",
"ttsPitch": "Tonhöhe",
"ttsVolume": "Lautstärke",
"ttsPreview": "Stimme vorschau",
"ttsSystemDefault": "Systemstandard",
"ttsSelectVoice": "Stimme auswählen",
"ttsPreviewText": "Dies ist eine Vorschau der ausgewählten Stimme.",
"ttsNoVoicesAvailable": "Keine Stimmen verfügbar",
"ttsVoicesForLanguage": "{language}-Stimmen",
"ttsOtherVoices": "Andere Sprachen",
"error": "Fehler",
"display": "Anzeige", "display": "Anzeige",
"realtime": "Echtzeit", "realtime": "Echtzeit",
"transportMode": "Transportmodus", "transportMode": "Transportmodus",

View File

@@ -650,6 +650,40 @@
"@sendOnEnterDescription": { "@sendOnEnterDescription": {
"description": "Explanation of how the Send on Enter toggle behaves." "description": "Explanation of how the Send on Enter toggle behaves."
}, },
"ttsSettings": "Text to Speech",
"@ttsSettings": {"description": "Section header for TTS-related customization options."},
"ttsVoice": "Voice",
"@ttsVoice": {"description": "Title for voice selection tile."},
"ttsSpeechRate": "Speech Rate",
"@ttsSpeechRate": {"description": "Title for speech rate slider."},
"ttsPitch": "Pitch",
"@ttsPitch": {"description": "Title for pitch slider."},
"ttsVolume": "Volume",
"@ttsVolume": {"description": "Title for volume slider."},
"ttsPreview": "Preview Voice",
"@ttsPreview": {"description": "Title for preview button."},
"ttsSystemDefault": "System Default",
"@ttsSystemDefault": {"description": "Label for system default voice option."},
"ttsSelectVoice": "Select Voice",
"@ttsSelectVoice": {"description": "Title for voice picker bottom sheet."},
"ttsPreviewText": "This is a preview of the selected voice.",
"@ttsPreviewText": {"description": "Sample text spoken during voice preview."},
"ttsNoVoicesAvailable": "No voices available",
"@ttsNoVoicesAvailable": {"description": "Error message when no TTS voices can be found."},
"ttsVoicesForLanguage": "{language} Voices",
"@ttsVoicesForLanguage": {
"description": "Section header for voices matching the app language",
"placeholders": {
"language": {
"type": "String",
"example": "EN"
}
}
},
"ttsOtherVoices": "Other Languages",
"@ttsOtherVoices": {"description": "Section header for voices in other languages."},
"error": "Error",
"@error": {"description": "Generic error label."},
"display": "Display", "display": "Display",
"@display": {"description": "Section header for visual and layout related settings."}, "@display": {"description": "Section header for visual and layout related settings."},
"realtime": "Realtime", "realtime": "Realtime",

View File

@@ -309,6 +309,19 @@
"chatSettings": "Conversación", "chatSettings": "Conversación",
"sendOnEnter": "Enviar con Enter", "sendOnEnter": "Enviar con Enter",
"sendOnEnterDescription": "Enter envía (teclado virtual). Cmd/Ctrl+Enter también disponible", "sendOnEnterDescription": "Enter envía (teclado virtual). Cmd/Ctrl+Enter también disponible",
"ttsSettings": "Texto a voz",
"ttsVoice": "Voz",
"ttsSpeechRate": "Velocidad de voz",
"ttsPitch": "Tono",
"ttsVolume": "Volumen",
"ttsPreview": "Vista previa de voz",
"ttsSystemDefault": "Predeterminado del sistema",
"ttsSelectVoice": "Seleccionar voz",
"ttsPreviewText": "Esta es una vista previa de la voz seleccionada.",
"ttsNoVoicesAvailable": "No hay voces disponibles",
"ttsVoicesForLanguage": "Voces de {language}",
"ttsOtherVoices": "Otros idiomas",
"error": "Error",
"display": "Visualización", "display": "Visualización",
"realtime": "Tiempo real", "realtime": "Tiempo real",
"transportMode": "Modo de transporte", "transportMode": "Modo de transporte",

View File

@@ -316,6 +316,19 @@
"chatSettings": "Discussion", "chatSettings": "Discussion",
"sendOnEnter": "Envoyer avec Entrée", "sendOnEnter": "Envoyer avec Entrée",
"sendOnEnterDescription": "Entrée envoie (clavier logiciel). Cmd/Ctrl+Entrée aussi disponible", "sendOnEnterDescription": "Entrée envoie (clavier logiciel). Cmd/Ctrl+Entrée aussi disponible",
"ttsSettings": "Synthèse vocale",
"ttsVoice": "Voix",
"ttsSpeechRate": "Vitesse de parole",
"ttsPitch": "Hauteur",
"ttsVolume": "Volume",
"ttsPreview": "Aperçu de la voix",
"ttsSystemDefault": "Système par défaut",
"ttsSelectVoice": "Sélectionner la voix",
"ttsPreviewText": "Ceci est un aperçu de la voix sélectionnée.",
"ttsNoVoicesAvailable": "Aucune voix disponible",
"ttsVoicesForLanguage": "Voix {language}",
"ttsOtherVoices": "Autres langues",
"error": "Erreur",
"display": "Affichage", "display": "Affichage",
"realtime": "Temps réel", "realtime": "Temps réel",
"transportMode": "Mode de transport", "transportMode": "Mode de transport",

View File

@@ -316,6 +316,19 @@
"chatSettings": "Chat", "chatSettings": "Chat",
"sendOnEnter": "Invia con Invio", "sendOnEnter": "Invia con Invio",
"sendOnEnterDescription": "Invio invia (tastiera software). Cmd/Ctrl+Invio disponibile", "sendOnEnterDescription": "Invio invia (tastiera software). Cmd/Ctrl+Invio disponibile",
"ttsSettings": "Sintesi vocale",
"ttsVoice": "Voce",
"ttsSpeechRate": "Velocità di sintesi vocale",
"ttsPitch": "Tonalità",
"ttsVolume": "Volume",
"ttsPreview": "Anteprima voce",
"ttsSystemDefault": "Predefinito del sistema",
"ttsSelectVoice": "Seleziona voce",
"ttsPreviewText": "Questa è un'anteprima della voce selezionata.",
"ttsNoVoicesAvailable": "Nessuna voce disponibile",
"ttsVoicesForLanguage": "Voci {language}",
"ttsOtherVoices": "Altre lingue",
"error": "Errore",
"display": "Schermo", "display": "Schermo",
"realtime": "Tempo reale", "realtime": "Tempo reale",
"transportMode": "Modalità di trasporto", "transportMode": "Modalità di trasporto",

View File

@@ -1718,6 +1718,84 @@ abstract class AppLocalizations {
/// **'Enter sends (soft keyboard). Cmd/Ctrl+Enter also available'** /// **'Enter sends (soft keyboard). Cmd/Ctrl+Enter also available'**
String get sendOnEnterDescription; String get sendOnEnterDescription;
/// Section header for TTS-related customization options.
///
/// In en, this message translates to:
/// **'Text to Speech'**
String get ttsSettings;
/// Title for voice selection tile.
///
/// In en, this message translates to:
/// **'Voice'**
String get ttsVoice;
/// Title for speech rate slider.
///
/// In en, this message translates to:
/// **'Speech Rate'**
String get ttsSpeechRate;
/// Title for pitch slider.
///
/// In en, this message translates to:
/// **'Pitch'**
String get ttsPitch;
/// Title for volume slider.
///
/// In en, this message translates to:
/// **'Volume'**
String get ttsVolume;
/// Title for preview button.
///
/// In en, this message translates to:
/// **'Preview Voice'**
String get ttsPreview;
/// Label for system default voice option.
///
/// In en, this message translates to:
/// **'System Default'**
String get ttsSystemDefault;
/// Title for voice picker bottom sheet.
///
/// In en, this message translates to:
/// **'Select Voice'**
String get ttsSelectVoice;
/// Sample text spoken during voice preview.
///
/// In en, this message translates to:
/// **'This is a preview of the selected voice.'**
String get ttsPreviewText;
/// Error message when no TTS voices can be found.
///
/// In en, this message translates to:
/// **'No voices available'**
String get ttsNoVoicesAvailable;
/// Section header for voices matching the app language
///
/// In en, this message translates to:
/// **'{language} Voices'**
String ttsVoicesForLanguage(String language);
/// Section header for voices in other languages.
///
/// In en, this message translates to:
/// **'Other Languages'**
String get ttsOtherVoices;
/// Generic error label.
///
/// In en, this message translates to:
/// **'Error'**
String get error;
/// Section header for visual and layout related settings. /// Section header for visual and layout related settings.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -895,6 +895,48 @@ class AppLocalizationsDe extends AppLocalizations {
String get sendOnEnterDescription => String get sendOnEnterDescription =>
'Enter sendet (Soft-Tastatur). Cmd/Ctrl+Enter ebenfalls verfügbar'; 'Enter sendet (Soft-Tastatur). Cmd/Ctrl+Enter ebenfalls verfügbar';
@override
String get ttsSettings => 'Text zu Sprache';
@override
String get ttsVoice => 'Stimme';
@override
String get ttsSpeechRate => 'Sprechgeschwindigkeit';
@override
String get ttsPitch => 'Tonhöhe';
@override
String get ttsVolume => 'Lautstärke';
@override
String get ttsPreview => 'Stimme vorschau';
@override
String get ttsSystemDefault => 'Systemstandard';
@override
String get ttsSelectVoice => 'Stimme auswählen';
@override
String get ttsPreviewText =>
'Dies ist eine Vorschau der ausgewählten Stimme.';
@override
String get ttsNoVoicesAvailable => 'Keine Stimmen verfügbar';
@override
String ttsVoicesForLanguage(String language) {
return '$language-Stimmen';
}
@override
String get ttsOtherVoices => 'Andere Sprachen';
@override
String get error => 'Fehler';
@override @override
String get display => 'Anzeige'; String get display => 'Anzeige';

View File

@@ -887,6 +887,47 @@ class AppLocalizationsEn extends AppLocalizations {
String get sendOnEnterDescription => String get sendOnEnterDescription =>
'Enter sends (soft keyboard). Cmd/Ctrl+Enter also available'; 'Enter sends (soft keyboard). Cmd/Ctrl+Enter also available';
@override
String get ttsSettings => 'Text to Speech';
@override
String get ttsVoice => 'Voice';
@override
String get ttsSpeechRate => 'Speech Rate';
@override
String get ttsPitch => 'Pitch';
@override
String get ttsVolume => 'Volume';
@override
String get ttsPreview => 'Preview Voice';
@override
String get ttsSystemDefault => 'System Default';
@override
String get ttsSelectVoice => 'Select Voice';
@override
String get ttsPreviewText => 'This is a preview of the selected voice.';
@override
String get ttsNoVoicesAvailable => 'No voices available';
@override
String ttsVoicesForLanguage(String language) {
return '$language Voices';
}
@override
String get ttsOtherVoices => 'Other Languages';
@override
String get error => 'Error';
@override @override
String get display => 'Display'; String get display => 'Display';

View File

@@ -903,6 +903,47 @@ class AppLocalizationsFr extends AppLocalizations {
String get sendOnEnterDescription => String get sendOnEnterDescription =>
'Entrée envoie (clavier logiciel). Cmd/Ctrl+Entrée aussi disponible'; 'Entrée envoie (clavier logiciel). Cmd/Ctrl+Entrée aussi disponible';
@override
String get ttsSettings => 'Synthèse vocale';
@override
String get ttsVoice => 'Voix';
@override
String get ttsSpeechRate => 'Vitesse de parole';
@override
String get ttsPitch => 'Hauteur';
@override
String get ttsVolume => 'Volume';
@override
String get ttsPreview => 'Aperçu de la voix';
@override
String get ttsSystemDefault => 'Système par défaut';
@override
String get ttsSelectVoice => 'Sélectionner la voix';
@override
String get ttsPreviewText => 'Ceci est un aperçu de la voix sélectionnée.';
@override
String get ttsNoVoicesAvailable => 'Aucune voix disponible';
@override
String ttsVoicesForLanguage(String language) {
return 'Voix $language';
}
@override
String get ttsOtherVoices => 'Autres langues';
@override
String get error => 'Erreur';
@override @override
String get display => 'Affichage'; String get display => 'Affichage';

View File

@@ -892,6 +892,47 @@ class AppLocalizationsIt extends AppLocalizations {
String get sendOnEnterDescription => String get sendOnEnterDescription =>
'Invio invia (tastiera software). Cmd/Ctrl+Invio disponibile'; 'Invio invia (tastiera software). Cmd/Ctrl+Invio disponibile';
@override
String get ttsSettings => 'Sintesi vocale';
@override
String get ttsVoice => 'Voce';
@override
String get ttsSpeechRate => 'Velocità di sintesi vocale';
@override
String get ttsPitch => 'Tonalità';
@override
String get ttsVolume => 'Volume';
@override
String get ttsPreview => 'Anteprima voce';
@override
String get ttsSystemDefault => 'Predefinito del sistema';
@override
String get ttsSelectVoice => 'Seleziona voce';
@override
String get ttsPreviewText => 'Questa è un\'anteprima della voce selezionata.';
@override
String get ttsNoVoicesAvailable => 'Nessuna voce disponibile';
@override
String ttsVoicesForLanguage(String language) {
return 'Voci $language';
}
@override
String get ttsOtherVoices => 'Altre lingue';
@override
String get error => 'Errore';
@override @override
String get display => 'Schermo'; String get display => 'Schermo';

View File

@@ -309,6 +309,19 @@
"chatSettings": "Chat", "chatSettings": "Chat",
"sendOnEnter": "Verzenden met Enter", "sendOnEnter": "Verzenden met Enter",
"sendOnEnterDescription": "Enter verzendt (softtoetsenbord). Cmd/Ctrl+Enter ook beschikbaar", "sendOnEnterDescription": "Enter verzendt (softtoetsenbord). Cmd/Ctrl+Enter ook beschikbaar",
"ttsSettings": "Tekst naar spraak",
"ttsVoice": "Stem",
"ttsSpeechRate": "Spraaksnelheid",
"ttsPitch": "Toonhoogte",
"ttsVolume": "Volume",
"ttsPreview": "Stemvoorbeeld",
"ttsSystemDefault": "Systeemstandaard",
"ttsSelectVoice": "Selecteer stem",
"ttsPreviewText": "Dit is een voorbeeld van de geselecteerde stem.",
"ttsNoVoicesAvailable": "Geen stemmen beschikbaar",
"ttsVoicesForLanguage": "{language} stemmen",
"ttsOtherVoices": "Andere talen",
"error": "Fout",
"display": "Weergave", "display": "Weergave",
"realtime": "Realtime", "realtime": "Realtime",
"transportMode": "Transportmodus", "transportMode": "Transportmodus",

View File

@@ -309,6 +309,19 @@
"chatSettings": "Чат", "chatSettings": "Чат",
"sendOnEnter": "Отправка по Enter", "sendOnEnter": "Отправка по Enter",
"sendOnEnterDescription": "Enter отправляет (программная клавиатура). Также доступно Cmd/Ctrl+Enter", "sendOnEnterDescription": "Enter отправляет (программная клавиатура). Также доступно Cmd/Ctrl+Enter",
"ttsSettings": "Преобразование текста в речь",
"ttsVoice": "Голос",
"ttsSpeechRate": "Скорость речи",
"ttsPitch": "Высота тона",
"ttsVolume": "Громкость",
"ttsPreview": "Предпросмотр голоса",
"ttsSystemDefault": "Системное значение по умолчанию",
"ttsSelectVoice": "Выбрать голос",
"ttsPreviewText": "Это предварительный просмотр выбранного голоса.",
"ttsNoVoicesAvailable": "Нет доступных голосов",
"ttsVoicesForLanguage": "Голоса {language}",
"ttsOtherVoices": "Другие языки",
"error": "Ошибка",
"display": "Отображение", "display": "Отображение",
"realtime": "Реальное время", "realtime": "Реальное время",
"transportMode": "Режим транспорта", "transportMode": "Режим транспорта",

View File

@@ -309,6 +309,19 @@
"chatSettings": "对话", "chatSettings": "对话",
"sendOnEnter": "回车发送", "sendOnEnter": "回车发送",
"sendOnEnterDescription": "回车发送软键盘。Cmd/Ctrl+Enter 也可用", "sendOnEnterDescription": "回车发送软键盘。Cmd/Ctrl+Enter 也可用",
"ttsSettings": "文本转语音",
"ttsVoice": "语音",
"ttsSpeechRate": "语速",
"ttsPitch": "音调",
"ttsVolume": "音量",
"ttsPreview": "预览语音",
"ttsSystemDefault": "系统默认",
"ttsSelectVoice": "选择语音",
"ttsPreviewText": "这是所选语音的预览。",
"ttsNoVoicesAvailable": "没有可用的语音",
"ttsVoicesForLanguage": "{language} 语音",
"ttsOtherVoices": "其他语言",
"error": "错误",
"display": "显示", "display": "显示",
"realtime": "实时", "realtime": "实时",
"transportMode": "传输模式", "transportMode": "传输模式",