feat: Enhance TTS player functionality and UI
- Added resume functionality to TTS player when paused. - Display voice name or language in TTS player UI. - Improved error handling in reader provider with debug messages. - Updated TTS service to configure Vietnamese voice and handle platform-specific audio settings. - Removed wakelock dependency and related code. - Fixed search screen error handling. - Updated settings screen to navigate to home after sign out. - Improved splash screen with timer management. - Enhanced main app error handling with logging. - Removed unused package_info_plus and wakelock_plus dependencies. - Added environment variable support for mobile runtime. - Integrated Google Sign-In configuration for Android. - Created logging observer for Riverpod providers. - Added scripts for environment setup and Google Sign-In validation.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -29,10 +29,16 @@ class TtsPlayerWidget extends ConsumerWidget {
|
||||
if (!tts.isPlaying)
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
onPressed: () => notifier.startReading(
|
||||
content,
|
||||
paragraphIndex: tts.paragraphIndex,
|
||||
),
|
||||
onPressed: () {
|
||||
if (tts.status == TtsStatus.paused) {
|
||||
notifier.resume();
|
||||
return;
|
||||
}
|
||||
notifier.startReading(
|
||||
content,
|
||||
paragraphIndex: tts.paragraphIndex,
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
IconButton.filled(
|
||||
@@ -68,6 +74,23 @@ class TtsPlayerWidget extends ConsumerWidget {
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
if (tts.voiceName != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
tts.voiceName!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
tts.language,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/chapter_model.dart';
|
||||
import '../../../core/models/reading_settings.dart';
|
||||
@@ -21,8 +22,10 @@ final chapterProvider =
|
||||
unawaited(offlineCache.saveChapter(chapter));
|
||||
return chapter;
|
||||
} catch (_) {
|
||||
debugPrint('[READER][CHAPTER][ERROR] Failed to load chapterId=$chapterId from network, trying cache');
|
||||
final cached = await offlineCache.loadChapter(chapterId);
|
||||
if (cached != null) return cached;
|
||||
debugPrint('[READER][CHAPTER][ERROR] No cache for chapterId=$chapterId');
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
@@ -57,7 +60,6 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
||||
chapterNumber: chapterNumber,
|
||||
scrollOffset: 0,
|
||||
);
|
||||
_persistProgress(chapterId, chapterNumber, 0);
|
||||
}
|
||||
void updateScroll(double offset) {
|
||||
if (state == null) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
enum TtsStatus { idle, playing, paused }
|
||||
|
||||
@@ -9,12 +10,16 @@ class TtsState {
|
||||
final int paragraphIndex;
|
||||
final int totalParagraphs;
|
||||
final double speed;
|
||||
final String language;
|
||||
final String? voiceName;
|
||||
|
||||
const TtsState({
|
||||
this.status = TtsStatus.idle,
|
||||
this.paragraphIndex = 0,
|
||||
this.totalParagraphs = 0,
|
||||
this.speed = 1.0,
|
||||
this.language = 'vi-VN',
|
||||
this.voiceName,
|
||||
});
|
||||
|
||||
TtsState copyWith({
|
||||
@@ -22,12 +27,17 @@ class TtsState {
|
||||
int? paragraphIndex,
|
||||
int? totalParagraphs,
|
||||
double? speed,
|
||||
String? language,
|
||||
String? voiceName,
|
||||
bool clearVoiceName = false,
|
||||
}) =>
|
||||
TtsState(
|
||||
status: status ?? this.status,
|
||||
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
|
||||
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
|
||||
speed: speed ?? this.speed,
|
||||
language: language ?? this.language,
|
||||
voiceName: clearVoiceName ? null : (voiceName ?? this.voiceName),
|
||||
);
|
||||
|
||||
bool get isPlaying => status == TtsStatus.playing;
|
||||
@@ -36,17 +46,42 @@ class TtsState {
|
||||
class TtsNotifier extends StateNotifier<TtsState> {
|
||||
final FlutterTts _tts = FlutterTts();
|
||||
List<String> _paragraphs = [];
|
||||
bool _initialized = false;
|
||||
Future<void>? _initFuture;
|
||||
|
||||
TtsNotifier() : super(const TtsState()) {
|
||||
_init();
|
||||
_initFuture = _init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
await _tts.setLanguage('vi-VN');
|
||||
await _tts.awaitSpeakCompletion(true);
|
||||
await _tts.setSharedInstance(true);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await _tts.setIosAudioCategory(
|
||||
IosTextToSpeechAudioCategory.playback,
|
||||
[
|
||||
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
|
||||
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
|
||||
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
|
||||
],
|
||||
IosTextToSpeechAudioMode.defaultMode,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
await _tts.setAudioAttributesForNavigation();
|
||||
}
|
||||
|
||||
await _configureVietnameseVoice();
|
||||
await _tts.setSpeechRate(1.0);
|
||||
await _tts.setVolume(1.0);
|
||||
await _tts.setPitch(1.0);
|
||||
|
||||
_tts.setStartHandler(() {
|
||||
state = state.copyWith(status: TtsStatus.playing);
|
||||
});
|
||||
|
||||
_tts.setCompletionHandler(() {
|
||||
if (state.status == TtsStatus.playing) {
|
||||
_next();
|
||||
@@ -55,12 +90,49 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
|
||||
_tts.setErrorHandler((msg) {
|
||||
state = state.copyWith(status: TtsStatus.idle);
|
||||
WakelockPlus.disable();
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> _configureVietnameseVoice() async {
|
||||
final dynamic voicesRaw = await _tts.getVoices;
|
||||
|
||||
String? selectedName;
|
||||
String selectedLanguage = 'vi-VN';
|
||||
|
||||
if (voicesRaw is List) {
|
||||
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
|
||||
final locale = (voice['locale'] ?? voice['language'] ?? '').toString().toLowerCase();
|
||||
return locale.startsWith('vi');
|
||||
}).toList();
|
||||
|
||||
if (vietnamese.isNotEmpty) {
|
||||
final preferred = vietnamese.firstWhere(
|
||||
(voice) =>
|
||||
(voice['name']?.toString().toLowerCase().contains('female') ?? false) ||
|
||||
(voice['name']?.toString().toLowerCase().contains('natural') ?? false),
|
||||
orElse: () => vietnamese.first,
|
||||
);
|
||||
selectedName = preferred['name']?.toString();
|
||||
selectedLanguage =
|
||||
(preferred['locale'] ?? preferred['language'] ?? 'vi-VN').toString();
|
||||
}
|
||||
}
|
||||
|
||||
await _tts.setLanguage(selectedLanguage);
|
||||
if (selectedName != null) {
|
||||
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
|
||||
}
|
||||
state = state.copyWith(language: selectedLanguage, voiceName: selectedName);
|
||||
}
|
||||
|
||||
/// Start reading from [content] starting at optional [paragraphIndex].
|
||||
Future<void> startReading(String content, {int paragraphIndex = 0}) async {
|
||||
if (!_initialized) {
|
||||
await (_initFuture ?? _init());
|
||||
}
|
||||
|
||||
_paragraphs = content
|
||||
.split(RegExp(r'\n+'))
|
||||
.map((p) => p.trim())
|
||||
@@ -75,14 +147,12 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
paragraphIndex: validIndex,
|
||||
totalParagraphs: _paragraphs.length,
|
||||
);
|
||||
await WakelockPlus.enable();
|
||||
await _speak(validIndex);
|
||||
}
|
||||
|
||||
Future<void> _speak(int index) async {
|
||||
if (index >= _paragraphs.length) {
|
||||
state = state.copyWith(status: TtsStatus.idle);
|
||||
await WakelockPlus.disable();
|
||||
return;
|
||||
}
|
||||
await _tts.setSpeechRate(state.speed);
|
||||
@@ -93,7 +163,6 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
final next = state.paragraphIndex + 1;
|
||||
if (next >= state.totalParagraphs) {
|
||||
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
|
||||
await WakelockPlus.disable();
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(paragraphIndex: next);
|
||||
@@ -103,20 +172,18 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
Future<void> pause() async {
|
||||
await _tts.pause();
|
||||
state = state.copyWith(status: TtsStatus.paused);
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
|
||||
Future<void> resume() async {
|
||||
if (state.status != TtsStatus.paused) return;
|
||||
state = state.copyWith(status: TtsStatus.playing);
|
||||
await WakelockPlus.enable();
|
||||
// Use paragraph-level resume for consistent behavior across engines.
|
||||
await _speak(state.paragraphIndex);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _tts.stop();
|
||||
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
|
||||
Future<void> skipForward() async {
|
||||
@@ -126,6 +193,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
|
||||
Future<void> skipBack() async {
|
||||
await _tts.stop();
|
||||
if (state.totalParagraphs <= 0) return;
|
||||
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
|
||||
state = state.copyWith(paragraphIndex: prev);
|
||||
if (state.status == TtsStatus.playing) await _speak(prev);
|
||||
@@ -139,7 +207,6 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
@override
|
||||
void dispose() {
|
||||
_tts.stop();
|
||||
WakelockPlus.disable();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user