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.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 {
|
||||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
} else {
|
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
|
||||||
@Suppress("DEPRECATION")
|
} else {
|
||||||
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null)
|
@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) {
|
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,16 +1027,21 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
|||||||
color: readerBackground,
|
color: readerBackground,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_TopBar(
|
ValueListenableBuilder<double>(
|
||||||
title: _chapterTopBarTitle(chapter),
|
valueListenable: _readingProgress,
|
||||||
progress: _readingProgress,
|
builder: (context, progress, _) {
|
||||||
onOpenSettings: () => _openReadingSettingsSheet(
|
return _TopBar(
|
||||||
chapter.content,
|
title: _chapterTopBarTitle(chapter),
|
||||||
chapter.id,
|
progress: progress,
|
||||||
'Chương ${chapter.number}: ${chapter.title}',
|
onOpenSettings: () => _openReadingSettingsSheet(
|
||||||
),
|
chapter.content,
|
||||||
barBackgroundColor: readerBackground,
|
chapter.id,
|
||||||
foregroundColor: readerTextColor,
|
'Chương ${chapter.number}: ${chapter.title}',
|
||||||
|
),
|
||||||
|
barBackgroundColor: readerBackground,
|
||||||
|
foregroundColor: readerTextColor,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
@@ -934,107 +1065,136 @@ 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,
|
||||||
padding: EdgeInsets.fromLTRB(
|
slivers: [
|
||||||
settings.horizontalPadding,
|
SliverPadding(
|
||||||
16,
|
padding: EdgeInsets.fromLTRB(
|
||||||
settings.horizontalPadding,
|
settings.horizontalPadding,
|
||||||
24,
|
16,
|
||||||
),
|
settings.horizontalPadding,
|
||||||
child: Align(
|
chapter.content.trim().isEmpty ? 24 : 0,
|
||||||
alignment: Alignment.topCenter,
|
),
|
||||||
child: ConstrainedBox(
|
sliver: SliverToBoxAdapter(
|
||||||
constraints: const BoxConstraints(maxWidth: 760),
|
child: Align(
|
||||||
child: Column(
|
alignment: Alignment.topCenter,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: ConstrainedBox(
|
||||||
children: [
|
constraints: const BoxConstraints(maxWidth: 760),
|
||||||
novelAsync.when(
|
child: Column(
|
||||||
loading: () => Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
'Đ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,
|
|
||||||
children: [
|
children: [
|
||||||
for (var index = 0; index < paragraphs.length; index++)
|
novelAsync.when(
|
||||||
Padding(
|
loading: () => Text(
|
||||||
key: _paragraphKeys[index],
|
'Đang tải tên truyện...',
|
||||||
padding: EdgeInsets.only(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
bottom: index == paragraphs.length - 1
|
color: readerMutedColor,
|
||||||
? 0
|
),
|
||||||
: settings.paragraphSpacing,
|
),
|
||||||
),
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
child: _buildParagraphText(
|
data: (novel) => Text(
|
||||||
context: context,
|
novel.title,
|
||||||
paragraph: paragraphs[index],
|
maxLines: 1,
|
||||||
textAlign: textAlign,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: readerTextColor,
|
color: readerMutedColor,
|
||||||
fontSize: settings.fontSize,
|
letterSpacing: 0.2,
|
||||||
height: settings.lineHeight,
|
),
|
||||||
letterSpacing: settings.letterSpacing,
|
),
|
||||||
fontFamily: settings.fontFamily == 'serif'
|
),
|
||||||
? 'Georgia'
|
const SizedBox(height: 4),
|
||||||
: settings.fontFamily == 'mono'
|
Text(
|
||||||
? 'Courier'
|
'Chương ${chapter.number}: ${chapter.title}',
|
||||||
: null,
|
style: Theme.of(context)
|
||||||
),
|
.textTheme
|
||||||
isActiveParagraph: shouldHighlightTts &&
|
.titleLarge
|
||||||
tts.activeParagraphIndex == index,
|
?.copyWith(color: readerTextColor),
|
||||||
highlightStart: tts.progressStart,
|
),
|
||||||
highlightEnd: tts.progressEnd,
|
const SizedBox(height: 20),
|
||||||
onSentenceTap: (charOffset) {
|
if (chapter.content.trim().isEmpty)
|
||||||
ref.read(ttsProvider.notifier).startReading(
|
Text(
|
||||||
chapter.content,
|
'Chương này hiện chưa có nội dung.',
|
||||||
contentKey: chapter.id,
|
style: Theme.of(context)
|
||||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
.textTheme
|
||||||
startParagraphIndex: index,
|
.bodyMedium
|
||||||
startCharOffset: charOffset,
|
?.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
|
floatingActionButton: chapterAsync.hasValue
|
||||||
? AnimatedSlide(
|
? ValueListenableBuilder<bool>(
|
||||||
duration: const Duration(milliseconds: 180),
|
valueListenable: _showQuickActions,
|
||||||
curve: Curves.easeOut,
|
builder: (context, showQuickActions, _) {
|
||||||
offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4),
|
return AnimatedSlide(
|
||||||
child: AnimatedOpacity(
|
duration: const Duration(milliseconds: 180),
|
||||||
duration: const Duration(milliseconds: 140),
|
curve: Curves.easeOut,
|
||||||
opacity: _showQuickActions ? 1 : 0,
|
offset: showQuickActions ? Offset.zero : const Offset(0, 1.4),
|
||||||
child: Builder(
|
child: AnimatedOpacity(
|
||||||
builder: (context) {
|
duration: const Duration(milliseconds: 140),
|
||||||
final chapter = chapterAsync.value!;
|
opacity: showQuickActions ? 1 : 0,
|
||||||
return Column(
|
child: Builder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
final chapter = chapterAsync.value!;
|
||||||
children: [
|
return Column(
|
||||||
FloatingActionButton.small(
|
mainAxisSize: MainAxisSize.min,
|
||||||
heroTag: 'reader-scroll-top',
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
onPressed: _scrollToTop,
|
children: [
|
||||||
child: const Icon(Icons.vertical_align_top_rounded, size: 20),
|
FloatingActionButton.small(
|
||||||
),
|
heroTag: 'reader-scroll-top',
|
||||||
const SizedBox(height: 10),
|
onPressed: _scrollToTop,
|
||||||
FloatingActionButton.small(
|
child: const Icon(Icons.vertical_align_top_rounded, size: 20),
|
||||||
heroTag: 'reader-toc',
|
),
|
||||||
onPressed: () => _openChapterToc(chapter),
|
const SizedBox(height: 10),
|
||||||
child: const Icon(Icons.list_alt_rounded, size: 20),
|
FloatingActionButton.small(
|
||||||
),
|
heroTag: 'reader-toc',
|
||||||
],
|
onPressed: () => _openChapterToc(chapter),
|
||||||
);
|
child: const Icon(Icons.list_alt_rounded, size: 20),
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
: 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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user