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:
cogwheel0
2025-10-23 16:31:15 +05:30
parent 2337568baf
commit 561e7dd616
10 changed files with 404 additions and 36 deletions

View File

@@ -1,4 +1,6 @@
PODS: PODS:
- audioplayers_darwin (0.0.1):
- Flutter
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- DKImagePickerController/Core (4.3.9): - DKImagePickerController/Core (4.3.9):
@@ -84,6 +86,7 @@ PODS:
- FlutterMacOS - FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
@@ -113,6 +116,8 @@ SPEC REPOS:
- SwiftyGif - SwiftyGif
EXTERNAL SOURCES: EXTERNAL SOURCES:
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/ios"
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios" :path: ".symlinks/plugins/connectivity_plus/ios"
file_picker: file_picker:
@@ -155,6 +160,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS: SPEC CHECKSUMS:
audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60

View File

@@ -25,6 +25,9 @@ final class PreferenceKeys {
static const String ttsSpeechRate = 'tts_speech_rate'; static const String ttsSpeechRate = 'tts_speech_rate';
static const String ttsPitch = 'tts_pitch'; static const String ttsPitch = 'tts_pitch';
static const String ttsVolume = 'tts_volume'; 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 { final class LegacyPreferenceKeys {

View File

@@ -1830,7 +1830,11 @@ Future<List<String>> availableVoices(Ref ref) async {
if (api == null) return []; if (api == null) return [];
try { 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) { } catch (e) {
DebugLogger.error('voices-failed', scope: 'voices', error: e); DebugLogger.error('voices-failed', scope: 'voices', error: e);
return []; return [];

View File

@@ -2261,12 +2261,24 @@ class ApiService {
} }
// Audio // Audio
Future<List<String>> getAvailableVoices() async { Future<List<Map<String, dynamic>>> getAvailableServerVoices() async {
_traceApi('Fetching available voices'); _traceApi('Fetching server TTS voices');
final response = await _dio.get('/api/v1/audio/voices'); final response = await _dio.get('/api/v1/audio/voices');
final data = response.data; 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) { 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 []; return [];
} }
@@ -2279,13 +2291,15 @@ class ApiService {
_traceApi('Generating speech for text: $textPreview...'); _traceApi('Generating speech for text: $textPreview...');
final response = await _dio.post( final response = await _dio.post(
'/api/v1/audio/speech', '/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 // Return audio data as bytes
if (response.data is List) { final data = response.data;
return (response.data as List).cast<int>(); if (data is List<int>) return data;
} if (data is Uint8List) return data.toList();
if (data is List) return (data).cast<int>();
return []; return [];
} }

View File

@@ -8,6 +8,9 @@ import 'animation_service.dart';
part 'settings_service.g.dart'; part 'settings_service.g.dart';
/// TTS engine selection
enum TtsEngine { device, server }
/// Service for managing app-wide settings including accessibility preferences /// Service for managing app-wide settings including accessibility preferences
class SettingsService { class SettingsService {
static const String _reduceMotionKey = PreferenceKeys.reduceMotion; static const String _reduceMotionKey = PreferenceKeys.reduceMotion;
@@ -142,6 +145,12 @@ class SettingsService {
ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0, ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0,
ttsVolume: ttsVolume:
(box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0, (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.ttsSpeechRate: settings.ttsSpeechRate,
PreferenceKeys.ttsPitch: settings.ttsPitch, PreferenceKeys.ttsPitch: settings.ttsPitch,
PreferenceKeys.ttsVolume: settings.ttsVolume, PreferenceKeys.ttsVolume: settings.ttsVolume,
PreferenceKeys.ttsEngine: settings.ttsEngine.name,
}; };
await box.putAll(updates); await box.putAll(updates);
@@ -185,6 +195,33 @@ class SettingsService {
} else { } else {
await box.delete(PreferenceKeys.ttsVoice); 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 // Voice input specific settings
@@ -314,6 +351,9 @@ class AppSettings {
final double ttsSpeechRate; final double ttsSpeechRate;
final double ttsPitch; final double ttsPitch;
final double ttsVolume; final double ttsVolume;
final TtsEngine ttsEngine;
final String? ttsServerVoiceId;
final String? ttsServerVoiceName;
const AppSettings({ const AppSettings({
this.reduceMotion = false, this.reduceMotion = false,
this.animationSpeed = 1.0, this.animationSpeed = 1.0,
@@ -332,6 +372,9 @@ class AppSettings {
this.ttsSpeechRate = 0.5, this.ttsSpeechRate = 0.5,
this.ttsPitch = 1.0, this.ttsPitch = 1.0,
this.ttsVolume = 1.0, this.ttsVolume = 1.0,
this.ttsEngine = TtsEngine.device,
this.ttsServerVoiceId,
this.ttsServerVoiceName,
}); });
AppSettings copyWith({ AppSettings copyWith({
@@ -352,6 +395,9 @@ class AppSettings {
double? ttsSpeechRate, double? ttsSpeechRate,
double? ttsPitch, double? ttsPitch,
double? ttsVolume, double? ttsVolume,
TtsEngine? ttsEngine,
Object? ttsServerVoiceId = const _DefaultValue(),
Object? ttsServerVoiceName = const _DefaultValue(),
}) { }) {
return AppSettings( return AppSettings(
reduceMotion: reduceMotion ?? this.reduceMotion, reduceMotion: reduceMotion ?? this.reduceMotion,
@@ -375,6 +421,13 @@ class AppSettings {
ttsSpeechRate: ttsSpeechRate ?? this.ttsSpeechRate, ttsSpeechRate: ttsSpeechRate ?? this.ttsSpeechRate,
ttsPitch: ttsPitch ?? this.ttsPitch, ttsPitch: ttsPitch ?? this.ttsPitch,
ttsVolume: ttsVolume ?? this.ttsVolume, 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.ttsSpeechRate == ttsSpeechRate &&
other.ttsPitch == ttsPitch && other.ttsPitch == ttsPitch &&
other.ttsVolume == ttsVolume && other.ttsVolume == ttsVolume &&
other.ttsEngine == ttsEngine &&
other.ttsServerVoiceId == ttsServerVoiceId &&
other.ttsServerVoiceName == ttsServerVoiceName &&
_listEquals(other.quickPills, quickPills); _listEquals(other.quickPills, quickPills);
// socketTransportMode intentionally not included in == to avoid frequent rebuilds // socketTransportMode intentionally not included in == to avoid frequent rebuilds
} }
@@ -420,6 +476,9 @@ class AppSettings {
ttsSpeechRate, ttsSpeechRate,
ttsPitch, ttsPitch,
ttsVolume, ttsVolume,
ttsEngine,
ttsServerVoiceId,
ttsServerVoiceName,
Object.hashAllUnordered(quickPills), Object.hashAllUnordered(quickPills),
); );
} }
@@ -543,6 +602,21 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
await SettingsService.saveSettings(state); 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 { Future<void> resetToDefaults() async {
const defaultSettings = AppSettings(); const defaultSettings = AppSettings();
await SettingsService.saveSettings(defaultSettings); await SettingsService.saveSettings(defaultSettings);

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/settings_service.dart'; import '../../../core/services/settings_service.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/utils/markdown_to_text.dart'; import '../../../core/utils/markdown_to_text.dart';
import '../services/text_to_speech_service.dart'; import '../services/text_to_speech_service.dart';
@@ -79,11 +80,15 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
// Listen to settings changes and update TTS when initialized // Listen to settings changes and update TTS when initialized
ref.listen<AppSettings>(appSettingsProvider, (previous, next) { ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
if (_service.isInitialized && _service.isAvailable) { if (_service.isInitialized && _service.isAvailable) {
final selectedVoice = next.ttsEngine == TtsEngine.server
? next.ttsServerVoiceId
: next.ttsVoice;
_service.updateSettings( _service.updateSettings(
voice: next.ttsVoice, voice: selectedVoice,
speechRate: next.ttsSpeechRate, speechRate: next.ttsSpeechRate,
pitch: next.ttsPitch, pitch: next.ttsPitch,
volume: next.ttsVolume, volume: next.ttsVolume,
engine: next.ttsEngine,
); );
} }
}, fireImmediately: false); }, fireImmediately: false);
@@ -105,10 +110,13 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
final settings = ref.read(appSettingsProvider); final settings = ref.read(appSettingsProvider);
final future = _service final future = _service
.initialize( .initialize(
voice: settings.ttsVoice, voice: settings.ttsEngine == TtsEngine.server
? settings.ttsServerVoiceId
: settings.ttsVoice,
speechRate: settings.ttsSpeechRate, speechRate: settings.ttsSpeechRate,
pitch: settings.ttsPitch, pitch: settings.ttsPitch,
volume: settings.ttsVolume, volume: settings.ttsVolume,
engine: settings.ttsEngine,
) )
.then((available) { .then((available) {
if (!ref.mounted) { if (!ref.mounted) {
@@ -289,7 +297,8 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
} }
final textToSpeechServiceProvider = Provider<TextToSpeechService>((ref) { final textToSpeechServiceProvider = Provider<TextToSpeechService>((ref) {
final service = TextToSpeechService(); final api = ref.watch(apiServiceProvider);
final service = TextToSpeechService(api: api);
ref.onDispose(() { ref.onDispose(() {
unawaited(service.dispose()); unawaited(service.dispose());
}); });

View File

@@ -1,13 +1,21 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_tts/flutter_tts.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 /// Lightweight wrapper around FlutterTts to centralize configuration
class TextToSpeechService { class TextToSpeechService {
final FlutterTts _tts = FlutterTts(); final FlutterTts _tts = FlutterTts();
final AudioPlayer _player = AudioPlayer();
final ApiService? _api;
TtsEngine _engine = TtsEngine.device;
String? _preferredVoice;
bool _initialized = false; bool _initialized = false;
bool _available = false; bool _available = false;
bool _voiceConfigured = false; bool _voiceConfigured = false;
@@ -22,6 +30,14 @@ class TextToSpeechService {
bool get isInitialized => _initialized; bool get isInitialized => _initialized;
bool get isAvailable => _available; 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 /// Register callbacks for TTS lifecycle events
void bindHandlers({ void bindHandlers({
VoidCallback? onStart, VoidCallback? onStart,
@@ -52,12 +68,15 @@ class TextToSpeechService {
double speechRate = 0.5, double speechRate = 0.5,
double pitch = 1.0, double pitch = 1.0,
double volume = 1.0, double volume = 1.0,
TtsEngine engine = TtsEngine.device,
}) async { }) async {
if (_initialized) { if (_initialized) {
return _available; return _available;
} }
try { try {
_engine = engine;
_preferredVoice = voice;
await _tts.awaitSpeakCompletion(false); await _tts.awaitSpeakCompletion(false);
// Set volume // Set volume
@@ -97,34 +116,61 @@ class TextToSpeechService {
} }
if (!_initialized) { 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) { if (!_available) {
throw StateError('Text-to-speech is unavailable on this device'); throw StateError('Text-to-speech is unavailable on this device');
} }
await _tts.stop(); await _tts.stop();
if (!_voiceConfigured) { if (!_voiceConfigured) {
await _configurePreferredVoice(); await _configurePreferredVoice();
} }
final result = await _tts.speak(text); final result = await _tts.speak(text);
if (result == null) {
return;
}
if (result is int && result != 1) { if (result is int && result != 1) {
_onError?.call('Text-to-speech engine returned code $result'); _onError?.call('Text-to-speech engine returned code $result');
} }
} }
Future<void> pause() async { Future<void> pause() async {
if (!_initialized || !_available) { if (!_initialized) return;
return;
}
try { try {
if (_engine == TtsEngine.server) {
await _player.pause();
} else if (_available) {
await _tts.pause(); await _tts.pause();
}
} catch (e) { } catch (e) {
_onError?.call(e.toString()); _onError?.call(e.toString());
} }
@@ -136,7 +182,11 @@ class TextToSpeechService {
} }
try { try {
if (_engine == TtsEngine.server) {
await _player.stop();
} else {
await _tts.stop(); await _tts.stop();
}
} catch (e) { } catch (e) {
_onError?.call(e.toString()); _onError?.call(e.toString());
} }
@@ -144,6 +194,7 @@ class TextToSpeechService {
Future<void> dispose() async { Future<void> dispose() async {
await stop(); await stop();
await _player.dispose();
} }
/// Update TTS settings on-the-fly /// Update TTS settings on-the-fly
@@ -152,12 +203,22 @@ class TextToSpeechService {
double? speechRate, double? speechRate,
double? pitch, double? pitch,
double? volume, double? volume,
TtsEngine? engine,
}) async { }) async {
if (!_initialized || !_available) { if (!_initialized || !_available) {
// Allow engine and voice to update before init
if (engine != null) _engine = engine;
if (voice != null) _preferredVoice = voice;
return; return;
} }
try { try {
if (engine != null) {
_engine = engine;
}
if (voice != null) {
_preferredVoice = voice;
}
if (volume != null) { if (volume != null) {
await _tts.setVolume(volume); await _tts.setVolume(volume);
} }
@@ -167,8 +228,10 @@ class TextToSpeechService {
if (pitch != null) { if (pitch != null) {
await _tts.setPitch(pitch); await _tts.setPitch(pitch);
} }
// Set specific voice by name // Set specific voice by name on device engine
await _setVoiceByName(voice); if (_engine == TtsEngine.device) {
await _setVoiceByName(_preferredVoice);
}
} catch (e) { } catch (e) {
_onError?.call(e.toString()); _onError?.call(e.toString());
} }
@@ -224,7 +287,31 @@ class TextToSpeechService {
/// Get available voices from the TTS engine /// Get available voices from the TTS engine
Future<List<Map<String, dynamic>>> getAvailableVoices() async { Future<List<Map<String, dynamic>>> getAvailableVoices() async {
if (!_initialized) { 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) { if (!_available) {

View File

@@ -441,10 +441,97 @@ class AppCustomizationPage extends ConsumerWidget {
TextStyle(color: theme.sidebarForeground, fontSize: 18), TextStyle(color: theme.sidebarForeground, fontSize: 18),
), ),
const SizedBox(height: Spacing.sm), 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( _ExpandableCard(
title: l10n.ttsVoice, title: l10n.ttsVoice,
subtitle: _getDisplayVoiceName( subtitle: _getDisplayVoiceName(
settings.ttsVoice, settings.ttsEngine == TtsEngine.server
? ((settings.ttsServerVoiceName ?? settings.ttsServerVoiceId) ??
'')
: (settings.ttsVoice ?? ''),
l10n.ttsSystemDefault, l10n.ttsSystemDefault,
), ),
icon: UiUtils.platformIcon( icon: UiUtils.platformIcon(
@@ -466,7 +553,11 @@ class AppCustomizationPage extends ConsumerWidget {
), ),
title: l10n.ttsVoice, title: l10n.ttsVoice,
subtitle: _getDisplayVoiceName( subtitle: _getDisplayVoiceName(
settings.ttsVoice, settings.ttsEngine == TtsEngine.server
? ((settings.ttsServerVoiceName ??
settings.ttsServerVoiceId) ??
'')
: (settings.ttsVoice ?? ''),
l10n.ttsSystemDefault, l10n.ttsSystemDefault,
), ),
onTap: () => _showVoicePickerSheet(context, ref, settings), onTap: () => _showVoicePickerSheet(context, ref, settings),
@@ -616,7 +707,10 @@ class AppCustomizationPage extends ConsumerWidget {
final theme = context.conduitTheme; final theme = context.conduitTheme;
final ttsService = ref.read(textToSpeechServiceProvider); 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(); final allVoices = await ttsService.getAvailableVoices();
if (!context.mounted) return; if (!context.mounted) return;
@@ -729,17 +823,29 @@ class AppCustomizationPage extends ConsumerWidget {
style: style:
theme.bodyMedium?.copyWith( theme.bodyMedium?.copyWith(
color: theme.sidebarForeground, color: theme.sidebarForeground,
fontWeight: settings.ttsVoice == null fontWeight:
(settings.ttsEngine == TtsEngine.server
? settings.ttsServerVoiceId == null
: settings.ttsVoice == null)
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
) ?? ) ??
TextStyle(color: theme.sidebarForeground), 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) ? Icon(Icons.check, color: theme.buttonPrimary)
: null, : null,
onTap: () { 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(); Navigator.of(sheetContext).pop();
}, },
), ),
@@ -823,7 +929,9 @@ class AppCustomizationPage extends ConsumerWidget {
final voiceId = _getVoiceIdentifier(voice); final voiceId = _getVoiceIdentifier(voice);
final displayName = _formatVoiceName(voice); final displayName = _formatVoiceName(voice);
final subtitle = _getVoiceSubtitle(voice); final subtitle = _getVoiceSubtitle(voice);
final isSelected = settings.ttsVoice == voiceId; final isSelected = settings.ttsEngine == TtsEngine.server
? settings.ttsServerVoiceId == voiceId
: settings.ttsVoice == voiceId;
return ListTile( return ListTile(
leading: Icon( leading: Icon(
@@ -865,9 +973,15 @@ class AppCustomizationPage extends ConsumerWidget {
? Icon(Icons.check, color: theme.buttonPrimary) ? Icon(Icons.check, color: theme.buttonPrimary)
: null, : null,
onTap: () { onTap: () {
ref final notifier = ref.read(
.read(appSettingsProvider.notifier) appSettingsProvider.notifier,
.setTtsVoice(voiceId); );
if (settings.ttsEngine == TtsEngine.server) {
notifier.setTtsServerVoiceId(voiceId);
notifier.setTtsServerVoiceName(displayName);
} else {
notifier.setTtsVoice(voiceId);
}
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
}, },
); );

View File

@@ -65,6 +65,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:

View File

@@ -47,6 +47,7 @@ dependencies:
record: ^6.1.1 record: ^6.1.1
stts: ^1.2.5 stts: ^1.2.5
flutter_tts: ^4.2.3 flutter_tts: ^4.2.3
audioplayers: ^5.2.1
image_picker: ^1.2.0 image_picker: ^1.2.0
file_picker: ^10.3.3 file_picker: ^10.3.3
path_provider: ^2.1.4 path_provider: ^2.1.4