feat: Update reading progress management and enhance chapter navigation
This commit is contained in:
@@ -16,6 +16,7 @@ import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Parcelable
|
||||
import android.os.PowerManager
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
@@ -46,6 +47,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
private const val BASE_SPEED = 0.9
|
||||
private const val TAG = "ReaderTtsMediaService"
|
||||
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_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 mediaSession: MediaSessionCompat
|
||||
private lateinit var audioManager: AudioManager
|
||||
private lateinit var powerManager: PowerManager
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var tts: TextToSpeech? = null
|
||||
private var isTtsReady = false
|
||||
private var isForegroundActive = false
|
||||
@@ -210,6 +215,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
createNotificationChannel()
|
||||
setupMediaSession()
|
||||
setupTextToSpeech()
|
||||
@@ -300,7 +306,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
if (!isActiveUtterance(utteranceId)) return@post
|
||||
if (utteranceId != currentUtteranceId) return@post
|
||||
clearUtteranceRuntimeState()
|
||||
handlePlaybackFailure()
|
||||
recoverFromSilentPlayback("utterance_error_$errorCode")
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -319,6 +325,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
} else {
|
||||
status = "idle"
|
||||
}
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
}
|
||||
@@ -348,6 +355,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
|
||||
private fun applyVoiceAndSpeedSettings() {
|
||||
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())
|
||||
val locale = language.toLocale()
|
||||
ttsInstance.setLanguage(locale)
|
||||
@@ -378,6 +393,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
pausedByAudioFocus = false
|
||||
pendingReplayAfterInit = false
|
||||
tts?.stop()
|
||||
syncPowerState()
|
||||
publishSnapshot()
|
||||
|
||||
if (!isTtsReady) return
|
||||
@@ -391,6 +407,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
status = "paused"
|
||||
pendingReplayAfterInit = false
|
||||
tts?.stop()
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
}
|
||||
@@ -401,6 +418,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
sessionGeneration += 1
|
||||
clearUtteranceRuntimeState()
|
||||
pendingReplayAfterInit = false
|
||||
syncPowerState()
|
||||
publishSnapshot()
|
||||
if (!isTtsReady) return
|
||||
speakCurrentSegment(forceRestart = true)
|
||||
@@ -418,6 +436,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
tts?.stop()
|
||||
abandonAudioFocus()
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
stopSelf()
|
||||
@@ -433,6 +452,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
status = "playing"
|
||||
pendingReplayAfterInit = false
|
||||
tts?.stop()
|
||||
syncPowerState()
|
||||
publishSnapshot()
|
||||
if (!isTtsReady) return
|
||||
speakCurrentSegment(forceRestart = true)
|
||||
@@ -449,6 +469,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
completedCount += 1
|
||||
clearUtteranceRuntimeState()
|
||||
abandonAudioFocus()
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
stopSelf()
|
||||
@@ -460,10 +481,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
private fun handlePlaybackFailure() {
|
||||
Log.e(TAG, "Playback stopped after recovery failed at index=$currentIndex contentKey=$contentKey")
|
||||
status = "idle"
|
||||
clearUtteranceRuntimeState()
|
||||
pendingReplayAfterInit = false
|
||||
abandonAudioFocus()
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
stopSelf()
|
||||
@@ -487,6 +510,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
if (!forceRestart) {
|
||||
currentSegmentRetry = 0
|
||||
}
|
||||
syncPowerState()
|
||||
syncNotificationState()
|
||||
publishSnapshot()
|
||||
|
||||
@@ -496,15 +520,20 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
currentUtteranceStarted = false
|
||||
lastSpeakRequestTimeMs = System.currentTimeMillis()
|
||||
scheduleUtteranceWatchdog(utteranceId)
|
||||
val speakResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null)
|
||||
val speakResult = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
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) {
|
||||
recoverFromSilentPlayback("speak_error")
|
||||
if (speakResult == null || speakResult == TextToSpeech.ERROR) {
|
||||
recoverFromSilentPlayback("speak_error_or_null")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,13 +581,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
}
|
||||
|
||||
clearUtteranceRuntimeState()
|
||||
if (currentSegmentRetry >= 2) {
|
||||
handlePlaybackFailure()
|
||||
if (currentSegmentRetry >= MAX_SEGMENT_RETRIES_BEFORE_SKIP) {
|
||||
skipCurrentSegmentAfterFailure(reason)
|
||||
return
|
||||
}
|
||||
|
||||
currentSegmentRetry += 1
|
||||
if (currentSegmentRetry >= 2) {
|
||||
if (currentSegmentRetry >= 3) {
|
||||
rebuildTtsEngineForRecovery(reason)
|
||||
return
|
||||
}
|
||||
@@ -576,6 +605,30 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
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() {
|
||||
if (status != "playing") return
|
||||
if (segments.isEmpty()) return
|
||||
@@ -602,9 +655,10 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
if (!currentUtteranceStarted) {
|
||||
if (!isSpeaking) {
|
||||
// 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
|
||||
if (elapsedSinceSpeak > 4_000L) {
|
||||
if (elapsedSinceSpeak > START_GRACE_PERIOD_MS) {
|
||||
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 {
|
||||
val generation = utteranceId.substringBefore(':').toIntOrNull() ?: return false
|
||||
return generation == sessionGeneration
|
||||
@@ -789,6 +865,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun syncNotificationState() {
|
||||
syncPowerState()
|
||||
updateMediaSessionState()
|
||||
if (!backgroundModeEnabled) {
|
||||
if (isForegroundActive) {
|
||||
@@ -896,6 +973,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
abandonAudioFocus()
|
||||
syncPowerState()
|
||||
if (isForegroundActive) {
|
||||
stopForeground(true)
|
||||
isForegroundActive = false
|
||||
|
||||
@@ -28,17 +28,21 @@ class ReaderScreen extends ConsumerStatefulWidget {
|
||||
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
final ScrollController _scrollCtrl = ScrollController();
|
||||
Timer? _uiAutoHideTimer;
|
||||
double _readingProgress = 0;
|
||||
final ValueNotifier<double> _readingProgress = ValueNotifier(0);
|
||||
final ValueNotifier<bool> _showQuickActions = ValueNotifier(true);
|
||||
String? _activeChapterId;
|
||||
bool _isRestoringProgress = false;
|
||||
bool _showQuickActions = true;
|
||||
double _lastScrollOffset = 0;
|
||||
double _scrollDeltaSinceToggle = 0;
|
||||
double _lastReportedOffset = 0;
|
||||
DateTime? _lastReportedAt;
|
||||
int _chapterDirection = 0; // -1: previous, 1: next
|
||||
int _lastAutoScrolledParagraph = -1;
|
||||
int _lastTtsCompletedCount = 0;
|
||||
String? _autoStartQueuedChapterId;
|
||||
final List<GlobalKey> _paragraphKeys = [];
|
||||
String? _sentenceSlicesChapterId;
|
||||
List<List<_SentenceSlice>> _sentenceSlicesByParagraph = const [];
|
||||
|
||||
List<String> _paragraphsOf(String content) => content
|
||||
.split(RegExp(r'\n+'))
|
||||
@@ -69,37 +73,30 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
|
||||
Widget _buildParagraphText({
|
||||
required BuildContext context,
|
||||
required String paragraph,
|
||||
required List<_SentenceSlice> sentenceSlices,
|
||||
required TextStyle style,
|
||||
required TextStyle highlightStyle,
|
||||
required TextAlign textAlign,
|
||||
required bool isActiveParagraph,
|
||||
required int highlightStart,
|
||||
required int highlightEnd,
|
||||
required Function(int charOffset) onSentenceTap,
|
||||
}) {
|
||||
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph).toList();
|
||||
|
||||
if (sentenceMatches.isEmpty) {
|
||||
if (sentenceSlices.isEmpty) {
|
||||
return SelectableText(
|
||||
paragraph,
|
||||
'',
|
||||
textAlign: textAlign,
|
||||
style: style,
|
||||
onTap: () => onSentenceTap(0),
|
||||
);
|
||||
}
|
||||
|
||||
final highlightStyle = style.copyWith(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
|
||||
return SelectableText.rich(
|
||||
TextSpan(
|
||||
style: style,
|
||||
children: sentenceMatches.map((match) {
|
||||
final sentence = match.group(0)!;
|
||||
final start = match.start;
|
||||
final end = match.end;
|
||||
children: sentenceSlices.map((slice) {
|
||||
final start = slice.start;
|
||||
final end = slice.end;
|
||||
|
||||
final isCurrentSpoken = isActiveParagraph &&
|
||||
highlightStart >= 0 &&
|
||||
@@ -108,7 +105,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
end <= highlightEnd;
|
||||
|
||||
return TextSpan(
|
||||
text: sentence,
|
||||
text: slice.text,
|
||||
style: isCurrentSpoken ? highlightStyle : null,
|
||||
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) {
|
||||
if (_paragraphKeys.length == count) return;
|
||||
_paragraphKeys
|
||||
@@ -183,9 +219,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollCtrl.addListener(_onScroll);
|
||||
});
|
||||
_scrollCtrl.addListener(_onScroll);
|
||||
}
|
||||
|
||||
/// Handle TTS state transitions that require navigation or restarts.
|
||||
@@ -235,13 +269,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
_uiAutoHideTimer?.cancel();
|
||||
_scrollCtrl.removeListener(_onScroll);
|
||||
_scrollCtrl.dispose();
|
||||
_readingProgress.dispose();
|
||||
_showQuickActions.dispose();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
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() {
|
||||
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 delta = currentOffset - _lastScrollOffset;
|
||||
@@ -252,12 +302,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
_scrollDeltaSinceToggle = delta;
|
||||
}
|
||||
|
||||
if (_showQuickActions && currentOffset > 120 && _scrollDeltaSinceToggle > 56) {
|
||||
setState(() => _showQuickActions = false);
|
||||
if (_showQuickActions.value && currentOffset > 120 && _scrollDeltaSinceToggle > 56) {
|
||||
_showQuickActions.value = false;
|
||||
_scrollDeltaSinceToggle = 0;
|
||||
} else if (!_showQuickActions &&
|
||||
} else if (!_showQuickActions.value &&
|
||||
(_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) {
|
||||
setState(() => _showQuickActions = true);
|
||||
_showQuickActions.value = true;
|
||||
_scrollDeltaSinceToggle = 0;
|
||||
}
|
||||
_lastScrollOffset = currentOffset;
|
||||
@@ -265,15 +315,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
if (!_scrollCtrl.hasClients) return;
|
||||
final max = _scrollCtrl.position.maxScrollExtent;
|
||||
final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0);
|
||||
if ((next - _readingProgress).abs() > 0.01) {
|
||||
setState(() => _readingProgress = next);
|
||||
if ((next - _readingProgress.value).abs() > 0.02) {
|
||||
_readingProgress.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeChapterSession(ChapterModel chapter) async {
|
||||
if (_activeChapterId == chapter.id) return;
|
||||
final previousChapterId = _activeChapterId;
|
||||
final switchedChapter = previousChapterId != null && previousChapterId != 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(
|
||||
chapter.novelId,
|
||||
@@ -281,6 +345,13 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
chapter.number,
|
||||
);
|
||||
|
||||
_consumePendingAutoStartForChapter(chapter);
|
||||
|
||||
if (switchedChapter) {
|
||||
ref.read(readerProvider.notifier).resetCurrentChapterProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
final localStore = ref.read(localStoreProvider);
|
||||
final saved = await localStore.loadProgress(chapter.novelId);
|
||||
if (!mounted || saved == null) return;
|
||||
@@ -297,6 +368,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
_isRestoringProgress = true;
|
||||
_scrollCtrl.jumpTo(target);
|
||||
_isRestoringProgress = false;
|
||||
_lastReportedOffset = target;
|
||||
_lastReportedAt = DateTime.now();
|
||||
_onScroll();
|
||||
});
|
||||
}
|
||||
@@ -323,6 +396,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
if (prevId == null) return;
|
||||
setState(() => _chapterDirection = -1);
|
||||
HapticFeedback.selectionClick();
|
||||
_queueAutoStartIfReadingCurrentChapter(chapter.id, prevId);
|
||||
context.pushReplacement(RouteNames.readerChapter(prevId));
|
||||
}
|
||||
|
||||
@@ -331,9 +405,40 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
if (nextId == null) return;
|
||||
setState(() => _chapterDirection = 1);
|
||||
HapticFeedback.selectionClick();
|
||||
_queueAutoStartIfReadingCurrentChapter(chapter.id, 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 {
|
||||
if (!_scrollCtrl.hasClients) return;
|
||||
await _scrollCtrl.animateTo(
|
||||
@@ -411,6 +516,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
if (!isCurrent) {
|
||||
_queueAutoStartIfReadingCurrentChapter(
|
||||
currentChapter.id,
|
||||
item.id,
|
||||
);
|
||||
context.pushReplacement(RouteNames.readerChapter(item.id));
|
||||
}
|
||||
},
|
||||
@@ -881,11 +990,28 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
data: (chapter) {
|
||||
final paragraphs = _paragraphsOf(chapter.content);
|
||||
_ensureParagraphKeys(paragraphs.length);
|
||||
final sentenceSlicesByParagraph =
|
||||
_sentenceSlicesForChapter(chapter, paragraphs);
|
||||
final textAlign = _textAlignFor(settings.textAlign);
|
||||
final novelAsync = ref.watch(novelDetailProvider(chapter.novelId));
|
||||
final tts = ref.watch(ttsProvider);
|
||||
final shouldHighlightTts = tts.contentKey == chapter.id &&
|
||||
(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);
|
||||
@@ -901,16 +1027,21 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
color: readerBackground,
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(
|
||||
title: _chapterTopBarTitle(chapter),
|
||||
progress: _readingProgress,
|
||||
onOpenSettings: () => _openReadingSettingsSheet(
|
||||
chapter.content,
|
||||
chapter.id,
|
||||
'Chương ${chapter.number}: ${chapter.title}',
|
||||
),
|
||||
barBackgroundColor: readerBackground,
|
||||
foregroundColor: readerTextColor,
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: _readingProgress,
|
||||
builder: (context, progress, _) {
|
||||
return _TopBar(
|
||||
title: _chapterTopBarTitle(chapter),
|
||||
progress: progress,
|
||||
onOpenSettings: () => _openReadingSettingsSheet(
|
||||
chapter.content,
|
||||
chapter.id,
|
||||
'Chương ${chapter.number}: ${chapter.title}',
|
||||
),
|
||||
barBackgroundColor: readerBackground,
|
||||
foregroundColor: readerTextColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
@@ -934,107 +1065,136 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
key: ValueKey(chapter.id),
|
||||
child: Scrollbar(
|
||||
controller: _scrollCtrl,
|
||||
child: SingleChildScrollView(
|
||||
child: CustomScrollView(
|
||||
controller: _scrollCtrl,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
settings.horizontalPadding,
|
||||
16,
|
||||
settings.horizontalPadding,
|
||||
24,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
novelAsync.when(
|
||||
loading: () => Text(
|
||||
'Đang tải tên truyện...',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: readerMutedColor,
|
||||
),
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (novel) => Text(
|
||||
novel.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: readerMutedColor,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Chương ${chapter.number}: ${chapter.title}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: readerTextColor),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (chapter.content.trim().isEmpty)
|
||||
Text(
|
||||
'Chương này hiện chưa có nội dung.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: readerMutedColor),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
settings.horizontalPadding,
|
||||
16,
|
||||
settings.horizontalPadding,
|
||||
chapter.content.trim().isEmpty ? 24 : 0,
|
||||
),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var index = 0; index < paragraphs.length; index++)
|
||||
Padding(
|
||||
key: _paragraphKeys[index],
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == paragraphs.length - 1
|
||||
? 0
|
||||
: settings.paragraphSpacing,
|
||||
),
|
||||
child: _buildParagraphText(
|
||||
context: context,
|
||||
paragraph: paragraphs[index],
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
color: readerTextColor,
|
||||
fontSize: settings.fontSize,
|
||||
height: settings.lineHeight,
|
||||
letterSpacing: settings.letterSpacing,
|
||||
fontFamily: settings.fontFamily == 'serif'
|
||||
? 'Georgia'
|
||||
: settings.fontFamily == 'mono'
|
||||
? 'Courier'
|
||||
: null,
|
||||
),
|
||||
isActiveParagraph: shouldHighlightTts &&
|
||||
tts.activeParagraphIndex == index,
|
||||
highlightStart: tts.progressStart,
|
||||
highlightEnd: tts.progressEnd,
|
||||
onSentenceTap: (charOffset) {
|
||||
ref.read(ttsProvider.notifier).startReading(
|
||||
chapter.content,
|
||||
contentKey: chapter.id,
|
||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||
startParagraphIndex: index,
|
||||
startCharOffset: charOffset,
|
||||
);
|
||||
},
|
||||
),
|
||||
novelAsync.when(
|
||||
loading: () => Text(
|
||||
'Đang tải tên truyện...',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: readerMutedColor,
|
||||
),
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (novel) => Text(
|
||||
novel.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: readerMutedColor,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Chương ${chapter.number}: ${chapter.title}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: readerTextColor),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (chapter.content.trim().isEmpty)
|
||||
Text(
|
||||
'Chương này hiện chưa có nội dung.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: readerMutedColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
_NavButtons(chapter: chapter),
|
||||
const SizedBox(height: 92),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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],
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == paragraphs.length - 1
|
||||
? 0
|
||||
: settings.paragraphSpacing,
|
||||
),
|
||||
child: _buildParagraphText(
|
||||
context: context,
|
||||
sentenceSlices: sentenceSlices,
|
||||
textAlign: textAlign,
|
||||
style: paragraphStyle,
|
||||
highlightStyle: paragraphHighlightStyle,
|
||||
isActiveParagraph: shouldHighlightTts &&
|
||||
tts.activeParagraphIndex == index,
|
||||
highlightStart: tts.progressStart,
|
||||
highlightEnd: tts.progressEnd,
|
||||
onSentenceTap: (charOffset) {
|
||||
ref.read(ttsProvider.notifier).startReading(
|
||||
chapter.content,
|
||||
contentKey: chapter.id,
|
||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||
startParagraphIndex: index,
|
||||
startCharOffset: charOffset,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1047,36 +1207,41 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
},
|
||||
),
|
||||
floatingActionButton: chapterAsync.hasValue
|
||||
? AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
opacity: _showQuickActions ? 1 : 0,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final chapter = chapterAsync.value!;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'reader-scroll-top',
|
||||
onPressed: _scrollToTop,
|
||||
child: const Icon(Icons.vertical_align_top_rounded, size: 20),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'reader-toc',
|
||||
onPressed: () => _openChapterToc(chapter),
|
||||
child: const Icon(Icons.list_alt_rounded, size: 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
? ValueListenableBuilder<bool>(
|
||||
valueListenable: _showQuickActions,
|
||||
builder: (context, showQuickActions, _) {
|
||||
return AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
offset: showQuickActions ? Offset.zero : const Offset(0, 1.4),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
opacity: showQuickActions ? 1 : 0,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final chapter = chapterAsync.value!;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'reader-scroll-top',
|
||||
onPressed: _scrollToTop,
|
||||
child: const Icon(Icons.vertical_align_top_rounded, size: 20),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'reader-toc',
|
||||
onPressed: () => _openChapterToc(chapter),
|
||||
child: const Icon(Icons.list_alt_rounded, size: 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
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 {
|
||||
final String title;
|
||||
final double progress;
|
||||
@@ -1373,20 +1550,25 @@ class _TabLabel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButtons extends ConsumerWidget {
|
||||
class _NavButtons extends StatelessWidget {
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (chapter.prevChapterId != null)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => context.pushReplacement(
|
||||
RouteNames.readerChapter(chapter.prevChapterId!),
|
||||
),
|
||||
onPressed: onGoPrevious,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
|
||||
),
|
||||
@@ -1396,9 +1578,7 @@ class _NavButtons extends ConsumerWidget {
|
||||
if (chapter.nextChapterId != null)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => context.pushReplacement(
|
||||
RouteNames.readerChapter(chapter.nextChapterId!),
|
||||
),
|
||||
onPressed: onGoNext,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
|
||||
),
|
||||
|
||||
@@ -61,6 +61,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
|
||||
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) {
|
||||
if (state == null) return;
|
||||
state = ReadingProgress(
|
||||
|
||||
+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
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
version: 1.0.1+2
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.3
|
||||
|
||||
Reference in New Issue
Block a user