1 Commits

Author SHA1 Message Date
javara999 c0e0ef1575 Improve live text reader repagination
Build & Test / Build & Test App (push) Waiting to run
2026-09-19 20:12:09 +09:00
7 changed files with 156 additions and 40 deletions
@@ -53,7 +53,7 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
selected = readerTheme == value,
onClick = {
viewModel.preferences.readerTheme.set(value)
viewModel.requestTextSettingsReload()
viewModel.requestTextAppearanceReload()
},
label = { Text(stringResource(labelRes)) },
)
@@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -186,6 +187,12 @@ class ReaderViewModel(
*/
private var loader: ChapterLoader? = null
/**
* Only the newest text repagination request is allowed to finish. Slider
* changes can arrive faster than a large TXT/EPUB chapter can be rebuilt.
*/
private var textRepaginationJob: Job? = null
/**
* The time the chapter was started reading
*/
@@ -841,17 +848,26 @@ class ReaderViewModel(
if (chapter.pageLoader?.usesTextReaderStyle != true) return
if (chapter.pages?.any { it is TextReaderPage } != true) return
val chapterLoader = loader ?: return
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex
viewModelScope.launchIO {
val oldPageCount = chapter.pages?.size?.coerceAtLeast(1) ?: 1
val requestedPage = chapter.requestedPage.coerceIn(0, oldPageCount - 1)
// Anchor at the middle of the visible page so position remains stable
// when font size/spacing changes the total page count.
val readingProgress = ((requestedPage + 0.5f) / oldPageCount)
.coerceIn(0f, 0.999999f)
textRepaginationJob?.cancel()
textRepaginationJob = viewModelScope.launchIO {
try {
val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
eventChannel.send(Event.ReloadTextViewerChapters)
// Give old visible holders time to detach before closing resources such as EpubReader.
delay(2000)
oldLoader.recycle()
// Resource cleanup must survive cancellation by the next slider value.
viewModelScope.launchNonCancellable {
delay(750)
oldLoader.recycle()
}
eventChannel.trySend(Event.ReloadTextViewerChapters)
} catch (e: Throwable) {
if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
@@ -22,7 +22,8 @@ import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR
import tachiyomi.source.local.LocalSource
import tachiyomi.source.local.io.Format
import kotlin.math.roundToInt
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
/**
* Loader used to retrieve the [PageLoader] for a given chapter.
@@ -90,10 +91,14 @@ class ChapterLoader(
throw Exception(context.stringResource(MR.strings.page_list_empty_error))
}
// If a newer slider value cancelled this rebuild while getPages()
// was working, do not publish stale pages into the active chapter.
currentCoroutineContext().ensureActive()
val oldLoader = chapter.pageLoader
chapter.pageLoader = newLoader
chapter.requestedPage = (readingProgress * pages.lastIndex)
.roundToInt()
chapter.requestedPage = (readingProgress.coerceIn(0f, 0.999999f) * pages.size)
.toInt()
.coerceIn(0, pages.lastIndex)
chapter.state = ReaderChapter.State.Loaded(pages)
oldLoader
@@ -16,13 +16,10 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.Reader
import kotlin.math.roundToInt
internal object TextPageRenderer {
private const val PAGE_WIDTH = 1440
private const val PAGE_HEIGHT = 2160
private const val MARGIN = 96
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2)
private const val MARGIN_DP = 32f
private const val STREAM_BUFFER_CHARS = 64 * 1024
private const val STREAM_READ_CHARS = 8 * 1024
@@ -101,7 +98,8 @@ internal object TextPageRenderer {
private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> {
if (text.isEmpty()) return emptyList()
val layout = createLayout(text, style)
val metrics = resolveLayoutMetrics(style)
val layout = createLayout(text, style, metrics.contentWidth)
if (layout.lineCount == 0) return listOf(PageSlice(0, text.length))
val pages = mutableListOf<PageSlice>()
@@ -111,7 +109,7 @@ internal object TextPageRenderer {
var endLine = startLine
while (
endLine + 1 < layout.lineCount &&
layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT
layout.getLineBottom(endLine + 1) - startTop <= metrics.contentHeight
) {
endLine++
}
@@ -125,13 +123,14 @@ internal object TextPageRenderer {
}
fun render(text: String, style: TextReaderStyle): InputStream {
val bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, Bitmap.Config.ARGB_8888)
val metrics = resolveLayoutMetrics(style)
val bitmap = Bitmap.createBitmap(metrics.pageWidth, metrics.pageHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val (backgroundColor, _) = resolveColors(style)
canvas.drawColor(backgroundColor)
canvas.save()
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat())
createLayout(text, style).draw(canvas)
canvas.translate(metrics.horizontalMargin.toFloat(), metrics.verticalMargin.toFloat())
createLayout(text, style, metrics.contentWidth).draw(canvas)
canvas.restore()
val output = ByteArrayOutputStream()
@@ -140,25 +139,46 @@ internal object TextPageRenderer {
return ByteArrayInputStream(output.toByteArray())
}
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout {
private fun createLayout(
text: String,
style: TextReaderStyle,
contentWidth: Int,
): StaticLayout {
val (_, textColor) = resolveColors(style)
val paint = TextPaint().apply {
isAntiAlias = true
color = textColor
textSize = style.fontSize * 3f
textSize = style.fontSizePx.coerceAtLeast(1f)
typeface = style.fontFamily.androidFamilyName
?.let { Typeface.create(it, Typeface.NORMAL) }
?: Typeface.DEFAULT
}
val styledText = addParagraphSpacing(text, style.paragraphSpacing * 3)
val styledText = addParagraphSpacing(text, style.paragraphSpacingPx)
return StaticLayout.Builder
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
.obtain(styledText, 0, styledText.length, paint, contentWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(true)
.setLineSpacing(0f, style.lineSpacingPercent / 100f)
.build()
}
private fun resolveLayoutMetrics(style: TextReaderStyle): LayoutMetrics {
val pageWidth = style.pageWidthPx.coerceAtLeast(1)
val pageHeight = style.pageHeightPx.coerceAtLeast(1)
val requestedMargin = (MARGIN_DP * style.density).roundToInt().coerceAtLeast(1)
val horizontalMargin = requestedMargin.coerceAtMost(((pageWidth - 1) / 2).coerceAtLeast(0))
val verticalMargin = requestedMargin.coerceAtMost(((pageHeight - 1) / 2).coerceAtLeast(0))
return LayoutMetrics(
pageWidth = pageWidth,
pageHeight = pageHeight,
horizontalMargin = horizontalMargin,
verticalMargin = verticalMargin,
contentWidth = (pageWidth - horizontalMargin * 2).coerceAtLeast(1),
contentHeight = (pageHeight - verticalMargin * 2).coerceAtLeast(1),
)
}
private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> {
val useDark = when (style.readerTheme) {
0 -> false
@@ -205,6 +225,15 @@ internal object TextPageRenderer {
}
}
private data class LayoutMetrics(
val pageWidth: Int,
val pageHeight: Int,
val horizontalMargin: Int,
val verticalMargin: Int,
val contentWidth: Int,
val contentHeight: Int,
)
private data class PageSlice(
val start: Int,
val endExclusive: Int,
@@ -5,9 +5,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
/**
* A rendered text page whose style can be changed without rebuilding the chapter.
* Page boundaries remain stable during the live preview; a full repagination is done
* once when the settings dialog is dismissed.
* A rendered text page whose visual style can be refreshed cheaply while layout-affecting
* changes trigger a debounced chapter repagination to rebuild page boundaries.
*/
internal class TextReaderPage(
index: Int,
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader.setting
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.util.TypedValue
import androidx.compose.ui.graphics.BlendMode
import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope
@@ -137,15 +138,44 @@ class ReaderPreferences(
}
}
fun textReaderStyle(context: Context) = TextReaderStyle(
fontSize = textFontSize.get(),
lineSpacingPercent = textLineSpacing.get(),
paragraphSpacing = textParagraphSpacing.get(),
fontFamily = textFontFamily.get(),
readerTheme = readerTheme.get(),
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES,
)
fun textReaderStyle(context: Context): TextReaderStyle {
val displayMetrics = context.resources.displayMetrics
val rawWidth = displayMetrics.widthPixels.coerceAtLeast(1)
val rawHeight = displayMetrics.heightPixels.coerceAtLeast(1)
// Keep the same physical page proportions as the device while capping the
// raster size so high-resolution phones do not create oversized bitmaps.
val renderScale = minOf(
1f,
TEXT_READER_MAX_RENDER_WIDTH / rawWidth.toFloat(),
TEXT_READER_MAX_RENDER_HEIGHT / rawHeight.toFloat(),
)
return TextReaderStyle(
fontSize = textFontSize.get(),
lineSpacingPercent = textLineSpacing.get(),
paragraphSpacing = textParagraphSpacing.get(),
fontFamily = textFontFamily.get(),
readerTheme = readerTheme.get(),
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES,
pageWidthPx = (rawWidth * renderScale).roundToInt().coerceAtLeast(1),
pageHeightPx = (rawHeight * renderScale).roundToInt().coerceAtLeast(1),
density = displayMetrics.density * renderScale,
fontSizePx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
textFontSize.get().toFloat(),
displayMetrics,
) * renderScale,
paragraphSpacingPx = (
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
textParagraphSpacing.get().toFloat(),
displayMetrics,
) * renderScale
).roundToInt(),
)
}
// endregion
@@ -336,6 +366,9 @@ class ReaderPreferences(
const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25
private const val TEXT_READER_MAX_RENDER_WIDTH = 1440
private const val TEXT_READER_MAX_RENDER_HEIGHT = 2400
const val MILLI_CONVERSION = 100
val TapZones = listOf(
@@ -399,4 +432,9 @@ data class TextReaderStyle(
val fontFamily: ReaderPreferences.TextFontFamily,
val readerTheme: Int,
val automaticDark: Boolean,
val pageWidthPx: Int,
val pageHeightPx: Int,
val density: Float,
val fontSizePx: Float,
val paragraphSpacingPx: Int,
)
@@ -32,20 +32,49 @@ class ReaderSettingsViewModel(
.stateIn(viewModelScope, SharingStarted.Lazily, null)
private var textSettingsReloadJob: Job? = null
private var textAppearanceReloadJob: Job? = null
private var textSettingsDirty = false
/**
* Layout-affecting text settings are previewed quickly, then the chapter is
* repaginated after a short debounce. This keeps the slider responsive while
* ensuring the number of characters/lines on a page follows the new style.
*/
fun requestTextSettingsReload() {
textSettingsDirty = true
textSettingsReloadJob?.cancel()
textSettingsReloadJob = viewModelScope.launch {
delay(200)
delay(250)
onTextSettingsPreview()
textSettingsDirty = false
onTextSettingsCommit()
textSettingsReloadJob = null
}
}
/**
* Theme-only changes do not affect page boundaries, so avoid an unnecessary
* full repagination.
*/
fun requestTextAppearanceReload() {
textAppearanceReloadJob?.cancel()
textAppearanceReloadJob = viewModelScope.launch {
delay(50)
onTextSettingsPreview()
textAppearanceReloadJob = null
}
}
fun flushTextSettingsReload() {
if (!textSettingsDirty) return
val hadPendingAppearanceReload = textAppearanceReloadJob != null
textAppearanceReloadJob?.cancel()
textAppearanceReloadJob = null
if (!textSettingsDirty) {
if (hadPendingAppearanceReload) {
onTextSettingsPreview()
}
return
}
textSettingsReloadJob?.cancel()
textSettingsReloadJob = null
onTextSettingsPreview()