2025-09-20 23:58:18 +05:30
|
|
|
import 'dart:async';
|
|
|
|
|
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
|
2025-10-17 14:40:44 +05:30
|
|
|
import '../../../core/services/settings_service.dart';
|
2025-10-09 00:20:36 +05:30
|
|
|
import '../../../core/utils/markdown_to_text.dart';
|
2025-09-20 23:58:18 +05:30
|
|
|
import '../services/text_to_speech_service.dart';
|
|
|
|
|
|
|
|
|
|
enum TtsPlaybackStatus { idle, initializing, loading, speaking, paused, error }
|
|
|
|
|
|
|
|
|
|
class TextToSpeechState {
|
|
|
|
|
final bool initialized;
|
|
|
|
|
final bool available;
|
|
|
|
|
final TtsPlaybackStatus status;
|
|
|
|
|
final String? activeMessageId;
|
|
|
|
|
final String? errorMessage;
|
|
|
|
|
|
|
|
|
|
const TextToSpeechState({
|
|
|
|
|
this.initialized = false,
|
|
|
|
|
this.available = false,
|
|
|
|
|
this.status = TtsPlaybackStatus.idle,
|
|
|
|
|
this.activeMessageId,
|
|
|
|
|
this.errorMessage,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
bool get isSpeaking => status == TtsPlaybackStatus.speaking;
|
|
|
|
|
bool get isBusy =>
|
|
|
|
|
status == TtsPlaybackStatus.loading ||
|
|
|
|
|
status == TtsPlaybackStatus.initializing;
|
|
|
|
|
|
|
|
|
|
TextToSpeechState copyWith({
|
|
|
|
|
bool? initialized,
|
|
|
|
|
bool? available,
|
|
|
|
|
TtsPlaybackStatus? status,
|
|
|
|
|
String? activeMessageId,
|
|
|
|
|
bool clearActiveMessageId = false,
|
|
|
|
|
String? errorMessage,
|
|
|
|
|
bool clearErrorMessage = false,
|
|
|
|
|
}) {
|
|
|
|
|
return TextToSpeechState(
|
|
|
|
|
initialized: initialized ?? this.initialized,
|
|
|
|
|
available: available ?? this.available,
|
|
|
|
|
status: status ?? this.status,
|
|
|
|
|
activeMessageId: clearActiveMessageId
|
|
|
|
|
? null
|
|
|
|
|
: activeMessageId ?? this.activeMessageId,
|
|
|
|
|
errorMessage: clearErrorMessage
|
|
|
|
|
? null
|
|
|
|
|
: errorMessage ?? this.errorMessage,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 22:31:44 +05:30
|
|
|
class TextToSpeechController extends Notifier<TextToSpeechState> {
|
|
|
|
|
late final TextToSpeechService _service;
|
|
|
|
|
bool _handlersBound = false;
|
2025-09-20 23:58:18 +05:30
|
|
|
Future<bool>? _initializationFuture;
|
|
|
|
|
|
2025-09-21 22:31:44 +05:30
|
|
|
@override
|
|
|
|
|
TextToSpeechState build() {
|
|
|
|
|
_service = ref.watch(textToSpeechServiceProvider);
|
2025-10-17 14:40:44 +05:30
|
|
|
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!_handlersBound) {
|
|
|
|
|
_handlersBound = true;
|
|
|
|
|
_service.bindHandlers(
|
|
|
|
|
onStart: _handleStart,
|
|
|
|
|
onComplete: _handleCompletion,
|
|
|
|
|
onCancel: _handleCancellation,
|
|
|
|
|
onPause: _handlePause,
|
|
|
|
|
onContinue: _handleContinue,
|
|
|
|
|
onError: _handleError,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
ref.onDispose(() {
|
|
|
|
|
unawaited(_service.stop());
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-17 14:40:44 +05:30
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2025-09-21 22:31:44 +05:30
|
|
|
return const TextToSpeechState();
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 23:58:18 +05:30
|
|
|
Future<bool> _ensureInitialized() {
|
|
|
|
|
final existing = _initializationFuture;
|
|
|
|
|
if (existing != null) {
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.initializing,
|
|
|
|
|
clearErrorMessage: true,
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-17 14:40:44 +05:30
|
|
|
final settings = ref.read(appSettingsProvider);
|
2025-09-20 23:58:18 +05:30
|
|
|
final future = _service
|
2025-10-17 14:40:44 +05:30
|
|
|
.initialize(
|
|
|
|
|
voice: settings.ttsVoice,
|
|
|
|
|
speechRate: settings.ttsSpeechRate,
|
|
|
|
|
pitch: settings.ttsPitch,
|
|
|
|
|
volume: settings.ttsVolume,
|
|
|
|
|
)
|
2025-09-20 23:58:18 +05:30
|
|
|
.then((available) {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return available;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
initialized: true,
|
|
|
|
|
available: available,
|
|
|
|
|
status: TtsPlaybackStatus.idle,
|
|
|
|
|
);
|
|
|
|
|
return available;
|
|
|
|
|
})
|
|
|
|
|
.catchError((error, _) {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
initialized: true,
|
|
|
|
|
available: false,
|
|
|
|
|
status: TtsPlaybackStatus.error,
|
|
|
|
|
errorMessage: error.toString(),
|
|
|
|
|
clearActiveMessageId: true,
|
|
|
|
|
);
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_initializationFuture = future;
|
|
|
|
|
future.whenComplete(() {
|
|
|
|
|
_initializationFuture = null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return future;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> toggleForMessage({
|
|
|
|
|
required String messageId,
|
|
|
|
|
required String text,
|
|
|
|
|
}) async {
|
|
|
|
|
if (text.trim().isEmpty) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 20:18:21 +05:30
|
|
|
final isCurrentlyActive =
|
|
|
|
|
state.activeMessageId == messageId &&
|
|
|
|
|
state.status != TtsPlaybackStatus.idle &&
|
|
|
|
|
state.status != TtsPlaybackStatus.error;
|
|
|
|
|
|
|
|
|
|
if (isCurrentlyActive) {
|
|
|
|
|
await stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 23:58:18 +05:30
|
|
|
final available = await _ensureInitialized();
|
|
|
|
|
if (!available) {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.error,
|
|
|
|
|
errorMessage: 'Text-to-speech unavailable',
|
|
|
|
|
clearActiveMessageId: true,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.loading,
|
|
|
|
|
activeMessageId: messageId,
|
|
|
|
|
clearErrorMessage: true,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-09 00:20:36 +05:30
|
|
|
// Convert markdown to clean text for TTS
|
|
|
|
|
final cleanText = MarkdownToText.convert(text);
|
|
|
|
|
if (cleanText.isEmpty) {
|
|
|
|
|
// No speakable content
|
|
|
|
|
if (!ref.mounted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.idle,
|
|
|
|
|
clearActiveMessageId: true,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await _service.speak(cleanText);
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (state.status == TtsPlaybackStatus.loading) {
|
|
|
|
|
state = state.copyWith(status: TtsPlaybackStatus.speaking);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.error,
|
|
|
|
|
errorMessage: e.toString(),
|
|
|
|
|
clearActiveMessageId: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> pause() async {
|
|
|
|
|
if (!state.initialized || !state.available) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await _service.pause();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> stop() async {
|
|
|
|
|
await _service.stop();
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.idle,
|
|
|
|
|
clearActiveMessageId: true,
|
|
|
|
|
clearErrorMessage: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleStart() {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(status: TtsPlaybackStatus.speaking);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleCompletion() {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.idle,
|
|
|
|
|
clearActiveMessageId: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleCancellation() {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.idle,
|
|
|
|
|
clearActiveMessageId: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handlePause() {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(status: TtsPlaybackStatus.paused);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleContinue() {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(status: TtsPlaybackStatus.speaking);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _handleError(String message) {
|
2025-09-21 22:31:44 +05:30
|
|
|
if (!ref.mounted) {
|
2025-09-20 23:58:18 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
status: TtsPlaybackStatus.error,
|
|
|
|
|
errorMessage: message,
|
|
|
|
|
clearActiveMessageId: true,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final textToSpeechServiceProvider = Provider<TextToSpeechService>((ref) {
|
|
|
|
|
final service = TextToSpeechService();
|
|
|
|
|
ref.onDispose(() {
|
|
|
|
|
unawaited(service.dispose());
|
|
|
|
|
});
|
|
|
|
|
return service;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final textToSpeechControllerProvider =
|
2025-09-21 22:31:44 +05:30
|
|
|
NotifierProvider<TextToSpeechController, TextToSpeechState>(
|
|
|
|
|
TextToSpeechController.new,
|
|
|
|
|
);
|