Refactor chapter list provider and improve TTS functionality
- 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.
This commit is contained in:
@@ -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,21 +94,27 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
|||||||
language: String,
|
language: String,
|
||||||
voiceName: String?,
|
voiceName: String?,
|
||||||
backgroundModeEnabled: Boolean,
|
backgroundModeEnabled: Boolean,
|
||||||
) {
|
): Boolean {
|
||||||
ContextCompat.startForegroundService(
|
return try {
|
||||||
context,
|
ContextCompat.startForegroundService(
|
||||||
Intent(context, ReaderTtsMediaService::class.java).apply {
|
context,
|
||||||
action = ACTION_START_READING
|
Intent(context, ReaderTtsMediaService::class.java).apply {
|
||||||
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
|
action = ACTION_START_READING
|
||||||
putExtra(EXTRA_START_INDEX, startIndex)
|
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
|
||||||
putExtra(EXTRA_CONTENT_KEY, contentKey)
|
putExtra(EXTRA_START_INDEX, startIndex)
|
||||||
putExtra(EXTRA_TITLE, title)
|
putExtra(EXTRA_CONTENT_KEY, contentKey)
|
||||||
putExtra(EXTRA_SPEED, speed)
|
putExtra(EXTRA_TITLE, title)
|
||||||
putExtra(EXTRA_LANGUAGE, language)
|
putExtra(EXTRA_SPEED, speed)
|
||||||
putExtra(EXTRA_VOICE_NAME, voiceName)
|
putExtra(EXTRA_LANGUAGE, language)
|
||||||
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
|
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) =
|
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
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -172,7 +204,7 @@ class _BookmarkTile extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
@@ -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 {
|
||||||
final res = await client.dio.get(
|
const limit = 500;
|
||||||
'/api/truyen/$idOrSlug/chapters',
|
var page = 1;
|
||||||
queryParameters: {
|
var totalPages = 1;
|
||||||
'page': query.page,
|
final items = <ChapterListItem>[];
|
||||||
'limit': chapterPageSize,
|
|
||||||
},
|
while (page <= totalPages) {
|
||||||
);
|
final res = await client.dio.get(
|
||||||
return res.data as Map<String, dynamic>;
|
'/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);
|
try {
|
||||||
var chapters = data['chapters'] as List? ?? const [];
|
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,32 +1226,44 @@ 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: Padding(
|
child: SizedBox(
|
||||||
key: _paragraphKeys[index],
|
width: double.infinity,
|
||||||
padding: EdgeInsets.only(
|
child: Padding(
|
||||||
bottom: index == paragraphs.length - 1
|
key: _paragraphKeys[index],
|
||||||
? 0
|
padding: EdgeInsets.only(
|
||||||
: settings.paragraphSpacing,
|
bottom: index == paragraphs.length - 1
|
||||||
),
|
? 0
|
||||||
child: _buildParagraphText(
|
: settings.paragraphSpacing,
|
||||||
context: context,
|
),
|
||||||
sentenceSlices: sentenceSlices,
|
child: _buildParagraphText(
|
||||||
textAlign: textAlign,
|
context: context,
|
||||||
style: paragraphStyle,
|
sentenceSlices: sentenceSlices,
|
||||||
highlightStyle: paragraphHighlightStyle,
|
textAlign: textAlign,
|
||||||
isActiveParagraph: shouldHighlightTts &&
|
style: paragraphStyle,
|
||||||
tts.activeParagraphIndex == index,
|
highlightStyle: paragraphHighlightStyle,
|
||||||
highlightStart: tts.progressStart,
|
isActiveParagraph: shouldHighlightTts &&
|
||||||
highlightEnd: tts.progressEnd,
|
tts.activeParagraphIndex == index,
|
||||||
onSentenceTap: (charOffset) {
|
highlightStart: tts.progressStart,
|
||||||
ref.read(ttsProvider.notifier).startReading(
|
highlightEnd: tts.progressEnd,
|
||||||
chapter.content,
|
onSentenceTap: (charOffset) {
|
||||||
contentKey: chapter.id,
|
final hasActiveTtsSession =
|
||||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
tts.contentKey == chapter.id &&
|
||||||
startParagraphIndex: index,
|
(tts.status == TtsStatus.playing ||
|
||||||
startCharOffset: charOffset,
|
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: [
|
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 =
|
||||||
|
|||||||
@@ -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,33 +683,32 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
|||||||
contentKey: contentKey,
|
contentKey: contentKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _mediaChannel.invokeMethod<void>('startReading', {
|
try {
|
||||||
'contentKey': contentKey,
|
await _mediaChannel.invokeMethod<void>('startReading', {
|
||||||
'title': title,
|
'contentKey': contentKey,
|
||||||
'startIndex': validIndex,
|
'title': title,
|
||||||
'speed': state.speed,
|
'startIndex': validIndex,
|
||||||
'language': state.language,
|
'speed': state.speed,
|
||||||
'voiceName': state.voiceName,
|
'language': state.language,
|
||||||
'backgroundModeEnabled': state.backgroundModeEnabled,
|
'voiceName': state.voiceName,
|
||||||
'segments': _segments.map((segment) => segment.toMap()).toList(),
|
'backgroundModeEnabled': state.backgroundModeEnabled,
|
||||||
});
|
'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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user