feat: Update reading progress management and enhance chapter navigation

This commit is contained in:
2026-04-16 13:18:25 +07:00
parent 583a41879f
commit 69b814f682
4 changed files with 458 additions and 185 deletions
@@ -16,6 +16,7 @@ import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.Parcelable import android.os.Parcelable
import android.os.PowerManager
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
import android.util.Log import android.util.Log
@@ -46,6 +47,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private const val BASE_SPEED = 0.9 private const val BASE_SPEED = 0.9
private const val TAG = "ReaderTtsMediaService" private const val TAG = "ReaderTtsMediaService"
private const val HEALTH_CHECK_INTERVAL_MS = 1500L private const val HEALTH_CHECK_INTERVAL_MS = 1500L
private const val START_GRACE_PERIOD_MS = 10_000L
private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4
const val ACTION_INIT = "com.example.reader_app.tts.INIT" const val ACTION_INIT = "com.example.reader_app.tts.INIT"
const val ACTION_START_READING = "com.example.reader_app.tts.START_READING" const val ACTION_START_READING = "com.example.reader_app.tts.START_READING"
@@ -154,7 +157,9 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationManager: NotificationManagerCompat
private lateinit var mediaSession: MediaSessionCompat private lateinit var mediaSession: MediaSessionCompat
private lateinit var audioManager: AudioManager private lateinit var audioManager: AudioManager
private lateinit var powerManager: PowerManager
private var audioFocusRequest: AudioFocusRequest? = null private var audioFocusRequest: AudioFocusRequest? = null
private var wakeLock: PowerManager.WakeLock? = null
private var tts: TextToSpeech? = null private var tts: TextToSpeech? = null
private var isTtsReady = false private var isTtsReady = false
private var isForegroundActive = false private var isForegroundActive = false
@@ -210,6 +215,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
super.onCreate() super.onCreate()
notificationManager = NotificationManagerCompat.from(this) notificationManager = NotificationManagerCompat.from(this)
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
createNotificationChannel() createNotificationChannel()
setupMediaSession() setupMediaSession()
setupTextToSpeech() setupTextToSpeech()
@@ -300,7 +306,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (!isActiveUtterance(utteranceId)) return@post if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post if (utteranceId != currentUtteranceId) return@post
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
handlePlaybackFailure() recoverFromSilentPlayback("utterance_error_$errorCode")
} }
} }
}, },
@@ -319,6 +325,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} else { } else {
status = "idle" status = "idle"
} }
syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
} }
@@ -348,6 +355,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private fun applyVoiceAndSpeedSettings() { private fun applyVoiceAndSpeedSettings() {
val ttsInstance = tts ?: return val ttsInstance = tts ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
ttsInstance.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
}
ttsInstance.setSpeechRate(speed.toFloat()) ttsInstance.setSpeechRate(speed.toFloat())
val locale = language.toLocale() val locale = language.toLocale()
ttsInstance.setLanguage(locale) ttsInstance.setLanguage(locale)
@@ -378,6 +393,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
pausedByAudioFocus = false pausedByAudioFocus = false
pendingReplayAfterInit = false pendingReplayAfterInit = false
tts?.stop() tts?.stop()
syncPowerState()
publishSnapshot() publishSnapshot()
if (!isTtsReady) return if (!isTtsReady) return
@@ -391,6 +407,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
status = "paused" status = "paused"
pendingReplayAfterInit = false pendingReplayAfterInit = false
tts?.stop() tts?.stop()
syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
} }
@@ -401,6 +418,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
pendingReplayAfterInit = false pendingReplayAfterInit = false
syncPowerState()
publishSnapshot() publishSnapshot()
if (!isTtsReady) return if (!isTtsReady) return
speakCurrentSegment(forceRestart = true) speakCurrentSegment(forceRestart = true)
@@ -418,6 +436,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
tts?.stop() tts?.stop()
abandonAudioFocus() abandonAudioFocus()
syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
stopSelf() stopSelf()
@@ -433,6 +452,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
status = "playing" status = "playing"
pendingReplayAfterInit = false pendingReplayAfterInit = false
tts?.stop() tts?.stop()
syncPowerState()
publishSnapshot() publishSnapshot()
if (!isTtsReady) return if (!isTtsReady) return
speakCurrentSegment(forceRestart = true) speakCurrentSegment(forceRestart = true)
@@ -449,6 +469,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
completedCount += 1 completedCount += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
abandonAudioFocus() abandonAudioFocus()
syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
stopSelf() stopSelf()
@@ -460,10 +481,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
private fun handlePlaybackFailure() { private fun handlePlaybackFailure() {
Log.e(TAG, "Playback stopped after recovery failed at index=$currentIndex contentKey=$contentKey")
status = "idle" status = "idle"
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
pendingReplayAfterInit = false pendingReplayAfterInit = false
abandonAudioFocus() abandonAudioFocus()
syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
stopSelf() stopSelf()
@@ -487,6 +510,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (!forceRestart) { if (!forceRestart) {
currentSegmentRetry = 0 currentSegmentRetry = 0
} }
syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
@@ -496,15 +520,20 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
currentUtteranceStarted = false currentUtteranceStarted = false
lastSpeakRequestTimeMs = System.currentTimeMillis() lastSpeakRequestTimeMs = System.currentTimeMillis()
scheduleUtteranceWatchdog(utteranceId) scheduleUtteranceWatchdog(utteranceId)
val speakResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { val speakResult = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId) tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null) tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null)
} }
} catch (e: Exception) {
Log.e(TAG, "speak() failed for index=$currentIndex", e)
null
}
if (speakResult == TextToSpeech.ERROR) { if (speakResult == null || speakResult == TextToSpeech.ERROR) {
recoverFromSilentPlayback("speak_error") recoverFromSilentPlayback("speak_error_or_null")
} }
} }
@@ -552,13 +581,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
if (currentSegmentRetry >= 2) { if (currentSegmentRetry >= MAX_SEGMENT_RETRIES_BEFORE_SKIP) {
handlePlaybackFailure() skipCurrentSegmentAfterFailure(reason)
return return
} }
currentSegmentRetry += 1 currentSegmentRetry += 1
if (currentSegmentRetry >= 2) { if (currentSegmentRetry >= 3) {
rebuildTtsEngineForRecovery(reason) rebuildTtsEngineForRecovery(reason)
return return
} }
@@ -576,6 +605,30 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
setupTextToSpeech() setupTextToSpeech()
} }
private fun skipCurrentSegmentAfterFailure(reason: String) {
Log.e(
TAG,
"Skipping problematic segment after repeated recovery failures: reason=$reason index=$currentIndex total=${segments.size}",
)
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
val nextIndex = currentIndex + 1
if (nextIndex >= segments.size) {
handlePlaybackFailure()
return
}
currentIndex = nextIndex
currentSegmentRetry = 0
publishSnapshot()
if (!isTtsReady) {
rebuildTtsEngineForRecovery("skip_after_failure")
return
}
speakCurrentSegment(forceRestart = false)
}
private fun runPlaybackHealthCheck() { private fun runPlaybackHealthCheck() {
if (status != "playing") return if (status != "playing") return
if (segments.isEmpty()) return if (segments.isEmpty()) return
@@ -602,9 +655,10 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (!currentUtteranceStarted) { if (!currentUtteranceStarted) {
if (!isSpeaking) { if (!isSpeaking) {
// Allow a grace period after speak() is called before flagging as silent. // Allow a grace period after speak() is called before flagging as silent.
// onStart typically fires within ~100 ms; 4 s covers slow TTS initialisation. // Some engines on mid/low-end devices need noticeably longer before
// firing onStart after many segments or after screen-off transitions.
val elapsedSinceSpeak = System.currentTimeMillis() - lastSpeakRequestTimeMs val elapsedSinceSpeak = System.currentTimeMillis() - lastSpeakRequestTimeMs
if (elapsedSinceSpeak > 4_000L) { if (elapsedSinceSpeak > START_GRACE_PERIOD_MS) {
recoverFromSilentPlayback("no_onStart_and_not_speaking") recoverFromSilentPlayback("no_onStart_and_not_speaking")
} }
} }
@@ -661,6 +715,28 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
} }
private fun syncPowerState() {
val shouldHoldWakeLock = backgroundModeEnabled && status == "playing"
if (shouldHoldWakeLock) {
if (wakeLock?.isHeld == true) return
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"reader_app:ReaderTtsPlayback"
).apply {
setReferenceCounted(false)
acquire()
}
return
}
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
}
private fun isActiveUtterance(utteranceId: String): Boolean { private fun isActiveUtterance(utteranceId: String): Boolean {
val generation = utteranceId.substringBefore(':').toIntOrNull() ?: return false val generation = utteranceId.substringBefore(':').toIntOrNull() ?: return false
return generation == sessionGeneration return generation == sessionGeneration
@@ -789,6 +865,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun syncNotificationState() { private fun syncNotificationState() {
syncPowerState()
updateMediaSessionState() updateMediaSessionState()
if (!backgroundModeEnabled) { if (!backgroundModeEnabled) {
if (isForegroundActive) { if (isForegroundActive) {
@@ -896,6 +973,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
abandonAudioFocus() abandonAudioFocus()
syncPowerState()
if (isForegroundActive) { if (isForegroundActive) {
stopForeground(true) stopForeground(true)
isForegroundActive = false isForegroundActive = false
@@ -28,17 +28,21 @@ class ReaderScreen extends ConsumerStatefulWidget {
class _ReaderScreenState extends ConsumerState<ReaderScreen> { class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final ScrollController _scrollCtrl = ScrollController(); final ScrollController _scrollCtrl = ScrollController();
Timer? _uiAutoHideTimer; Timer? _uiAutoHideTimer;
double _readingProgress = 0; final ValueNotifier<double> _readingProgress = ValueNotifier(0);
final ValueNotifier<bool> _showQuickActions = ValueNotifier(true);
String? _activeChapterId; String? _activeChapterId;
bool _isRestoringProgress = false; bool _isRestoringProgress = false;
bool _showQuickActions = true;
double _lastScrollOffset = 0; double _lastScrollOffset = 0;
double _scrollDeltaSinceToggle = 0; double _scrollDeltaSinceToggle = 0;
double _lastReportedOffset = 0;
DateTime? _lastReportedAt;
int _chapterDirection = 0; // -1: previous, 1: next int _chapterDirection = 0; // -1: previous, 1: next
int _lastAutoScrolledParagraph = -1; int _lastAutoScrolledParagraph = -1;
int _lastTtsCompletedCount = 0; int _lastTtsCompletedCount = 0;
String? _autoStartQueuedChapterId; String? _autoStartQueuedChapterId;
final List<GlobalKey> _paragraphKeys = []; final List<GlobalKey> _paragraphKeys = [];
String? _sentenceSlicesChapterId;
List<List<_SentenceSlice>> _sentenceSlicesByParagraph = const [];
List<String> _paragraphsOf(String content) => content List<String> _paragraphsOf(String content) => content
.split(RegExp(r'\n+')) .split(RegExp(r'\n+'))
@@ -69,37 +73,30 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Widget _buildParagraphText({ Widget _buildParagraphText({
required BuildContext context, required BuildContext context,
required String paragraph, required List<_SentenceSlice> sentenceSlices,
required TextStyle style, required TextStyle style,
required TextStyle highlightStyle,
required TextAlign textAlign, required TextAlign textAlign,
required bool isActiveParagraph, required bool isActiveParagraph,
required int highlightStart, required int highlightStart,
required int highlightEnd, required int highlightEnd,
required Function(int charOffset) onSentenceTap, required Function(int charOffset) onSentenceTap,
}) { }) {
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph).toList(); if (sentenceSlices.isEmpty) {
if (sentenceMatches.isEmpty) {
return SelectableText( return SelectableText(
paragraph, '',
textAlign: textAlign, textAlign: textAlign,
style: style, style: style,
onTap: () => onSentenceTap(0), onTap: () => onSentenceTap(0),
); );
} }
final highlightStyle = style.copyWith(
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
fontWeight: FontWeight.w600,
);
return SelectableText.rich( return SelectableText.rich(
TextSpan( TextSpan(
style: style, style: style,
children: sentenceMatches.map((match) { children: sentenceSlices.map((slice) {
final sentence = match.group(0)!; final start = slice.start;
final start = match.start; final end = slice.end;
final end = match.end;
final isCurrentSpoken = isActiveParagraph && final isCurrentSpoken = isActiveParagraph &&
highlightStart >= 0 && highlightStart >= 0 &&
@@ -108,7 +105,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
end <= highlightEnd; end <= highlightEnd;
return TextSpan( return TextSpan(
text: sentence, text: slice.text,
style: isCurrentSpoken ? highlightStyle : null, style: isCurrentSpoken ? highlightStyle : null,
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start), recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
); );
@@ -118,6 +115,45 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
); );
} }
List<List<_SentenceSlice>> _sentenceSlicesForChapter(
ChapterModel chapter,
List<String> paragraphs,
) {
if (_sentenceSlicesChapterId == chapter.id &&
_sentenceSlicesByParagraph.length == paragraphs.length) {
return _sentenceSlicesByParagraph;
}
final sentencePattern = RegExp(r'[^.!?…]+[.!?…]*');
final parsed = <List<_SentenceSlice>>[];
for (final paragraph in paragraphs) {
final matches = sentencePattern.allMatches(paragraph).toList();
if (matches.isEmpty) {
parsed.add([
_SentenceSlice(text: paragraph, start: 0, end: paragraph.length),
]);
continue;
}
parsed.add(
matches
.map(
(match) => _SentenceSlice(
text: match.group(0)!,
start: match.start,
end: match.end,
),
)
.toList(),
);
}
_sentenceSlicesChapterId = chapter.id;
_sentenceSlicesByParagraph = parsed;
return parsed;
}
void _ensureParagraphKeys(int count) { void _ensureParagraphKeys(int count) {
if (_paragraphKeys.length == count) return; if (_paragraphKeys.length == count) return;
_paragraphKeys _paragraphKeys
@@ -183,9 +219,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
void initState() { void initState() {
super.initState(); super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollCtrl.addListener(_onScroll); _scrollCtrl.addListener(_onScroll);
});
} }
/// Handle TTS state transitions that require navigation or restarts. /// Handle TTS state transitions that require navigation or restarts.
@@ -235,13 +269,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_uiAutoHideTimer?.cancel(); _uiAutoHideTimer?.cancel();
_scrollCtrl.removeListener(_onScroll); _scrollCtrl.removeListener(_onScroll);
_scrollCtrl.dispose(); _scrollCtrl.dispose();
_readingProgress.dispose();
_showQuickActions.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose(); super.dispose();
} }
bool _shouldReportScroll(double offset) {
final now = DateTime.now();
final elapsedMs = _lastReportedAt == null
? 1000
: now.difference(_lastReportedAt!).inMilliseconds;
final delta = (offset - _lastReportedOffset).abs();
return delta >= 24 || elapsedMs >= 700;
}
void _onScroll() { void _onScroll() {
if (_isRestoringProgress) return; if (_isRestoringProgress) return;
ref.read(readerProvider.notifier).updateScroll(_scrollCtrl.offset); final offset = _scrollCtrl.hasClients ? _scrollCtrl.offset : 0.0;
if (_shouldReportScroll(offset)) {
_lastReportedOffset = offset;
_lastReportedAt = DateTime.now();
ref.read(readerProvider.notifier).updateScroll(offset);
}
final currentOffset = _scrollCtrl.hasClients ? _scrollCtrl.offset : _lastScrollOffset; final currentOffset = _scrollCtrl.hasClients ? _scrollCtrl.offset : _lastScrollOffset;
final delta = currentOffset - _lastScrollOffset; final delta = currentOffset - _lastScrollOffset;
@@ -252,12 +302,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_scrollDeltaSinceToggle = delta; _scrollDeltaSinceToggle = delta;
} }
if (_showQuickActions && currentOffset > 120 && _scrollDeltaSinceToggle > 56) { if (_showQuickActions.value && currentOffset > 120 && _scrollDeltaSinceToggle > 56) {
setState(() => _showQuickActions = false); _showQuickActions.value = false;
_scrollDeltaSinceToggle = 0; _scrollDeltaSinceToggle = 0;
} else if (!_showQuickActions && } else if (!_showQuickActions.value &&
(_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) { (_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) {
setState(() => _showQuickActions = true); _showQuickActions.value = true;
_scrollDeltaSinceToggle = 0; _scrollDeltaSinceToggle = 0;
} }
_lastScrollOffset = currentOffset; _lastScrollOffset = currentOffset;
@@ -265,15 +315,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (!_scrollCtrl.hasClients) return; if (!_scrollCtrl.hasClients) return;
final max = _scrollCtrl.position.maxScrollExtent; final max = _scrollCtrl.position.maxScrollExtent;
final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0); final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0);
if ((next - _readingProgress).abs() > 0.01) { if ((next - _readingProgress.value).abs() > 0.02) {
setState(() => _readingProgress = next); _readingProgress.value = next;
} }
} }
Future<void> _initializeChapterSession(ChapterModel chapter) async { Future<void> _initializeChapterSession(ChapterModel chapter) async {
if (_activeChapterId == chapter.id) return; if (_activeChapterId == chapter.id) return;
final previousChapterId = _activeChapterId;
final switchedChapter = previousChapterId != null && previousChapterId != chapter.id;
_activeChapterId = chapter.id; _activeChapterId = chapter.id;
_readingProgress = 0; _readingProgress.value = 0;
_showQuickActions.value = true;
_lastScrollOffset = 0;
_scrollDeltaSinceToggle = 0;
_lastReportedOffset = 0;
_lastReportedAt = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_isRestoringProgress = true;
_scrollCtrl.jumpTo(0);
_isRestoringProgress = false;
});
ref.read(readerProvider.notifier).open( ref.read(readerProvider.notifier).open(
chapter.novelId, chapter.novelId,
@@ -281,6 +345,13 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.number, chapter.number,
); );
_consumePendingAutoStartForChapter(chapter);
if (switchedChapter) {
ref.read(readerProvider.notifier).resetCurrentChapterProgress();
return;
}
final localStore = ref.read(localStoreProvider); final localStore = ref.read(localStoreProvider);
final saved = await localStore.loadProgress(chapter.novelId); final saved = await localStore.loadProgress(chapter.novelId);
if (!mounted || saved == null) return; if (!mounted || saved == null) return;
@@ -297,6 +368,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_isRestoringProgress = true; _isRestoringProgress = true;
_scrollCtrl.jumpTo(target); _scrollCtrl.jumpTo(target);
_isRestoringProgress = false; _isRestoringProgress = false;
_lastReportedOffset = target;
_lastReportedAt = DateTime.now();
_onScroll(); _onScroll();
}); });
} }
@@ -323,6 +396,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (prevId == null) return; if (prevId == null) return;
setState(() => _chapterDirection = -1); setState(() => _chapterDirection = -1);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
_queueAutoStartIfReadingCurrentChapter(chapter.id, prevId);
context.pushReplacement(RouteNames.readerChapter(prevId)); context.pushReplacement(RouteNames.readerChapter(prevId));
} }
@@ -331,9 +405,40 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (nextId == null) return; if (nextId == null) return;
setState(() => _chapterDirection = 1); setState(() => _chapterDirection = 1);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
_queueAutoStartIfReadingCurrentChapter(chapter.id, nextId);
context.pushReplacement(RouteNames.readerChapter(nextId)); context.pushReplacement(RouteNames.readerChapter(nextId));
} }
void _queueAutoStartIfReadingCurrentChapter(
String currentChapterId,
String targetChapterId,
) {
final tts = ref.read(ttsProvider);
final isCurrentlyReading = tts.contentKey == currentChapterId &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (!isCurrentlyReading) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId);
}
void _consumePendingAutoStartForChapter(ChapterModel chapter) {
final tts = ref.read(ttsProvider);
if (tts.pendingAutoStartChapterId != chapter.id) return;
if (_autoStartQueuedChapterId == chapter.id) return;
_autoStartQueuedChapterId = chapter.id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final notifier = ref.read(ttsProvider.notifier);
notifier.clearPendingAutoStartChapter();
notifier.startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
);
_autoStartQueuedChapterId = null;
});
}
Future<void> _scrollToTop() async { Future<void> _scrollToTop() async {
if (!_scrollCtrl.hasClients) return; if (!_scrollCtrl.hasClients) return;
await _scrollCtrl.animateTo( await _scrollCtrl.animateTo(
@@ -411,6 +516,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
if (!isCurrent) { if (!isCurrent) {
_queueAutoStartIfReadingCurrentChapter(
currentChapter.id,
item.id,
);
context.pushReplacement(RouteNames.readerChapter(item.id)); context.pushReplacement(RouteNames.readerChapter(item.id));
} }
}, },
@@ -881,11 +990,28 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
data: (chapter) { data: (chapter) {
final paragraphs = _paragraphsOf(chapter.content); final paragraphs = _paragraphsOf(chapter.content);
_ensureParagraphKeys(paragraphs.length); _ensureParagraphKeys(paragraphs.length);
final sentenceSlicesByParagraph =
_sentenceSlicesForChapter(chapter, paragraphs);
final textAlign = _textAlignFor(settings.textAlign); final textAlign = _textAlignFor(settings.textAlign);
final novelAsync = ref.watch(novelDetailProvider(chapter.novelId)); final novelAsync = ref.watch(novelDetailProvider(chapter.novelId));
final tts = ref.watch(ttsProvider); final tts = ref.watch(ttsProvider);
final shouldHighlightTts = tts.contentKey == chapter.id && final shouldHighlightTts = tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused); (tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
final paragraphStyle = TextStyle(
color: readerTextColor,
fontSize: settings.fontSize,
height: settings.lineHeight,
letterSpacing: settings.letterSpacing,
fontFamily: settings.fontFamily == 'serif'
? 'Georgia'
: settings.fontFamily == 'mono'
? 'Courier'
: null,
);
final paragraphHighlightStyle = paragraphStyle.copyWith(
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
fontWeight: FontWeight.w600,
);
_maybeAutoScrollToTtsParagraph(tts, paragraphs.length); _maybeAutoScrollToTtsParagraph(tts, paragraphs.length);
@@ -901,9 +1027,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
color: readerBackground, color: readerBackground,
child: Column( child: Column(
children: [ children: [
_TopBar( ValueListenableBuilder<double>(
valueListenable: _readingProgress,
builder: (context, progress, _) {
return _TopBar(
title: _chapterTopBarTitle(chapter), title: _chapterTopBarTitle(chapter),
progress: _readingProgress, progress: progress,
onOpenSettings: () => _openReadingSettingsSheet( onOpenSettings: () => _openReadingSettingsSheet(
chapter.content, chapter.content,
chapter.id, chapter.id,
@@ -911,6 +1040,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
), ),
barBackgroundColor: readerBackground, barBackgroundColor: readerBackground,
foregroundColor: readerTextColor, foregroundColor: readerTextColor,
);
},
), ),
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
@@ -934,14 +1065,17 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
key: ValueKey(chapter.id), key: ValueKey(chapter.id),
child: Scrollbar( child: Scrollbar(
controller: _scrollCtrl, controller: _scrollCtrl,
child: SingleChildScrollView( child: CustomScrollView(
controller: _scrollCtrl, controller: _scrollCtrl,
slivers: [
SliverPadding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
settings.horizontalPadding, settings.horizontalPadding,
16, 16,
settings.horizontalPadding, settings.horizontalPadding,
24, chapter.content.trim().isEmpty ? 24 : 0,
), ),
sliver: SliverToBoxAdapter(
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: ConstrainedBox( child: ConstrainedBox(
@@ -983,13 +1117,30 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
.textTheme .textTheme
.bodyMedium .bodyMedium
?.copyWith(color: readerMutedColor), ?.copyWith(color: readerMutedColor),
) ),
else ],
Column( ),
crossAxisAlignment: CrossAxisAlignment.stretch, ),
children: [ ),
for (var index = 0; index < paragraphs.length; index++) ),
Padding( ),
if (chapter.content.trim().isNotEmpty)
SliverPadding(
padding: EdgeInsets.fromLTRB(
settings.horizontalPadding,
0,
settings.horizontalPadding,
0,
),
sliver: SliverList.builder(
itemCount: paragraphs.length,
itemBuilder: (context, index) {
final sentenceSlices = sentenceSlicesByParagraph[index];
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Padding(
key: _paragraphKeys[index], key: _paragraphKeys[index],
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: index == paragraphs.length - 1 bottom: index == paragraphs.length - 1
@@ -998,19 +1149,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
), ),
child: _buildParagraphText( child: _buildParagraphText(
context: context, context: context,
paragraph: paragraphs[index], sentenceSlices: sentenceSlices,
textAlign: textAlign, textAlign: textAlign,
style: TextStyle( style: paragraphStyle,
color: readerTextColor, highlightStyle: paragraphHighlightStyle,
fontSize: settings.fontSize,
height: settings.lineHeight,
letterSpacing: settings.letterSpacing,
fontFamily: settings.fontFamily == 'serif'
? 'Georgia'
: settings.fontFamily == 'mono'
? 'Courier'
: null,
),
isActiveParagraph: shouldHighlightTts && isActiveParagraph: shouldHighlightTts &&
tts.activeParagraphIndex == index, tts.activeParagraphIndex == index,
highlightStart: tts.progressStart, highlightStart: tts.progressStart,
@@ -1026,16 +1168,34 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}, },
), ),
), ),
),
);
},
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(
settings.horizontalPadding,
40,
settings.horizontalPadding,
92,
),
sliver: SliverToBoxAdapter(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: _NavButtons(
chapter: chapter,
onGoPrevious: () => _goToPreviousChapter(chapter),
onGoNext: () => _goToNextChapter(chapter),
),
),
),
),
),
], ],
), ),
const SizedBox(height: 40),
_NavButtons(chapter: chapter),
const SizedBox(height: 92),
],
),
),
),
),
), ),
), ),
), ),
@@ -1047,13 +1207,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}, },
), ),
floatingActionButton: chapterAsync.hasValue floatingActionButton: chapterAsync.hasValue
? AnimatedSlide( ? ValueListenableBuilder<bool>(
valueListenable: _showQuickActions,
builder: (context, showQuickActions, _) {
return AnimatedSlide(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
curve: Curves.easeOut, curve: Curves.easeOut,
offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4), offset: showQuickActions ? Offset.zero : const Offset(0, 1.4),
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 140), duration: const Duration(milliseconds: 140),
opacity: _showQuickActions ? 1 : 0, opacity: showQuickActions ? 1 : 0,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final chapter = chapterAsync.value!; final chapter = chapterAsync.value!;
@@ -1077,6 +1240,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}, },
), ),
), ),
);
},
) )
: null, : null,
bottomNavigationBar: chapterAsync.whenOrNull( bottomNavigationBar: chapterAsync.whenOrNull(
@@ -1104,6 +1269,18 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
} }
} }
class _SentenceSlice {
const _SentenceSlice({
required this.text,
required this.start,
required this.end,
});
final String text;
final int start;
final int end;
}
class _TopBar extends StatelessWidget { class _TopBar extends StatelessWidget {
final String title; final String title;
final double progress; final double progress;
@@ -1373,20 +1550,25 @@ class _TabLabel extends StatelessWidget {
} }
} }
class _NavButtons extends ConsumerWidget { class _NavButtons extends StatelessWidget {
final ChapterModel chapter; final ChapterModel chapter;
const _NavButtons({required this.chapter}); final VoidCallback onGoPrevious;
final VoidCallback onGoNext;
const _NavButtons({
required this.chapter,
required this.onGoPrevious,
required this.onGoNext,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
if (chapter.prevChapterId != null) if (chapter.prevChapterId != null)
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () => context.pushReplacement( onPressed: onGoPrevious,
RouteNames.readerChapter(chapter.prevChapterId!),
),
icon: const Icon(Icons.chevron_left), icon: const Icon(Icons.chevron_left),
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'), label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
), ),
@@ -1396,9 +1578,7 @@ class _NavButtons extends ConsumerWidget {
if (chapter.nextChapterId != null) if (chapter.nextChapterId != null)
Expanded( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () => context.pushReplacement( onPressed: onGoNext,
RouteNames.readerChapter(chapter.nextChapterId!),
),
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'), label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
), ),
@@ -61,6 +61,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
scrollOffset: 0, scrollOffset: 0,
); );
} }
void resetCurrentChapterProgress() {
if (state == null) return;
state = ReadingProgress(
novelId: state!.novelId,
chapterId: state!.chapterId,
chapterNumber: state!.chapterNumber,
scrollOffset: 0,
);
// Persist immediately so a freshly opened chapter always resumes at top.
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, 0));
}
void updateScroll(double offset) { void updateScroll(double offset) {
if (state == null) return; if (state == null) return;
state = ReadingProgress( state = ReadingProgress(
+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.0+1 version: 1.0.1+2
environment: environment:
sdk: ^3.11.3 sdk: ^3.11.3