feat(tts): server-backed TTS engine selection
Introduce server TTS support and engine selection while keeping device TTS as the default. - Add new persistence keys for storing TTS engine and selected server voice (ttsEngine, ttsServerVoiceId, ttsServerVoiceName). - Extend TextToSpeechService to support two engines: TtsEngine.device (FlutterTts) and TtsEngine.server (remote audio). - Wire in an AudioPlayer and optional ApiService to fetch raw audio bytes from the server and play them, with event hooks mapped to existing lifecycle callbacks. - Implement fallback to device TTS on server errors or empty responses, and ensure player lifecycle (pause/stop/dispose) is handled when using server engine. - Allow engine and preferred voice to be configured before initialization and updated at runtime via updateSettings. This enables selecting a server-side voice and using a remote TTS provider while preserving compatibility with the existing device TTS implementation.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
PODS:
|
||||
- audioplayers_darwin (0.0.1):
|
||||
- Flutter
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
@@ -84,6 +86,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
@@ -113,6 +116,8 @@ SPEC REPOS:
|
||||
- SwiftyGif
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audioplayers_darwin:
|
||||
:path: ".symlinks/plugins/audioplayers_darwin/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
file_picker:
|
||||
@@ -155,6 +160,7 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
|
||||
@@ -25,6 +25,9 @@ final class PreferenceKeys {
|
||||
static const String ttsSpeechRate = 'tts_speech_rate';
|
||||
static const String ttsPitch = 'tts_pitch';
|
||||
static const String ttsVolume = 'tts_volume';
|
||||
static const String ttsEngine = 'tts_engine'; // 'device' | 'server'
|
||||
static const String ttsServerVoiceId = 'tts_server_voice_id';
|
||||
static const String ttsServerVoiceName = 'tts_server_voice_name';
|
||||
}
|
||||
|
||||
final class LegacyPreferenceKeys {
|
||||
|
||||
@@ -1830,7 +1830,11 @@ Future<List<String>> availableVoices(Ref ref) async {
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
return await api.getAvailableVoices();
|
||||
final voices = await api.getAvailableServerVoices();
|
||||
return voices
|
||||
.map((v) => (v['name'] ?? v['id'] ?? '').toString())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
DebugLogger.error('voices-failed', scope: 'voices', error: e);
|
||||
return [];
|
||||
|
||||
@@ -2261,12 +2261,24 @@ class ApiService {
|
||||
}
|
||||
|
||||
// Audio
|
||||
Future<List<String>> getAvailableVoices() async {
|
||||
_traceApi('Fetching available voices');
|
||||
Future<List<Map<String, dynamic>>> getAvailableServerVoices() async {
|
||||
_traceApi('Fetching server TTS voices');
|
||||
final response = await _dio.get('/api/v1/audio/voices');
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final voices = data['voices'];
|
||||
if (voices is List) {
|
||||
return voices
|
||||
.whereType<Map>()
|
||||
.map((e) => e.cast<String, dynamic>())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
if (data is List) {
|
||||
return data.cast<String>();
|
||||
// Fallback: plain list of ids
|
||||
return data
|
||||
.map((e) => {'id': e.toString(), 'name': e.toString()})
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -2279,13 +2291,15 @@ class ApiService {
|
||||
_traceApi('Generating speech for text: $textPreview...');
|
||||
final response = await _dio.post(
|
||||
'/api/v1/audio/speech',
|
||||
data: {'text': text, if (voice != null) 'voice': voice},
|
||||
data: {'input': text, if (voice != null) 'voice': voice},
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
|
||||
// Return audio data as bytes
|
||||
if (response.data is List) {
|
||||
return (response.data as List).cast<int>();
|
||||
}
|
||||
final data = response.data;
|
||||
if (data is List<int>) return data;
|
||||
if (data is Uint8List) return data.toList();
|
||||
if (data is List) return (data).cast<int>();
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import 'animation_service.dart';
|
||||
|
||||
part 'settings_service.g.dart';
|
||||
|
||||
/// TTS engine selection
|
||||
enum TtsEngine { device, server }
|
||||
|
||||
/// Service for managing app-wide settings including accessibility preferences
|
||||
class SettingsService {
|
||||
static const String _reduceMotionKey = PreferenceKeys.reduceMotion;
|
||||
@@ -142,6 +145,12 @@ class SettingsService {
|
||||
ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0,
|
||||
ttsVolume:
|
||||
(box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0,
|
||||
ttsEngine: _parseTtsEngine(
|
||||
box.get(PreferenceKeys.ttsEngine) as String?,
|
||||
),
|
||||
ttsServerVoiceId: box.get(PreferenceKeys.ttsServerVoiceId) as String?,
|
||||
ttsServerVoiceName:
|
||||
box.get(PreferenceKeys.ttsServerVoiceName) as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -164,6 +173,7 @@ class SettingsService {
|
||||
PreferenceKeys.ttsSpeechRate: settings.ttsSpeechRate,
|
||||
PreferenceKeys.ttsPitch: settings.ttsPitch,
|
||||
PreferenceKeys.ttsVolume: settings.ttsVolume,
|
||||
PreferenceKeys.ttsEngine: settings.ttsEngine.name,
|
||||
};
|
||||
|
||||
await box.putAll(updates);
|
||||
@@ -185,6 +195,33 @@ class SettingsService {
|
||||
} else {
|
||||
await box.delete(PreferenceKeys.ttsVoice);
|
||||
}
|
||||
|
||||
// Server-specific voice id and friendly name
|
||||
if (settings.ttsServerVoiceId != null &&
|
||||
settings.ttsServerVoiceId!.isNotEmpty) {
|
||||
await box.put(PreferenceKeys.ttsServerVoiceId, settings.ttsServerVoiceId);
|
||||
} else {
|
||||
await box.delete(PreferenceKeys.ttsServerVoiceId);
|
||||
}
|
||||
if (settings.ttsServerVoiceName != null &&
|
||||
settings.ttsServerVoiceName!.isNotEmpty) {
|
||||
await box.put(
|
||||
PreferenceKeys.ttsServerVoiceName,
|
||||
settings.ttsServerVoiceName,
|
||||
);
|
||||
} else {
|
||||
await box.delete(PreferenceKeys.ttsServerVoiceName);
|
||||
}
|
||||
}
|
||||
|
||||
static TtsEngine _parseTtsEngine(String? raw) {
|
||||
switch ((raw ?? '').toLowerCase()) {
|
||||
case 'server':
|
||||
return TtsEngine.server;
|
||||
case 'device':
|
||||
default:
|
||||
return TtsEngine.device;
|
||||
}
|
||||
}
|
||||
|
||||
// Voice input specific settings
|
||||
@@ -314,6 +351,9 @@ class AppSettings {
|
||||
final double ttsSpeechRate;
|
||||
final double ttsPitch;
|
||||
final double ttsVolume;
|
||||
final TtsEngine ttsEngine;
|
||||
final String? ttsServerVoiceId;
|
||||
final String? ttsServerVoiceName;
|
||||
const AppSettings({
|
||||
this.reduceMotion = false,
|
||||
this.animationSpeed = 1.0,
|
||||
@@ -332,6 +372,9 @@ class AppSettings {
|
||||
this.ttsSpeechRate = 0.5,
|
||||
this.ttsPitch = 1.0,
|
||||
this.ttsVolume = 1.0,
|
||||
this.ttsEngine = TtsEngine.device,
|
||||
this.ttsServerVoiceId,
|
||||
this.ttsServerVoiceName,
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -352,6 +395,9 @@ class AppSettings {
|
||||
double? ttsSpeechRate,
|
||||
double? ttsPitch,
|
||||
double? ttsVolume,
|
||||
TtsEngine? ttsEngine,
|
||||
Object? ttsServerVoiceId = const _DefaultValue(),
|
||||
Object? ttsServerVoiceName = const _DefaultValue(),
|
||||
}) {
|
||||
return AppSettings(
|
||||
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||
@@ -375,6 +421,13 @@ class AppSettings {
|
||||
ttsSpeechRate: ttsSpeechRate ?? this.ttsSpeechRate,
|
||||
ttsPitch: ttsPitch ?? this.ttsPitch,
|
||||
ttsVolume: ttsVolume ?? this.ttsVolume,
|
||||
ttsEngine: ttsEngine ?? this.ttsEngine,
|
||||
ttsServerVoiceId: ttsServerVoiceId is _DefaultValue
|
||||
? this.ttsServerVoiceId
|
||||
: ttsServerVoiceId as String?,
|
||||
ttsServerVoiceName: ttsServerVoiceName is _DefaultValue
|
||||
? this.ttsServerVoiceName
|
||||
: ttsServerVoiceName as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -397,6 +450,9 @@ class AppSettings {
|
||||
other.ttsSpeechRate == ttsSpeechRate &&
|
||||
other.ttsPitch == ttsPitch &&
|
||||
other.ttsVolume == ttsVolume &&
|
||||
other.ttsEngine == ttsEngine &&
|
||||
other.ttsServerVoiceId == ttsServerVoiceId &&
|
||||
other.ttsServerVoiceName == ttsServerVoiceName &&
|
||||
_listEquals(other.quickPills, quickPills);
|
||||
// socketTransportMode intentionally not included in == to avoid frequent rebuilds
|
||||
}
|
||||
@@ -420,6 +476,9 @@ class AppSettings {
|
||||
ttsSpeechRate,
|
||||
ttsPitch,
|
||||
ttsVolume,
|
||||
ttsEngine,
|
||||
ttsServerVoiceId,
|
||||
ttsServerVoiceName,
|
||||
Object.hashAllUnordered(quickPills),
|
||||
);
|
||||
}
|
||||
@@ -543,6 +602,21 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
await SettingsService.saveSettings(state);
|
||||
}
|
||||
|
||||
Future<void> setTtsEngine(TtsEngine engine) async {
|
||||
state = state.copyWith(ttsEngine: engine);
|
||||
await SettingsService.saveSettings(state);
|
||||
}
|
||||
|
||||
Future<void> setTtsServerVoiceName(String? name) async {
|
||||
state = state.copyWith(ttsServerVoiceName: name);
|
||||
await SettingsService.saveSettings(state);
|
||||
}
|
||||
|
||||
Future<void> setTtsServerVoiceId(String? id) async {
|
||||
state = state.copyWith(ttsServerVoiceId: id);
|
||||
await SettingsService.saveSettings(state);
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
const defaultSettings = AppSettings();
|
||||
await SettingsService.saveSettings(defaultSettings);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/services/settings_service.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/utils/markdown_to_text.dart';
|
||||
import '../services/text_to_speech_service.dart';
|
||||
|
||||
@@ -79,11 +80,15 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
|
||||
// Listen to settings changes and update TTS when initialized
|
||||
ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
|
||||
if (_service.isInitialized && _service.isAvailable) {
|
||||
final selectedVoice = next.ttsEngine == TtsEngine.server
|
||||
? next.ttsServerVoiceId
|
||||
: next.ttsVoice;
|
||||
_service.updateSettings(
|
||||
voice: next.ttsVoice,
|
||||
voice: selectedVoice,
|
||||
speechRate: next.ttsSpeechRate,
|
||||
pitch: next.ttsPitch,
|
||||
volume: next.ttsVolume,
|
||||
engine: next.ttsEngine,
|
||||
);
|
||||
}
|
||||
}, fireImmediately: false);
|
||||
@@ -105,10 +110,13 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
|
||||
final settings = ref.read(appSettingsProvider);
|
||||
final future = _service
|
||||
.initialize(
|
||||
voice: settings.ttsVoice,
|
||||
voice: settings.ttsEngine == TtsEngine.server
|
||||
? settings.ttsServerVoiceId
|
||||
: settings.ttsVoice,
|
||||
speechRate: settings.ttsSpeechRate,
|
||||
pitch: settings.ttsPitch,
|
||||
volume: settings.ttsVolume,
|
||||
engine: settings.ttsEngine,
|
||||
)
|
||||
.then((available) {
|
||||
if (!ref.mounted) {
|
||||
@@ -289,7 +297,8 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
|
||||
}
|
||||
|
||||
final textToSpeechServiceProvider = Provider<TextToSpeechService>((ref) {
|
||||
final service = TextToSpeechService();
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
final service = TextToSpeechService(api: api);
|
||||
ref.onDispose(() {
|
||||
unawaited(service.dispose());
|
||||
});
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../../core/services/settings_service.dart';
|
||||
|
||||
/// Lightweight wrapper around FlutterTts to centralize configuration
|
||||
class TextToSpeechService {
|
||||
final FlutterTts _tts = FlutterTts();
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
final ApiService? _api;
|
||||
TtsEngine _engine = TtsEngine.device;
|
||||
String? _preferredVoice;
|
||||
bool _initialized = false;
|
||||
bool _available = false;
|
||||
bool _voiceConfigured = false;
|
||||
@@ -22,6 +30,14 @@ class TextToSpeechService {
|
||||
bool get isInitialized => _initialized;
|
||||
bool get isAvailable => _available;
|
||||
|
||||
TextToSpeechService({ApiService? api}) : _api = api {
|
||||
// Wire minimal player events to callbacks
|
||||
_player.onPlayerComplete.listen((_) => _handleComplete());
|
||||
_player.onPlayerStateChanged.listen((s) {
|
||||
if (s == PlayerState.playing) _handleStart();
|
||||
});
|
||||
}
|
||||
|
||||
/// Register callbacks for TTS lifecycle events
|
||||
void bindHandlers({
|
||||
VoidCallback? onStart,
|
||||
@@ -52,12 +68,15 @@ class TextToSpeechService {
|
||||
double speechRate = 0.5,
|
||||
double pitch = 1.0,
|
||||
double volume = 1.0,
|
||||
TtsEngine engine = TtsEngine.device,
|
||||
}) async {
|
||||
if (_initialized) {
|
||||
return _available;
|
||||
}
|
||||
|
||||
try {
|
||||
_engine = engine;
|
||||
_preferredVoice = voice;
|
||||
await _tts.awaitSpeakCompletion(false);
|
||||
|
||||
// Set volume
|
||||
@@ -97,34 +116,61 @@ class TextToSpeechService {
|
||||
}
|
||||
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
await initialize(voice: _preferredVoice, engine: _engine);
|
||||
}
|
||||
|
||||
if (_engine == TtsEngine.server && _api != null) {
|
||||
// Server-backed TTS path
|
||||
try {
|
||||
final effectiveVoice =
|
||||
(_preferredVoice == null || _preferredVoice!.trim().isEmpty)
|
||||
? 'alloy'
|
||||
: _preferredVoice!;
|
||||
|
||||
final bytes = await _api.generateSpeech(
|
||||
text: text,
|
||||
voice: effectiveVoice,
|
||||
);
|
||||
if (bytes.isEmpty) {
|
||||
throw Exception('Empty audio response');
|
||||
}
|
||||
await _player.stop();
|
||||
final data = Uint8List.fromList(bytes);
|
||||
await _player.play(BytesSource(data));
|
||||
} catch (e) {
|
||||
_onError?.call(e.toString());
|
||||
// Fallback to device TTS on failure
|
||||
await _speakOnDevice(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Device TTS path
|
||||
await _speakOnDevice(text);
|
||||
}
|
||||
|
||||
Future<void> _speakOnDevice(String text) async {
|
||||
if (!_available) {
|
||||
throw StateError('Text-to-speech is unavailable on this device');
|
||||
}
|
||||
|
||||
await _tts.stop();
|
||||
if (!_voiceConfigured) {
|
||||
await _configurePreferredVoice();
|
||||
}
|
||||
final result = await _tts.speak(text);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result is int && result != 1) {
|
||||
_onError?.call('Text-to-speech engine returned code $result');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
if (!_initialized || !_available) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await _tts.pause();
|
||||
if (_engine == TtsEngine.server) {
|
||||
await _player.pause();
|
||||
} else if (_available) {
|
||||
await _tts.pause();
|
||||
}
|
||||
} catch (e) {
|
||||
_onError?.call(e.toString());
|
||||
}
|
||||
@@ -136,7 +182,11 @@ class TextToSpeechService {
|
||||
}
|
||||
|
||||
try {
|
||||
await _tts.stop();
|
||||
if (_engine == TtsEngine.server) {
|
||||
await _player.stop();
|
||||
} else {
|
||||
await _tts.stop();
|
||||
}
|
||||
} catch (e) {
|
||||
_onError?.call(e.toString());
|
||||
}
|
||||
@@ -144,6 +194,7 @@ class TextToSpeechService {
|
||||
|
||||
Future<void> dispose() async {
|
||||
await stop();
|
||||
await _player.dispose();
|
||||
}
|
||||
|
||||
/// Update TTS settings on-the-fly
|
||||
@@ -152,12 +203,22 @@ class TextToSpeechService {
|
||||
double? speechRate,
|
||||
double? pitch,
|
||||
double? volume,
|
||||
TtsEngine? engine,
|
||||
}) async {
|
||||
if (!_initialized || !_available) {
|
||||
// Allow engine and voice to update before init
|
||||
if (engine != null) _engine = engine;
|
||||
if (voice != null) _preferredVoice = voice;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (engine != null) {
|
||||
_engine = engine;
|
||||
}
|
||||
if (voice != null) {
|
||||
_preferredVoice = voice;
|
||||
}
|
||||
if (volume != null) {
|
||||
await _tts.setVolume(volume);
|
||||
}
|
||||
@@ -167,8 +228,10 @@ class TextToSpeechService {
|
||||
if (pitch != null) {
|
||||
await _tts.setPitch(pitch);
|
||||
}
|
||||
// Set specific voice by name
|
||||
await _setVoiceByName(voice);
|
||||
// Set specific voice by name on device engine
|
||||
if (_engine == TtsEngine.device) {
|
||||
await _setVoiceByName(_preferredVoice);
|
||||
}
|
||||
} catch (e) {
|
||||
_onError?.call(e.toString());
|
||||
}
|
||||
@@ -224,7 +287,31 @@ class TextToSpeechService {
|
||||
/// Get available voices from the TTS engine
|
||||
Future<List<Map<String, dynamic>>> getAvailableVoices() async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
await initialize(voice: _preferredVoice, engine: _engine);
|
||||
}
|
||||
|
||||
if (_engine == TtsEngine.server && _api != null) {
|
||||
try {
|
||||
final serverVoices = await _api.getAvailableServerVoices();
|
||||
final mapped = serverVoices
|
||||
.map(
|
||||
(v) => {
|
||||
'name': (v['name'] ?? v['id'] ?? '').toString(),
|
||||
'locale': (v['locale'] ?? '').toString(),
|
||||
},
|
||||
)
|
||||
.where((e) => (e['name'] as String).isNotEmpty)
|
||||
.toList();
|
||||
if (mapped.isEmpty) {
|
||||
return [
|
||||
{'name': 'alloy', 'locale': ''},
|
||||
];
|
||||
}
|
||||
return mapped;
|
||||
} catch (e) {
|
||||
_onError?.call(e.toString());
|
||||
// Fall back to device voices
|
||||
}
|
||||
}
|
||||
|
||||
if (!_available) {
|
||||
|
||||
@@ -441,10 +441,97 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
TextStyle(color: theme.sidebarForeground, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
ConduitCard(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_buildIconBadge(
|
||||
context,
|
||||
UiUtils.platformIcon(
|
||||
ios: CupertinoIcons.settings,
|
||||
android: Icons.settings_voice,
|
||||
),
|
||||
color: theme.buttonPrimary,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
const Text('Engine'),
|
||||
const Spacer(),
|
||||
Wrap(
|
||||
spacing: Spacing.sm,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('On Device'),
|
||||
selected: settings.ttsEngine == TtsEngine.device,
|
||||
showCheckmark: false,
|
||||
selectedColor: theme.buttonPrimary,
|
||||
backgroundColor: theme.cardBackground,
|
||||
side: BorderSide(
|
||||
color: settings.ttsEngine == TtsEngine.device
|
||||
? theme.buttonPrimary.withValues(alpha: 0.6)
|
||||
: theme.textPrimary.withValues(alpha: 0.2),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: settings.ttsEngine == TtsEngine.device
|
||||
? theme.buttonPrimaryText
|
||||
: theme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
onSelected: (v) {
|
||||
if (v) {
|
||||
final notifier = ref.read(
|
||||
appSettingsProvider.notifier,
|
||||
);
|
||||
notifier.setTtsEngine(TtsEngine.device);
|
||||
// Keep previous voice (device voices)
|
||||
}
|
||||
},
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Server'),
|
||||
selected: settings.ttsEngine == TtsEngine.server,
|
||||
showCheckmark: false,
|
||||
selectedColor: theme.buttonPrimary,
|
||||
backgroundColor: theme.cardBackground,
|
||||
side: BorderSide(
|
||||
color: settings.ttsEngine == TtsEngine.server
|
||||
? theme.buttonPrimary.withValues(alpha: 0.6)
|
||||
: theme.textPrimary.withValues(alpha: 0.2),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: settings.ttsEngine == TtsEngine.server
|
||||
? theme.buttonPrimaryText
|
||||
: theme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
onSelected: (v) {
|
||||
if (v) {
|
||||
final notifier = ref.read(
|
||||
appSettingsProvider.notifier,
|
||||
);
|
||||
// Clear device-specific voice so server can default
|
||||
notifier.setTtsVoice(null);
|
||||
notifier.setTtsEngine(TtsEngine.server);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_ExpandableCard(
|
||||
title: l10n.ttsVoice,
|
||||
subtitle: _getDisplayVoiceName(
|
||||
settings.ttsVoice,
|
||||
settings.ttsEngine == TtsEngine.server
|
||||
? ((settings.ttsServerVoiceName ?? settings.ttsServerVoiceId) ??
|
||||
'')
|
||||
: (settings.ttsVoice ?? ''),
|
||||
l10n.ttsSystemDefault,
|
||||
),
|
||||
icon: UiUtils.platformIcon(
|
||||
@@ -466,7 +553,11 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
),
|
||||
title: l10n.ttsVoice,
|
||||
subtitle: _getDisplayVoiceName(
|
||||
settings.ttsVoice,
|
||||
settings.ttsEngine == TtsEngine.server
|
||||
? ((settings.ttsServerVoiceName ??
|
||||
settings.ttsServerVoiceId) ??
|
||||
'')
|
||||
: (settings.ttsVoice ?? ''),
|
||||
l10n.ttsSystemDefault,
|
||||
),
|
||||
onTap: () => _showVoicePickerSheet(context, ref, settings),
|
||||
@@ -616,7 +707,10 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
final theme = context.conduitTheme;
|
||||
final ttsService = ref.read(textToSpeechServiceProvider);
|
||||
|
||||
// Fetch available voices
|
||||
// Ensure the service uses the currently selected engine before fetching
|
||||
await ttsService.updateSettings(engine: settings.ttsEngine);
|
||||
|
||||
// Fetch available voices from the active engine
|
||||
final allVoices = await ttsService.getAvailableVoices();
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -729,17 +823,29 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
style:
|
||||
theme.bodyMedium?.copyWith(
|
||||
color: theme.sidebarForeground,
|
||||
fontWeight: settings.ttsVoice == null
|
||||
fontWeight:
|
||||
(settings.ttsEngine == TtsEngine.server
|
||||
? settings.ttsServerVoiceId == null
|
||||
: settings.ttsVoice == null)
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
) ??
|
||||
TextStyle(color: theme.sidebarForeground),
|
||||
),
|
||||
trailing: settings.ttsVoice == null
|
||||
trailing:
|
||||
(settings.ttsEngine == TtsEngine.server
|
||||
? settings.ttsServerVoiceId == null
|
||||
: settings.ttsVoice == null)
|
||||
? Icon(Icons.check, color: theme.buttonPrimary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref.read(appSettingsProvider.notifier).setTtsVoice(null);
|
||||
final notifier = ref.read(appSettingsProvider.notifier);
|
||||
if (settings.ttsEngine == TtsEngine.server) {
|
||||
notifier.setTtsServerVoiceId(null);
|
||||
notifier.setTtsServerVoiceName(null);
|
||||
} else {
|
||||
notifier.setTtsVoice(null);
|
||||
}
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
@@ -823,7 +929,9 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
final voiceId = _getVoiceIdentifier(voice);
|
||||
final displayName = _formatVoiceName(voice);
|
||||
final subtitle = _getVoiceSubtitle(voice);
|
||||
final isSelected = settings.ttsVoice == voiceId;
|
||||
final isSelected = settings.ttsEngine == TtsEngine.server
|
||||
? settings.ttsServerVoiceId == voiceId
|
||||
: settings.ttsVoice == voiceId;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
@@ -865,9 +973,15 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
? Icon(Icons.check, color: theme.buttonPrimary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.setTtsVoice(voiceId);
|
||||
final notifier = ref.read(
|
||||
appSettingsProvider.notifier,
|
||||
);
|
||||
if (settings.ttsEngine == TtsEngine.server) {
|
||||
notifier.setTtsServerVoiceId(voiceId);
|
||||
notifier.setTtsServerVoiceName(displayName);
|
||||
} else {
|
||||
notifier.setTtsVoice(voiceId);
|
||||
}
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
);
|
||||
|
||||
56
pubspec.lock
56
pubspec.lock
@@ -65,6 +65,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audioplayers:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audioplayers
|
||||
sha256: c05c6147124cd63e725e861335a8b4d57300b80e6e92cea7c145c739223bbaef
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
audioplayers_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_android
|
||||
sha256: b00e1a0e11365d88576320ec2d8c192bc21f1afb6c0e5995d1c57ae63156acb5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.3"
|
||||
audioplayers_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_darwin
|
||||
sha256: "3034e99a6df8d101da0f5082dcca0a2a99db62ab1d4ddb3277bed3f6f81afe08"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
audioplayers_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_linux
|
||||
sha256: "60787e73fefc4d2e0b9c02c69885402177e818e4e27ef087074cf27c02246c9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
audioplayers_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_platform_interface
|
||||
sha256: "365c547f1bb9e77d94dd1687903a668d8f7ac3409e48e6e6a3668a1ac2982adb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
audioplayers_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_web
|
||||
sha256: "22cd0173e54d92bd9b2c80b1204eb1eb159ece87475ab58c9788a70ec43c2a62"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
audioplayers_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audioplayers_windows
|
||||
sha256: "9536812c9103563644ada2ef45ae523806b0745f7a78e89d1b5fb1951de90e1a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -47,6 +47,7 @@ dependencies:
|
||||
record: ^6.1.1
|
||||
stts: ^1.2.5
|
||||
flutter_tts: ^4.2.3
|
||||
audioplayers: ^5.2.1
|
||||
image_picker: ^1.2.0
|
||||
file_picker: ^10.3.3
|
||||
path_provider: ^2.1.4
|
||||
|
||||
Reference in New Issue
Block a user