diff --git a/lib/core/persistence/persistence_keys.dart b/lib/core/persistence/persistence_keys.dart index 8953255..80e58b7 100644 --- a/lib/core/persistence/persistence_keys.dart +++ b/lib/core/persistence/persistence_keys.dart @@ -21,6 +21,10 @@ final class PreferenceKeys { static const String localeCode = 'locale_code_v1'; static const String onboardingSeen = 'onboarding_seen_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 { diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart index b723aa5..b21a168 100644 --- a/lib/core/services/settings_service.dart +++ b/lib/core/services/settings_service.dart @@ -136,6 +136,12 @@ class SettingsService { (box.get(_quickPillsKey) as List?) ?? const [], ), 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, _quickPillsKey: settings.quickPills.take(2).toList(), _sendOnEnterKey: settings.sendOnEnter, + PreferenceKeys.ttsSpeechRate: settings.ttsSpeechRate, + PreferenceKeys.ttsPitch: settings.ttsPitch, + PreferenceKeys.ttsVolume: settings.ttsVolume, }; await box.putAll(updates); @@ -170,6 +179,12 @@ class SettingsService { } else { 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 @@ -295,6 +310,10 @@ class AppSettings { final String socketTransportMode; // 'auto' or 'ws' final List quickPills; // e.g., ['web','image'] final bool sendOnEnter; + final String? ttsVoice; + final double ttsSpeechRate; + final double ttsPitch; + final double ttsVolume; const AppSettings({ this.reduceMotion = false, this.animationSpeed = 1.0, @@ -309,6 +328,10 @@ class AppSettings { this.socketTransportMode = 'ws', this.quickPills = const [], this.sendOnEnter = false, + this.ttsVoice, + this.ttsSpeechRate = 0.5, + this.ttsPitch = 1.0, + this.ttsVolume = 1.0, }); AppSettings copyWith({ @@ -325,6 +348,10 @@ class AppSettings { String? socketTransportMode, List? quickPills, bool? sendOnEnter, + Object? ttsVoice = const _DefaultValue(), + double? ttsSpeechRate, + double? ttsPitch, + double? ttsVolume, }) { return AppSettings( reduceMotion: reduceMotion ?? this.reduceMotion, @@ -344,6 +371,10 @@ class AppSettings { socketTransportMode: socketTransportMode ?? this.socketTransportMode, quickPills: quickPills ?? this.quickPills, 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.voiceAutoSendFinal == voiceAutoSendFinal && other.sendOnEnter == sendOnEnter && + other.ttsVoice == ttsVoice && + other.ttsSpeechRate == ttsSpeechRate && + other.ttsPitch == ttsPitch && + other.ttsVolume == ttsVolume && _listEquals(other.quickPills, quickPills); // socketTransportMode intentionally not included in == to avoid frequent rebuilds } @@ -381,6 +416,10 @@ class AppSettings { voiceAutoSendFinal, socketTransportMode, sendOnEnter, + ttsVoice, + ttsSpeechRate, + ttsPitch, + ttsVolume, Object.hashAllUnordered(quickPills), ); } @@ -484,6 +523,26 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { await SettingsService.setSendOnEnter(value); } + Future setTtsVoice(String? voice) async { + state = state.copyWith(ttsVoice: voice); + await SettingsService.saveSettings(state); + } + + Future setTtsSpeechRate(double rate) async { + state = state.copyWith(ttsSpeechRate: rate); + await SettingsService.saveSettings(state); + } + + Future setTtsPitch(double pitch) async { + state = state.copyWith(ttsPitch: pitch); + await SettingsService.saveSettings(state); + } + + Future setTtsVolume(double volume) async { + state = state.copyWith(ttsVolume: volume); + await SettingsService.saveSettings(state); + } + Future resetToDefaults() async { const defaultSettings = AppSettings(); await SettingsService.saveSettings(defaultSettings); diff --git a/lib/features/chat/providers/text_to_speech_provider.dart b/lib/features/chat/providers/text_to_speech_provider.dart index ed12fed..b25e341 100644 --- a/lib/features/chat/providers/text_to_speech_provider.dart +++ b/lib/features/chat/providers/text_to_speech_provider.dart @@ -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 { @override TextToSpeechState build() { _service = ref.watch(textToSpeechServiceProvider); + if (!_handlersBound) { _handlersBound = true; _service.bindHandlers( @@ -73,6 +75,19 @@ class TextToSpeechController extends Notifier { unawaited(_service.stop()); }); } + + // Listen to settings changes and update TTS when initialized + ref.listen(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 { 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; diff --git a/lib/features/chat/services/text_to_speech_service.dart b/lib/features/chat/services/text_to_speech_service.dart index 2df1701..6591f41 100644 --- a/lib/features/chat/services/text_to_speech_service.dart +++ b/lib/features/chat/services/text_to_speech_service.dart @@ -47,7 +47,12 @@ class TextToSpeechService { } /// Initialize the native TTS engine lazily - Future initialize() async { + Future 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 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 _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? 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>> getAvailableVoices() async { + if (!_initialized) { + await initialize(); + } + + if (!_available) { + return []; + } + + try { + final voicesRaw = await _tts.getVoices; + if (voicesRaw is! List) { + return []; + } + + final parsedVoices = >[]; + 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 _configurePreferredVoice() async { if (_voiceConfigured) { return; diff --git a/lib/features/profile/views/app_customization_page.dart b/lib/features/profile/views/app_customization_page.dart index b61332b..1c32497 100644 --- a/lib/features/profile/views/app_customization_page.dart +++ b/lib/features/profile/views/app_customization_page.dart @@ -13,6 +13,7 @@ import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/utils/ui_utils.dart'; import '../../../core/providers/app_providers.dart'; import '../../../l10n/app_localizations.dart'; +import '../../chat/providers/text_to_speech_provider.dart'; class AppCustomizationPage extends ConsumerWidget { const AppCustomizationPage({super.key}); @@ -66,6 +67,8 @@ class AppCustomizationPage extends ConsumerWidget { _buildQuickPillsSection(context, ref, settings), const SizedBox(height: Spacing.xl), _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 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 _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 = >[]; + final otherVoices = >[]; + + 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( + 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 _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 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 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 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) { switch (code) { case 'en': diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 00e96de..3bb3b35 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -316,6 +316,19 @@ "chatSettings": "Chat", "sendOnEnter": "Mit Enter senden", "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", "realtime": "Echtzeit", "transportMode": "Transportmodus", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eba5a37..b61c28b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -650,6 +650,40 @@ "@sendOnEnterDescription": { "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": {"description": "Section header for visual and layout related settings."}, "realtime": "Realtime", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9775617..502902a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -309,6 +309,19 @@ "chatSettings": "Conversación", "sendOnEnter": "Enviar con Enter", "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", "realtime": "Tiempo real", "transportMode": "Modo de transporte", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 99ecb6b..f12f453 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -316,6 +316,19 @@ "chatSettings": "Discussion", "sendOnEnter": "Envoyer avec Entrée", "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", "realtime": "Temps réel", "transportMode": "Mode de transport", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9253441..16514b0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -316,6 +316,19 @@ "chatSettings": "Chat", "sendOnEnter": "Invia con Invio", "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", "realtime": "Tempo reale", "transportMode": "Modalità di trasporto", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f56db99..d32aa46 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1718,6 +1718,84 @@ abstract class AppLocalizations { /// **'Enter sends (soft keyboard). Cmd/Ctrl+Enter also available'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 9d212b0..2d79ceb 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -895,6 +895,48 @@ class AppLocalizationsDe extends AppLocalizations { String get sendOnEnterDescription => '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 String get display => 'Anzeige'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9c04e70..5a13c21 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -887,6 +887,47 @@ class AppLocalizationsEn extends AppLocalizations { String get sendOnEnterDescription => '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 String get display => 'Display'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 0bc9108..1f3957a 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -903,6 +903,47 @@ class AppLocalizationsFr extends AppLocalizations { String get sendOnEnterDescription => '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 String get display => 'Affichage'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 9927952..f50f1d8 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -892,6 +892,47 @@ class AppLocalizationsIt extends AppLocalizations { String get sendOnEnterDescription => '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 String get display => 'Schermo'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index ff39f34..276f129 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -309,6 +309,19 @@ "chatSettings": "Chat", "sendOnEnter": "Verzenden met Enter", "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", "realtime": "Realtime", "transportMode": "Transportmodus", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6ff9263..33ebad2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -309,6 +309,19 @@ "chatSettings": "Чат", "sendOnEnter": "Отправка по Enter", "sendOnEnterDescription": "Enter отправляет (программная клавиатура). Также доступно Cmd/Ctrl+Enter", + "ttsSettings": "Преобразование текста в речь", + "ttsVoice": "Голос", + "ttsSpeechRate": "Скорость речи", + "ttsPitch": "Высота тона", + "ttsVolume": "Громкость", + "ttsPreview": "Предпросмотр голоса", + "ttsSystemDefault": "Системное значение по умолчанию", + "ttsSelectVoice": "Выбрать голос", + "ttsPreviewText": "Это предварительный просмотр выбранного голоса.", + "ttsNoVoicesAvailable": "Нет доступных голосов", + "ttsVoicesForLanguage": "Голоса {language}", + "ttsOtherVoices": "Другие языки", + "error": "Ошибка", "display": "Отображение", "realtime": "Реальное время", "transportMode": "Режим транспорта", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 62bd7c4..e4b20d0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -309,6 +309,19 @@ "chatSettings": "对话", "sendOnEnter": "回车发送", "sendOnEnterDescription": "回车发送(软键盘)。Cmd/Ctrl+Enter 也可用", + "ttsSettings": "文本转语音", + "ttsVoice": "语音", + "ttsSpeechRate": "语速", + "ttsPitch": "音调", + "ttsVolume": "音量", + "ttsPreview": "预览语音", + "ttsSystemDefault": "系统默认", + "ttsSelectVoice": "选择语音", + "ttsPreviewText": "这是所选语音的预览。", + "ttsNoVoicesAvailable": "没有可用的语音", + "ttsVoicesForLanguage": "{language} 语音", + "ttsOtherVoices": "其他语言", + "error": "错误", "display": "显示", "realtime": "实时", "transportMode": "传输模式",