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.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
@@ -93,7 +94,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
language: String, language: String,
voiceName: String?, voiceName: String?,
backgroundModeEnabled: Boolean, backgroundModeEnabled: Boolean,
) { ): Boolean {
return try {
ContextCompat.startForegroundService( ContextCompat.startForegroundService(
context, context,
Intent(context, ReaderTtsMediaService::class.java).apply { Intent(context, ReaderTtsMediaService::class.java).apply {
@@ -108,6 +110,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled) putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
}, },
) )
true
} catch (e: Throwable) {
Log.e(TAG, "startForegroundService blocked or failed", e)
false
}
} }
fun pause(context: Context) = fun pause(context: Context) =
@@ -905,7 +912,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID) 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()) .setContentTitle(title ?: appLabel())
.setContentText(currentProgressLabel()) .setContentText(currentProgressLabel())
.setContentIntent(buildLaunchIntent()) .setContentIntent(buildLaunchIntent())
@@ -995,8 +1003,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
// to prevent Android from killing us. // to prevent Android from killing us.
if (status == "playing" || status == "paused") { if (status == "playing" || status == "paused") {
val notification = buildNotification() val notification = buildNotification()
startForeground(NOTIFICATION_ID, notification) isForegroundActive = startForegroundCompat(notification)
isForegroundActive = true
} }
return return
} }
@@ -1005,8 +1012,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
"playing", "paused" -> { "playing", "paused" -> {
val notification = buildNotification() val notification = buildNotification()
if (!isForegroundActive) { if (!isForegroundActive) {
startForeground(NOTIFICATION_ID, notification) isForegroundActive = startForegroundCompat(notification)
isForegroundActive = true
} else { } else {
notificationManager.notify(NOTIFICATION_ID, notification) 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() { private fun publishSnapshot() {
val segment = currentSegment() val segment = currentSegment()
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
+19 -1
View File
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
import 'novel_model.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 { class BookmarkModel extends Equatable {
const BookmarkModel({ const BookmarkModel({
required this.id, required this.id,
required this.novelId, required this.novelId,
this.type = BookmarkType.bookmarked,
this.lastChapterId, this.lastChapterId,
this.lastChapterNumber, this.lastChapterNumber,
this.readChapters = const [], this.readChapters = const [],
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
final String id; final String id;
final String novelId; final String novelId;
final BookmarkType type;
final String? lastChapterId; final String? lastChapterId;
final int? lastChapterNumber; final int? lastChapterNumber;
final List<int> readChapters; final List<int> readChapters;
@@ -22,6 +39,7 @@ class BookmarkModel extends Equatable {
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel( factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
id: json['id'] as String, id: json['id'] as String,
novelId: json['novelId'] as String, novelId: json['novelId'] as String,
type: BookmarkType.fromString(json['type'] as String?),
lastChapterId: json['lastChapterId'] as String?, lastChapterId: json['lastChapterId'] as String?,
lastChapterNumber: json['lastChapterNumber'] as int?, lastChapterNumber: json['lastChapterNumber'] as int?,
readChapters: (json['readChapters'] as List<dynamic>?) readChapters: (json['readChapters'] as List<dynamic>?)
@@ -34,5 +52,5 @@ class BookmarkModel extends Equatable {
); );
@override @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.horizontalPadding = 20,
this.paragraphSpacing = 24, this.paragraphSpacing = 24,
this.textAlign = 'left', this.textAlign = 'left',
this.enableSentenceTapTts = false,
}); });
final double fontSize; final double fontSize;
@@ -22,6 +23,7 @@ class ReadingSettings {
final double horizontalPadding; final double horizontalPadding;
final double paragraphSpacing; final double paragraphSpacing;
final String textAlign; final String textAlign;
final bool enableSentenceTapTts;
ReadingSettings copyWith({ ReadingSettings copyWith({
double? fontSize, double? fontSize,
@@ -34,6 +36,7 @@ class ReadingSettings {
double? horizontalPadding, double? horizontalPadding,
double? paragraphSpacing, double? paragraphSpacing,
String? textAlign, String? textAlign,
bool? enableSentenceTapTts,
}) => }) =>
ReadingSettings( ReadingSettings(
fontSize: fontSize ?? this.fontSize, fontSize: fontSize ?? this.fontSize,
@@ -46,6 +49,7 @@ class ReadingSettings {
horizontalPadding: horizontalPadding ?? this.horizontalPadding, horizontalPadding: horizontalPadding ?? this.horizontalPadding,
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing, paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
textAlign: textAlign ?? this.textAlign, textAlign: textAlign ?? this.textAlign,
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
); );
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings( factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
@@ -60,6 +64,7 @@ class ReadingSettings {
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20, horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24, paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
textAlign: json['textAlign'] as String? ?? 'left', textAlign: json['textAlign'] as String? ?? 'left',
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -73,5 +78,6 @@ class ReadingSettings {
'horizontalPadding': horizontalPadding, 'horizontalPadding': horizontalPadding,
'paragraphSpacing': paragraphSpacing, 'paragraphSpacing': paragraphSpacing,
'textAlign': textAlign, 'textAlign': textAlign,
'enableSentenceTapTts': enableSentenceTapTts,
}; };
} }
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/models/bookmark_model.dart'; import '../../../core/models/bookmark_model.dart';
import '../../../shared/widgets/main_app_header.dart'; import '../../../shared/widgets/main_app_header.dart';
import '../../novel/providers/novels_provider.dart';
import '../providers/bookshelf_provider.dart'; import '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart'; import '../../auth/providers/auth_provider.dart';
@@ -50,7 +51,7 @@ class BookshelfScreen extends ConsumerWidget {
return Scaffold( return Scaffold(
body: DefaultTabController( body: DefaultTabController(
length: 3, length: 2,
child: Column( child: Column(
children: [ children: [
MainAppHeader( MainAppHeader(
@@ -68,9 +69,8 @@ class BookshelfScreen extends ConsumerWidget {
unselectedLabelColor: Colors.white70, unselectedLabelColor: Colors.white70,
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
tabs: const [ tabs: const [
Tab(text: 'Đã đọc'), Tab(text: 'Đang đọc'),
Tab(text: 'Đã lưu'), Tab(text: 'Đánh dấu'),
Tab(text: 'Đang mở'),
], ],
), ),
), ),
@@ -93,23 +93,18 @@ class BookshelfScreen extends ConsumerWidget {
), ),
), ),
data: (bookmarks) { data: (bookmarks) {
final readItems = bookmarks.where((e) => e.readChapters.isNotEmpty).toList(); final readingItems = ref.watch(readingBookmarksProvider);
final savedItems = bookmarks; final bookmarkedItems = ref.watch(savedBookmarksProvider);
final openingItems = bookmarks.where((e) => e.lastChapterId != null).toList();
return TabBarView( return TabBarView(
children: [ children: [
_BookshelfList( _BookshelfList(
bookmarks: readItems, bookmarks: readingItems,
emptyLabel: 'Chưa có truyện đã đọc.', emptyLabel: 'Chưa có truyện đang đọc.',
), ),
_BookshelfList( _BookshelfList(
bookmarks: savedItems, bookmarks: bookmarkedItems,
emptyLabel: 'Chưa có truyện nào trong tủ sách.', emptyLabel: 'Chưa có truyện đánh dấu.',
),
_BookshelfList(
bookmarks: openingItems,
emptyLabel: 'Chưa có truyện đang mở.',
), ),
], ],
); );
@@ -150,20 +145,57 @@ class _BookshelfList extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24), padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
itemCount: bookmarks.length, itemCount: bookmarks.length,
separatorBuilder: (context, index) => const SizedBox(height: 12), 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; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final novel = bookmark.novel; final novel = bookmark.novel;
return Container( return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow, color: Theme.of(context).colorScheme.surfaceContainerLow,
@@ -211,7 +243,10 @@ class _BookmarkTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), 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), const SizedBox(height: 8),
@@ -249,7 +284,7 @@ class _BookmarkTile extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)), onPressed: () => _openContinueReader(context, ref),
icon: const Icon(Icons.menu_book_rounded), icon: const Icon(Icons.menu_book_rounded),
label: const Text('Đọc tiếp'), label: const Text('Đọc tiếp'),
style: FilledButton.styleFrom( 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) { bool isBookmarked(String novelId) {
return (state.valueOrNull ?? []).any((b) => b.novelId == 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 = final bookshelfProvider =
@@ -51,6 +67,16 @@ final bookshelfProvider =
return BookshelfNotifier(ref); 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 isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
final bookshelf = ref.watch(bookshelfProvider); final bookshelf = ref.watch(bookshelfProvider);
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false; 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/models/chapter_model.dart';
import '../../../core/network/providers.dart'; import '../../../core/network/providers.dart';
const chapterPageSize = 50;
// ─── Browse / Search ────────────────────────────────────────────────────────── // ─── Browse / Search ──────────────────────────────────────────────────────────
class BrowseParams { class BrowseParams {
@@ -28,11 +26,11 @@ class BrowseParams {
if (raw == null || raw.isEmpty) return null; if (raw == null || raw.isEmpty) return null;
switch (raw.toLowerCase()) { switch (raw.toLowerCase()) {
case 'ongoing': case 'ongoing':
return 'Đang ra'; return 'ONGOING';
case 'completed': case 'completed':
return 'Hoàn thành'; return 'COMPLETED';
case 'hiatus': case 'hiatus':
return 'Tạm ngưng'; return 'HIATUS';
default: default:
return raw; return raw;
} }
@@ -189,77 +187,51 @@ final novelDetailProvider =
// ─── Chapter List ───────────────────────────────────────────────────────────── // ─── 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 = final chapterListProvider =
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async { FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async { 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( final res = await client.dio.get(
'/api/truyen/$idOrSlug/chapters', '/api/truyen/$idOrSlug/chapters',
queryParameters: { queryParameters: {'page': page, 'limit': limit},
'page': query.page,
'limit': chapterPageSize,
},
); );
return res.data as Map<String, dynamic>; 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;
} }
var data = await fetchChapterPage(query.novelId); return items;
var chapters = data['chapters'] as List? ?? const []; }
try {
return await fetchAllChapters(novelId);
} catch (_) {
// Backend stores chapters by novel id in MongoDB; if route opened by slug, // 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. // first request can return empty list. Resolve canonical id and retry once.
if (chapters.isEmpty) {
try { 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 novelData = novelRes.data as Map<String, dynamic>;
final canonicalId = novelData['id'] as String?; final canonicalId = novelData['id'] as String?;
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) { if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
data = await fetchChapterPage(canonicalId); return await fetchAllChapters(canonicalId);
chapters = data['chapters'] as List? ?? const [];
} }
} catch (_) { } catch (_) {
// Keep original empty list when fallback resolution fails. // 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) { void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
if (tts.status == TtsStatus.idle) return; if (tts.status != TtsStatus.playing) return;
final index = tts.activeParagraphIndex; final index = tts.activeParagraphIndex;
if (index < 0 || index >= paragraphCount) return; if (index < 0 || index >= paragraphCount) return;
if (index == _lastAutoScrolledParagraph) return; if (index == _lastAutoScrolledParagraph) return;
@@ -189,6 +189,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (!mounted) return; if (!mounted) return;
final ctx = _paragraphKeys[index].currentContext; final ctx = _paragraphKeys[index].currentContext;
if (ctx == null) return; if (ctx == null) return;
// Clear any active text-selection focus before programmatic scrolling.
FocusManager.instance.primaryFocus?.unfocus();
Scrollable.ensureVisible( Scrollable.ensureVisible(
ctx, ctx,
alignment: 0.22, alignment: 0.22,
@@ -455,11 +457,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
builder: (sheetContext) { builder: (sheetContext) {
return Consumer( return Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1;
final chaptersAsync = ref.watch( final chaptersAsync = ref.watch(
chapterListProvider( chapterListProvider(currentChapter.novelId),
ChapterListQuery(novelId: currentChapter.novelId, page: tocPage),
),
); );
return FractionallySizedBox( return FractionallySizedBox(
heightFactor: 0.82, heightFactor: 0.82,
@@ -489,12 +488,20 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
child: chaptersAsync.when( child: chaptersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Không tải được mục lục: $e')), error: (e, _) => Center(child: Text('Không tải được mục lục: $e')),
data: (pageData) { data: (chapters) {
final chapters = pageData.chapters;
if (chapters.isEmpty) { if (chapters.isEmpty) {
return const Center(child: Text('Chưa có danh sách chương.')); 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( return ListView.separated(
controller: scrollController,
itemCount: chapters.length, itemCount: chapters.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -847,6 +854,22 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( Row(
children: [ children: [
Expanded( Expanded(
@@ -1166,6 +1189,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
.titleLarge .titleLarge
?.copyWith(color: readerTextColor), ?.copyWith(color: readerTextColor),
), ),
const SizedBox(height: 12),
_NavButtons(
chapter: chapter,
onGoPrevious: () => _goToPreviousChapter(chapter),
onGoNext: () => _goToNextChapter(chapter),
),
const SizedBox(height: 20), const SizedBox(height: 20),
if (chapter.content.trim().isEmpty) if (chapter.content.trim().isEmpty)
Text( Text(
@@ -1197,6 +1226,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760), constraints: const BoxConstraints(maxWidth: 760),
child: SizedBox(
width: double.infinity,
child: Padding( child: Padding(
key: _paragraphKeys[index], key: _paragraphKeys[index],
padding: EdgeInsets.only( padding: EdgeInsets.only(
@@ -1215,6 +1246,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
highlightStart: tts.progressStart, highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd, highlightEnd: tts.progressEnd,
onSentenceTap: (charOffset) { 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( ref.read(ttsProvider.notifier).startReading(
chapter.content, chapter.content,
contentKey: chapter.id, contentKey: chapter.id,
@@ -1226,6 +1266,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
), ),
), ),
), ),
),
); );
}, },
), ),
@@ -1614,20 +1655,18 @@ class _NavButtons extends StatelessWidget {
children: [ children: [
if (chapter.prevChapterId != null) if (chapter.prevChapterId != null)
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton(
onPressed: onGoPrevious, onPressed: onGoPrevious,
icon: const Icon(Icons.chevron_left), child: Text('< Chương ${chapter.prevChapterNumber ?? '?'}'),
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
), ),
), ),
if (chapter.prevChapterId != null && chapter.nextChapterId != null) if (chapter.prevChapterId != null && chapter.nextChapterId != null)
const SizedBox(width: 12), const SizedBox(width: 12),
if (chapter.nextChapterId != null) if (chapter.nextChapterId != null)
Expanded( Expanded(
child: FilledButton.icon( child: FilledButton(
onPressed: onGoNext, onPressed: onGoNext,
icon: const Icon(Icons.chevron_right), child: Text('> Chương ${chapter.nextChapterNumber ?? '?'}'),
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
), ),
), ),
], ],
@@ -145,6 +145,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
final localStore = _ref.read(localStoreProvider); final localStore = _ref.read(localStoreProvider);
await localStore.saveReadingSettings(settings); await localStore.saveReadingSettings(settings);
} }
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
await update(state.copyWith(enableSentenceTapTts: enabled));
}
} }
final readingSettingsProvider = final readingSettingsProvider =
+80 -12
View File
@@ -155,6 +155,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
int _pendingFallbackIndex = -1; int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false; bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false; bool _hasPromptedNotificationSettings = false;
bool _androidFallbackReady = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid; 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) { void _handleAndroidMediaEvent(dynamic event) {
_applyAndroidSnapshot(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. // Keep natural sentence flow while removing symbols that are usually read out noisily.
final cleaned = raw final cleaned = raw
.replaceAll(RegExp(r'["“”]'), ' ')
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ') .replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ') .replaceAll(RegExp(r'\s+'), ' ')
.trim(); .trim();
@@ -614,6 +683,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
contentKey: contentKey, contentKey: contentKey,
); );
try {
await _mediaChannel.invokeMethod<void>('startReading', { await _mediaChannel.invokeMethod<void>('startReading', {
'contentKey': contentKey, 'contentKey': contentKey,
'title': title, 'title': title,
@@ -624,23 +694,21 @@ class TtsNotifier extends StateNotifier<TtsState> {
'backgroundModeEnabled': state.backgroundModeEnabled, 'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(), 'segments': _segments.map((segment) => segment.toMap()).toList(),
}); });
} on PlatformException {
await _startFallbackReading(
validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
}
return; return;
} }
final sessionId = await _interruptFallbackPlayback(); await _startFallbackReading(
validIndex: validIndex,
state = state.copyWith( selectedSegment: selectedSegment,
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
contentKey: contentKey, contentKey: contentKey,
); );
await _syncBackgroundMode();
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
} }
Future<int> _interruptFallbackPlayback() async { 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 # 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 # 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. # 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: environment:
sdk: ^3.11.3 sdk: ^3.11.3