diff --git a/lib/features/chat/providers/text_to_speech_provider.dart b/lib/features/chat/providers/text_to_speech_provider.dart index 33615c0..8e57ba1 100644 --- a/lib/features/chat/providers/text_to_speech_provider.dart +++ b/lib/features/chat/providers/text_to_speech_provider.dart @@ -120,6 +120,16 @@ class TextToSpeechController extends StateNotifier { return; } + final isCurrentlyActive = + state.activeMessageId == messageId && + state.status != TtsPlaybackStatus.idle && + state.status != TtsPlaybackStatus.error; + + if (isCurrentlyActive) { + await stop(); + return; + } + final available = await _ensureInitialized(); if (!available) { if (!mounted) { @@ -133,15 +143,6 @@ class TextToSpeechController extends StateNotifier { return; } - final isCurrentlyActive = - state.activeMessageId == messageId && - state.status != TtsPlaybackStatus.idle; - - if (isCurrentlyActive) { - await stop(); - return; - } - state = state.copyWith( status: TtsPlaybackStatus.loading, activeMessageId: messageId, diff --git a/lib/features/chat/services/text_to_speech_service.dart b/lib/features/chat/services/text_to_speech_service.dart index 3e275db..cef1c11 100644 --- a/lib/features/chat/services/text_to_speech_service.dart +++ b/lib/features/chat/services/text_to_speech_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_tts/flutter_tts.dart'; /// Lightweight wrapper around FlutterTts to centralize configuration @@ -9,6 +10,7 @@ class TextToSpeechService { final FlutterTts _tts = FlutterTts(); bool _initialized = false; bool _available = false; + bool _voiceConfigured = false; VoidCallback? _onStart; VoidCallback? _onComplete; @@ -53,6 +55,7 @@ class TextToSpeechService { try { await _tts.awaitSpeakCompletion(false); if (!kIsWeb && Platform.isIOS) { + await _tts.setSharedInstance(true); await _tts.setIosAudioCategory(IosTextToSpeechAudioCategory.playback, [ IosTextToSpeechAudioCategoryOptions.mixWithOthers, IosTextToSpeechAudioCategoryOptions.defaultToSpeaker, @@ -60,6 +63,7 @@ class TextToSpeechService { IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP, ]); } + await _configurePreferredVoice(); _available = true; } catch (e) { _available = false; @@ -84,6 +88,9 @@ class TextToSpeechService { } await _tts.stop(); + if (!_voiceConfigured) { + await _configurePreferredVoice(); + } final result = await _tts.speak(text); if (result == null) { return; @@ -122,6 +129,312 @@ class TextToSpeechService { await stop(); } + Future _configurePreferredVoice() async { + if (_voiceConfigured) { + return; + } + if (kIsWeb || (!Platform.isIOS && !Platform.isAndroid)) { + _voiceConfigured = true; + return; + } + + var configured = false; + try { + Map? defaultVoice; + bool voiceSet = false; + + if (Platform.isIOS) { + try { + final rawDefault = await _tts.getDefaultVoice; + if (rawDefault is Map) { + defaultVoice = _normalizeVoiceEntry(rawDefault); + await _tts.setVoice(_voiceCommandFrom(defaultVoice)); + configured = true; + voiceSet = true; + } + } catch (_) { + defaultVoice = null; + } + } + + if (voiceSet) { + return; + } + + 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); + } + } + } + + if (parsedVoices.isEmpty) { + return; + } + + final localeTag = WidgetsBinding.instance.platformDispatcher.locale + .toLanguageTag() + .toLowerCase(); + final preferred = _selectPreferredVoice( + parsedVoices, + localeTag, + defaultVoice: defaultVoice, + ); + if (preferred == null) { + if (Platform.isIOS) { + configured = true; // Allow system default voice to be used + } + return; + } + + await _tts.setVoice(_voiceCommandFrom(preferred)); + configured = true; + } catch (e) { + _onError?.call(e.toString()); + } finally { + _voiceConfigured = configured || _voiceConfigured; + } + } + + Map _normalizeVoiceEntry(Map entry) { + final normalized = {}; + entry.forEach((key, value) { + if (key != null) { + normalized[key.toString()] = value; + } + }); + return normalized; + } + + Map _voiceCommandFrom(Map voice) { + final command = {}; + for (final key in [ + 'name', + 'locale', + 'identifier', + 'id', + 'voiceIdentifier', + 'engine', + ]) { + final value = voice[key]; + if (value != null) { + command[key] = value.toString(); + } + } + if (!command.containsKey('name') && voice['name'] != null) { + command['name'] = voice['name'].toString(); + } + if (!command.containsKey('locale') && voice['locale'] != null) { + command['locale'] = voice['locale'].toString(); + } + return command; + } + + int _iosVoiceScore(Map voice) { + final identifier = + voice['identifier']?.toString().toLowerCase() ?? + voice['id']?.toString().toLowerCase() ?? + ''; + final name = voice['name']?.toString().toLowerCase() ?? ''; + + int score = 0; + if (identifier.contains('premium')) { + score += 400; + } else if (identifier.contains('enhanced')) { + score += 250; + } else if (identifier.contains('compact')) { + score += 50; + } + + if (identifier.contains('siri') || name.contains('siri')) { + score += 150; + } + + if (identifier.contains('female') || name.contains('female')) { + score += 15; + } + if (identifier.contains('male') || name.contains('male')) { + score += 10; + } + + // Prefer non-compact by default when no other hints are present + if (!identifier.contains('compact')) { + score += 25; + } + + return score; + } + + Map? _selectPreferredVoice( + List> voices, + String localeTag, { + Map? defaultVoice, + }) { + Map? matchesLocale(Iterable> input) { + for (final voice in input) { + final locale = voice['locale']?.toString().toLowerCase(); + if (locale == null) continue; + if (locale == localeTag) { + return voice; + } + final localePrimary = locale.split(RegExp('[-_]')).first; + final tagPrimary = localeTag.split(RegExp('[-_]')).first; + if (localePrimary == tagPrimary) { + return voice; + } + } + return null; + } + + Map? matchDefaultVoice() { + final dv = defaultVoice; + if (dv == null) { + return null; + } + + final identifiers = {}; + for (final key in ['identifier', 'id', 'voiceIdentifier', 'voice']) { + final value = dv[key]?.toString(); + if (value != null && value.isNotEmpty) { + identifiers.add(value.toLowerCase()); + } + } + + if (identifiers.isNotEmpty) { + for (final voice in voices) { + for (final key in ['identifier', 'id', 'voiceIdentifier', 'voice']) { + final value = voice[key]?.toString(); + if (value != null && identifiers.contains(value.toLowerCase())) { + return voice; + } + } + } + } + + final defaultName = dv['name']?.toString(); + final defaultLocale = dv['locale']?.toString(); + if (defaultName != null && defaultLocale != null) { + final lowerName = defaultName.toLowerCase(); + final lowerLocale = defaultLocale.toLowerCase(); + for (final voice in voices) { + final name = voice['name']?.toString(); + final locale = voice['locale']?.toString(); + if (name != null && + locale != null && + name.toLowerCase() == lowerName && + locale.toLowerCase() == lowerLocale) { + return voice; + } + } + } + + return null; + } + + Map? pickIosVoice() { + final userDefault = matchDefaultVoice(); + if (userDefault != null) { + return userDefault; + } + + final siriCandidates = voices.where((voice) { + final name = voice['name']?.toString().toLowerCase() ?? ''; + final identifier = voice['identifier']?.toString().toLowerCase() ?? ''; + final voiceId = voice['id']?.toString().toLowerCase() ?? ''; + return name.contains('siri') || + identifier.contains('siri') || + voiceId.contains('siri'); + }).toList(); + + if (siriCandidates.isNotEmpty) { + siriCandidates.sort((a, b) => _iosVoiceScore(b) - _iosVoiceScore(a)); + final localeMatch = matchesLocale(siriCandidates); + if (localeMatch != null) { + return localeMatch; + } + return siriCandidates.first; + } + + final ranked = [...voices]; + ranked.sort((a, b) => _iosVoiceScore(b) - _iosVoiceScore(a)); + final localeMatch = matchesLocale(ranked); + if (localeMatch != null) { + return localeMatch; + } + return ranked.isNotEmpty ? ranked.first : null; + } + + Map? pickAndroidVoice() { + int qualityScore(String? quality) { + switch ((quality ?? '').toLowerCase()) { + case 'very_high': + case 'very-high': + return 3; + case 'high': + return 2; + case 'normal': + return 1; + default: + return 0; + } + } + + final preferredEngineVoices = voices + .where( + (voice) => + (voice['engine']?.toString() ?? '').toLowerCase().contains( + 'google', + ) || + voice['engine'] is! String, + ) + .toList(); + + preferredEngineVoices.sort((a, b) { + final qualityDiff = + qualityScore(b['quality']?.toString()) - + qualityScore(a['quality']?.toString()); + if (qualityDiff != 0) { + return qualityDiff; + } + final latencyA = a['latency']?.toString() ?? ''; + final latencyB = b['latency']?.toString() ?? ''; + return latencyA.compareTo(latencyB); + }); + + final ordered = preferredEngineVoices.isEmpty + ? voices + : preferredEngineVoices; + return matchesLocale(ordered) ?? matchesLocale(voices); + } + + Map? selected; + if (Platform.isIOS) { + selected = pickIosVoice(); + } else if (Platform.isAndroid) { + selected = pickAndroidVoice(); + } + + if (selected == null) { + return null; + } + + final name = selected['name']?.toString(); + final locale = selected['locale']?.toString(); + if (name == null || locale == null) { + return null; + } + + return selected; + } + void _handleStart() { _onStart?.call(); }