1 Commits

Author SHA1 Message Date
virtus 66613857e8 Refactor chapter list provider and improve TTS functionality
Build Android APK / build-apk (push) Successful in 12m10s
Build Android AAB / build-aab (push) Successful in 19m35s
- Removed the constant chapterPageSize and refactored ChapterListQuery to use a simpler approach for fetching chapters.
- Updated the chapter list provider to handle fetching all chapters in a single request with pagination.
- Enhanced error handling for fetching chapters by resolving canonical IDs when necessary.
- Modified TTS functionality to ensure proper handling of Android fallback reading and improved error management.
- Added a new setting to enable/disable TTS on sentence tap.
- Updated UI components in the reader screen for better user experience and added navigation buttons for chapters.
- Bumped version to 1.0.3+4 in pubspec.yaml.
2026-04-24 03:03:32 +07:00
11 changed files with 1112 additions and 447 deletions
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
@@ -93,21 +94,27 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
language: String,
voiceName: String?,
backgroundModeEnabled: Boolean,
) {
ContextCompat.startForegroundService(
context,
Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_START_READING
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
putExtra(EXTRA_START_INDEX, startIndex)
putExtra(EXTRA_CONTENT_KEY, contentKey)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_SPEED, speed)
putExtra(EXTRA_LANGUAGE, language)
putExtra(EXTRA_VOICE_NAME, voiceName)
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
},
)
): Boolean {
return try {
ContextCompat.startForegroundService(
context,
Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_START_READING
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
putExtra(EXTRA_START_INDEX, startIndex)
putExtra(EXTRA_CONTENT_KEY, contentKey)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_SPEED, speed)
putExtra(EXTRA_LANGUAGE, language)
putExtra(EXTRA_VOICE_NAME, voiceName)
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
},
)
true
} catch (e: Throwable) {
Log.e(TAG, "startForegroundService blocked or failed", e)
false
}
}
fun pause(context: Context) =
@@ -905,7 +912,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
@SuppressLint("MissingPermission")
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
// Avoid adaptive launcher icon for foreground notifications on strict OEM ROMs.
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(title ?: appLabel())
.setContentText(currentProgressLabel())
.setContentIntent(buildLaunchIntent())
@@ -995,8 +1003,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
// to prevent Android from killing us.
if (status == "playing" || status == "paused") {
val notification = buildNotification()
startForeground(NOTIFICATION_ID, notification)
isForegroundActive = true
isForegroundActive = startForegroundCompat(notification)
}
return
}
@@ -1005,8 +1012,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
"playing", "paused" -> {
val notification = buildNotification()
if (!isForegroundActive) {
startForeground(NOTIFICATION_ID, notification)
isForegroundActive = true
isForegroundActive = startForegroundCompat(notification)
} else {
notificationManager.notify(NOTIFICATION_ID, notification)
}
@@ -1021,6 +1027,24 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
}
}
private fun startForegroundCompat(notification: android.app.Notification): Boolean {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
true
} catch (e: Throwable) {
Log.e(TAG, "startForeground failed", e)
false
}
}
private fun publishSnapshot() {
val segment = currentSegment()
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
+19 -1
View File
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
import 'novel_model.dart';
enum BookmarkType {
reading('reading'),
bookmarked('bookmarked');
const BookmarkType(this.value);
final String value;
static BookmarkType fromString(String? str) {
return values.firstWhere(
(e) => e.value == str,
orElse: () => BookmarkType.bookmarked,
);
}
}
class BookmarkModel extends Equatable {
const BookmarkModel({
required this.id,
required this.novelId,
this.type = BookmarkType.bookmarked,
this.lastChapterId,
this.lastChapterNumber,
this.readChapters = const [],
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
final String id;
final String novelId;
final BookmarkType type;
final String? lastChapterId;
final int? lastChapterNumber;
final List<int> readChapters;
@@ -22,6 +39,7 @@ class BookmarkModel extends Equatable {
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
id: json['id'] as String,
novelId: json['novelId'] as String,
type: BookmarkType.fromString(json['type'] as String?),
lastChapterId: json['lastChapterId'] as String?,
lastChapterNumber: json['lastChapterNumber'] as int?,
readChapters: (json['readChapters'] as List<dynamic>?)
@@ -34,5 +52,5 @@ class BookmarkModel extends Equatable {
);
@override
List<Object?> get props => [id, novelId];
List<Object?> get props => [id, novelId, type];
}
+6
View File
@@ -10,6 +10,7 @@ class ReadingSettings {
this.horizontalPadding = 20,
this.paragraphSpacing = 24,
this.textAlign = 'left',
this.enableSentenceTapTts = false,
});
final double fontSize;
@@ -22,6 +23,7 @@ class ReadingSettings {
final double horizontalPadding;
final double paragraphSpacing;
final String textAlign;
final bool enableSentenceTapTts;
ReadingSettings copyWith({
double? fontSize,
@@ -34,6 +36,7 @@ class ReadingSettings {
double? horizontalPadding,
double? paragraphSpacing,
String? textAlign,
bool? enableSentenceTapTts,
}) =>
ReadingSettings(
fontSize: fontSize ?? this.fontSize,
@@ -46,6 +49,7 @@ class ReadingSettings {
horizontalPadding: horizontalPadding ?? this.horizontalPadding,
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
textAlign: textAlign ?? this.textAlign,
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
);
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
@@ -60,6 +64,7 @@ class ReadingSettings {
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
textAlign: json['textAlign'] as String? ?? 'left',
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
);
Map<String, dynamic> toJson() => {
@@ -73,5 +78,6 @@ class ReadingSettings {
'horizontalPadding': horizontalPadding,
'paragraphSpacing': paragraphSpacing,
'textAlign': textAlign,
'enableSentenceTapTts': enableSentenceTapTts,
};
}
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/bookmark_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../../novel/providers/novels_provider.dart';
import '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart';
@@ -50,7 +51,7 @@ class BookshelfScreen extends ConsumerWidget {
return Scaffold(
body: DefaultTabController(
length: 3,
length: 2,
child: Column(
children: [
MainAppHeader(
@@ -68,9 +69,8 @@ class BookshelfScreen extends ConsumerWidget {
unselectedLabelColor: Colors.white70,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: 'Đã đọc'),
Tab(text: 'Đã lưu'),
Tab(text: 'Đang mở'),
Tab(text: 'Đang đọc'),
Tab(text: 'Đánh dấu'),
],
),
),
@@ -93,23 +93,18 @@ class BookshelfScreen extends ConsumerWidget {
),
),
data: (bookmarks) {
final readItems = bookmarks.where((e) => e.readChapters.isNotEmpty).toList();
final savedItems = bookmarks;
final openingItems = bookmarks.where((e) => e.lastChapterId != null).toList();
final readingItems = ref.watch(readingBookmarksProvider);
final bookmarkedItems = ref.watch(savedBookmarksProvider);
return TabBarView(
children: [
_BookshelfList(
bookmarks: readItems,
emptyLabel: 'Chưa có truyện đã đọc.',
bookmarks: readingItems,
emptyLabel: 'Chưa có truyện đang đọc.',
),
_BookshelfList(
bookmarks: savedItems,
emptyLabel: 'Chưa có truyện nào trong tủ sách.',
),
_BookshelfList(
bookmarks: openingItems,
emptyLabel: 'Chưa có truyện đang mở.',
bookmarks: bookmarkedItems,
emptyLabel: 'Chưa có truyện đánh dấu.',
),
],
);
@@ -150,20 +145,57 @@ class _BookshelfList extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
itemCount: bookmarks.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => _BookmarkTile(bookmark: bookmarks[index]),
itemBuilder: (context, index) {
final bookmark = bookmarks[index];
return _BookmarkTile(
bookmark: bookmark,
onRemove: () => ref
.read(bookshelfProvider.notifier)
.removeFromShelf(bookmark.novelId, bookmark.type),
);
},
),
);
}
}
class _BookmarkTile extends StatelessWidget {
class _BookmarkTile extends ConsumerWidget {
final BookmarkModel bookmark;
const _BookmarkTile({required this.bookmark});
final VoidCallback onRemove;
const _BookmarkTile({
required this.bookmark,
required this.onRemove,
});
Future<void> _openContinueReader(BuildContext context, WidgetRef ref) async {
var targetChapterId = bookmark.lastChapterId;
if (targetChapterId == null || targetChapterId.isEmpty) {
try {
final chapters = await ref.read(
chapterListProvider(bookmark.novelId).future,
);
if (chapters.isNotEmpty) {
targetChapterId = chapters.first.id;
}
} catch (_) {
// Fall through to novel detail when chapter lookup fails.
}
}
if (!context.mounted) return;
if (targetChapterId != null && targetChapterId.isNotEmpty) {
context.push(RouteNames.readerChapter(targetChapterId));
return;
}
context.push(RouteNames.novelDetail(bookmark.novelId));
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final novel = bookmark.novel;
return Container(
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
@@ -172,7 +204,7 @@ class _BookmarkTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
@@ -211,7 +243,10 @@ class _BookmarkTile extends StatelessWidget {
),
),
const SizedBox(width: 8),
const Icon(Icons.close_rounded, size: 20),
GestureDetector(
onTap: onRemove,
child: const Icon(Icons.close_rounded, size: 20),
),
],
),
const SizedBox(height: 8),
@@ -249,7 +284,7 @@ class _BookmarkTile extends StatelessWidget {
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
onPressed: () => _openContinueReader(context, ref),
icon: const Icon(Icons.menu_book_rounded),
label: const Text('Đọc tiếp'),
style: FilledButton.styleFrom(
@@ -258,22 +293,11 @@ class _BookmarkTile extends StatelessWidget {
),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
icon: const Icon(Icons.headphones_rounded),
label: const Text('Nghe tiếp'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
);
}
}
@@ -44,6 +44,22 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
bool isBookmarked(String novelId) {
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
}
Future<void> removeFromShelf(String novelId, BookmarkType type) async {
try {
final client = _ref.read(apiClientProvider);
await client.dio.delete(
'/api/user/bookmarks/$novelId',
queryParameters: {'type': type.value},
);
final current = state.valueOrNull ?? [];
state = AsyncValue.data(
current.where((b) => b.novelId != novelId || b.type != type).toList(),
);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
final bookshelfProvider =
@@ -51,6 +67,16 @@ final bookshelfProvider =
return BookshelfNotifier(ref);
});
final readingBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
return bookmarks.where((b) => b.type == BookmarkType.reading).toList();
});
final savedBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
return bookmarks.where((b) => b.type == BookmarkType.bookmarked).toList();
});
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
final bookshelf = ref.watch(bookshelfProvider);
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,6 @@ import '../../../core/models/novel_model.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/network/providers.dart';
const chapterPageSize = 50;
// ─── Browse / Search ──────────────────────────────────────────────────────────
class BrowseParams {
@@ -28,11 +26,11 @@ class BrowseParams {
if (raw == null || raw.isEmpty) return null;
switch (raw.toLowerCase()) {
case 'ongoing':
return 'Đang ra';
return 'ONGOING';
case 'completed':
return 'Hoàn thành';
return 'COMPLETED';
case 'hiatus':
return 'Tạm ngưng';
return 'HIATUS';
default:
return raw;
}
@@ -189,77 +187,51 @@ final novelDetailProvider =
// ─── Chapter List ─────────────────────────────────────────────────────────────
class ChapterListQuery {
const ChapterListQuery({required this.novelId, this.page = 1});
final String novelId;
final int page;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ChapterListQuery &&
other.novelId == novelId &&
other.page == page;
}
@override
int get hashCode => Object.hash(novelId, page);
}
class ChapterListPage {
const ChapterListPage({
required this.chapters,
required this.totalChapters,
required this.totalPages,
required this.currentPage,
});
final List<ChapterListItem> chapters;
final int totalChapters;
final int totalPages;
final int currentPage;
}
final chapterListProvider =
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
final client = ref.read(apiClientProvider);
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
final res = await client.dio.get(
'/api/truyen/$idOrSlug/chapters',
queryParameters: {
'page': query.page,
'limit': chapterPageSize,
},
);
return res.data as Map<String, dynamic>;
Future<List<ChapterListItem>> fetchAllChapters(String idOrSlug) async {
const limit = 500;
var page = 1;
var totalPages = 1;
final items = <ChapterListItem>[];
while (page <= totalPages) {
final res = await client.dio.get(
'/api/truyen/$idOrSlug/chapters',
queryParameters: {'page': page, 'limit': limit},
);
final data = res.data as Map<String, dynamic>;
final chapters = data['chapters'] as List? ?? const [];
items.addAll(
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)),
);
final apiTotalPages = (data['totalPages'] as num?)?.toInt() ?? 1;
totalPages = apiTotalPages > 0 ? apiTotalPages : 1;
page += 1;
}
return items;
}
var data = await fetchChapterPage(query.novelId);
var chapters = data['chapters'] as List? ?? const [];
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
// first request can return empty list. Resolve canonical id and retry once.
if (chapters.isEmpty) {
try {
return await fetchAllChapters(novelId);
} catch (_) {
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
// first request can return empty list. Resolve canonical id and retry once.
try {
final novelRes = await client.dio.get('/api/novels/${query.novelId}');
final novelRes = await client.dio.get('/api/novels/$novelId');
final novelData = novelRes.data as Map<String, dynamic>;
final canonicalId = novelData['id'] as String?;
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) {
data = await fetchChapterPage(canonicalId);
chapters = data['chapters'] as List? ?? const [];
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
return await fetchAllChapters(canonicalId);
}
} catch (_) {
// Keep original empty list when fallback resolution fails.
}
rethrow;
}
return ChapterListPage(
chapters:
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
);
});
@@ -179,7 +179,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}
void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
if (tts.status == TtsStatus.idle) return;
if (tts.status != TtsStatus.playing) return;
final index = tts.activeParagraphIndex;
if (index < 0 || index >= paragraphCount) return;
if (index == _lastAutoScrolledParagraph) return;
@@ -189,6 +189,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (!mounted) return;
final ctx = _paragraphKeys[index].currentContext;
if (ctx == null) return;
// Clear any active text-selection focus before programmatic scrolling.
FocusManager.instance.primaryFocus?.unfocus();
Scrollable.ensureVisible(
ctx,
alignment: 0.22,
@@ -455,11 +457,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
builder: (sheetContext) {
return Consumer(
builder: (context, ref, _) {
final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1;
final chaptersAsync = ref.watch(
chapterListProvider(
ChapterListQuery(novelId: currentChapter.novelId, page: tocPage),
),
chapterListProvider(currentChapter.novelId),
);
return FractionallySizedBox(
heightFactor: 0.82,
@@ -489,12 +488,20 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
child: chaptersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Không tải được mục lục: $e')),
data: (pageData) {
final chapters = pageData.chapters;
data: (chapters) {
if (chapters.isEmpty) {
return const Center(child: Text('Chưa có danh sách chương.'));
}
// Find index of current chapter for auto-scroll
final currentIndex = chapters.indexWhere((ch) => ch.id == currentChapter.id);
final scrollController = ScrollController(
initialScrollOffset: currentIndex > 0
? currentIndex * 48.0 // Approximate height per ListTile
: 0,
);
return ListView.separated(
controller: scrollController,
itemCount: chapters.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
@@ -847,6 +854,22 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: settings.enableSentenceTapTts,
onChanged: (enabled) {
unawaited(
ref
.read(readingSettingsProvider.notifier)
.setSentenceTapTtsEnabled(enabled),
);
},
title: const Text('Bật chạm câu để phát TTS'),
subtitle: const Text(
'Tắt để tránh chạm nhầm làm bắt đầu TTS.',
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
@@ -1166,6 +1189,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
.titleLarge
?.copyWith(color: readerTextColor),
),
const SizedBox(height: 12),
_NavButtons(
chapter: chapter,
onGoPrevious: () => _goToPreviousChapter(chapter),
onGoNext: () => _goToNextChapter(chapter),
),
const SizedBox(height: 20),
if (chapter.content.trim().isEmpty)
Text(
@@ -1197,32 +1226,44 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Padding(
key: _paragraphKeys[index],
padding: EdgeInsets.only(
bottom: index == paragraphs.length - 1
? 0
: settings.paragraphSpacing,
),
child: _buildParagraphText(
context: context,
sentenceSlices: sentenceSlices,
textAlign: textAlign,
style: paragraphStyle,
highlightStyle: paragraphHighlightStyle,
isActiveParagraph: shouldHighlightTts &&
tts.activeParagraphIndex == index,
highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd,
onSentenceTap: (charOffset) {
ref.read(ttsProvider.notifier).startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
startParagraphIndex: index,
startCharOffset: charOffset,
);
},
child: SizedBox(
width: double.infinity,
child: Padding(
key: _paragraphKeys[index],
padding: EdgeInsets.only(
bottom: index == paragraphs.length - 1
? 0
: settings.paragraphSpacing,
),
child: _buildParagraphText(
context: context,
sentenceSlices: sentenceSlices,
textAlign: textAlign,
style: paragraphStyle,
highlightStyle: paragraphHighlightStyle,
isActiveParagraph: shouldHighlightTts &&
tts.activeParagraphIndex == index,
highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd,
onSentenceTap: (charOffset) {
final hasActiveTtsSession =
tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing ||
tts.status == TtsStatus.paused);
final canStartFromSentence =
settings.enableSentenceTapTts || hasActiveTtsSession;
if (!canStartFromSentence) {
return;
}
ref.read(ttsProvider.notifier).startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
startParagraphIndex: index,
startCharOffset: charOffset,
);
},
),
),
),
),
@@ -1614,20 +1655,18 @@ class _NavButtons extends StatelessWidget {
children: [
if (chapter.prevChapterId != null)
Expanded(
child: OutlinedButton.icon(
child: OutlinedButton(
onPressed: onGoPrevious,
icon: const Icon(Icons.chevron_left),
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
child: Text('< Chương ${chapter.prevChapterNumber ?? '?'}'),
),
),
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
const SizedBox(width: 12),
if (chapter.nextChapterId != null)
Expanded(
child: FilledButton.icon(
child: FilledButton(
onPressed: onGoNext,
icon: const Icon(Icons.chevron_right),
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
child: Text('> Chương ${chapter.nextChapterNumber ?? '?'}'),
),
),
],
@@ -145,6 +145,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
final localStore = _ref.read(localStoreProvider);
await localStore.saveReadingSettings(settings);
}
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
await update(state.copyWith(enableSentenceTapTts: enabled));
}
}
final readingSettingsProvider =
+90 -22
View File
@@ -155,6 +155,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false;
bool _androidFallbackReady = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid;
@@ -315,6 +316,73 @@ class TtsNotifier extends StateNotifier<TtsState> {
);
}
Future<void> _ensureAndroidFallbackReady() async {
if (_androidFallbackReady) return;
await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true);
await _configureVietnameseVoiceWithFlutterTts();
await _tts.setSpeechRate(state.speed);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_tts.setStartHandler(() {
_didStartCurrentFallbackUtterance = true;
final index = _pendingFallbackIndex;
if (index >= 0 && index < _segments.length) {
final segment = _segments[index];
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
);
} else {
state = state.copyWith(status: TtsStatus.playing);
}
});
_tts.setCompletionHandler(() {
// Fallback playback progression is driven by _playFallbackFromGeneration.
});
_tts.setErrorHandler((_) {
if (_isInterruptingPlayback) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
});
_androidFallbackReady = true;
}
Future<void> _startFallbackReading({
required int validIndex,
required _TtsSegment selectedSegment,
required String? contentKey,
}) async {
await _ensureAndroidFallbackReady();
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: selectedSegment.paragraphIndex,
progressStart: selectedSegment.start,
progressEnd: selectedSegment.end,
contentKey: contentKey,
);
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
void _handleAndroidMediaEvent(dynamic event) {
_applyAndroidSnapshot(event);
}
@@ -372,6 +440,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
// Keep natural sentence flow while removing symbols that are usually read out noisily.
final cleaned = raw
.replaceAll(RegExp(r'["“”]'), ' ')
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
@@ -614,33 +683,32 @@ class TtsNotifier extends StateNotifier<TtsState> {
contentKey: contentKey,
);
await _mediaChannel.invokeMethod<void>('startReading', {
'contentKey': contentKey,
'title': title,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(),
});
try {
await _mediaChannel.invokeMethod<void>('startReading', {
'contentKey': contentKey,
'title': title,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(),
});
} on PlatformException {
await _startFallbackReading(
validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
}
return;
}
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
await _startFallbackReading(
validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
await _syncBackgroundMode();
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<int> _interruptFallbackPlayback() async {
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.2+3
version: 1.0.3+4
environment:
sdk: ^3.11.3