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:
@@ -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 {
|
||||
|
||||
@@ -136,6 +136,12 @@ class SettingsService {
|
||||
(box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[],
|
||||
),
|
||||
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<String> 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<String>? 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<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 {
|
||||
const defaultSettings = AppSettings();
|
||||
await SettingsService.saveSettings(defaultSettings);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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) {
|
||||
switch (code) {
|
||||
case 'en':
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Режим транспорта",
|
||||
|
||||
@@ -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": "传输模式",
|
||||
|
||||
Reference in New Issue
Block a user