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, selected = readerTheme == value,
onClick = { onClick = {
viewModel.preferences.readerTheme.set(value) viewModel.preferences.readerTheme.set(value)
viewModel.requestTextSettingsReload() viewModel.requestTextAppearanceReload()
}, },
label = { Text(stringResource(labelRes)) }, label = { Text(stringResource(labelRes)) },
) )
@@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir import eu.kanade.tachiyomi.util.storage.cacheImageDir
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -186,6 +187,12 @@ class ReaderViewModel(
*/ */
private var loader: ChapterLoader? = null 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 * The time the chapter was started reading
*/ */
@@ -841,17 +848,26 @@ class ReaderViewModel(
if (chapter.pageLoader?.usesTextReaderStyle != true) return if (chapter.pageLoader?.usesTextReaderStyle != true) return
if (chapter.pages?.any { it is TextReaderPage } != true) return if (chapter.pages?.any { it is TextReaderPage } != true) return
val chapterLoader = loader ?: 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 { try {
val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO 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. // Resource cleanup must survive cancellation by the next slider value.
delay(2000) viewModelScope.launchNonCancellable {
delay(750)
oldLoader.recycle() oldLoader.recycle()
}
eventChannel.trySend(Event.ReloadTextViewerChapters)
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" } 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.i18n.MR
import tachiyomi.source.local.LocalSource import tachiyomi.source.local.LocalSource
import tachiyomi.source.local.io.Format 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. * 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)) 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 val oldLoader = chapter.pageLoader
chapter.pageLoader = newLoader chapter.pageLoader = newLoader
chapter.requestedPage = (readingProgress * pages.lastIndex) chapter.requestedPage = (readingProgress.coerceIn(0f, 0.999999f) * pages.size)
.roundToInt() .toInt()
.coerceIn(0, pages.lastIndex) .coerceIn(0, pages.lastIndex)
chapter.state = ReaderChapter.State.Loaded(pages) chapter.state = ReaderChapter.State.Loaded(pages)
oldLoader oldLoader
@@ -16,13 +16,10 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.Reader import java.io.Reader
import kotlin.math.roundToInt
internal object TextPageRenderer { internal object TextPageRenderer {
private const val PAGE_WIDTH = 1440 private const val MARGIN_DP = 32f
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 STREAM_BUFFER_CHARS = 64 * 1024 private const val STREAM_BUFFER_CHARS = 64 * 1024
private const val STREAM_READ_CHARS = 8 * 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> { private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> {
if (text.isEmpty()) return emptyList() 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)) if (layout.lineCount == 0) return listOf(PageSlice(0, text.length))
val pages = mutableListOf<PageSlice>() val pages = mutableListOf<PageSlice>()
@@ -111,7 +109,7 @@ internal object TextPageRenderer {
var endLine = startLine var endLine = startLine
while ( while (
endLine + 1 < layout.lineCount && endLine + 1 < layout.lineCount &&
layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT layout.getLineBottom(endLine + 1) - startTop <= metrics.contentHeight
) { ) {
endLine++ endLine++
} }
@@ -125,13 +123,14 @@ internal object TextPageRenderer {
} }
fun render(text: String, style: TextReaderStyle): InputStream { 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 canvas = Canvas(bitmap)
val (backgroundColor, _) = resolveColors(style) val (backgroundColor, _) = resolveColors(style)
canvas.drawColor(backgroundColor) canvas.drawColor(backgroundColor)
canvas.save() canvas.save()
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat()) canvas.translate(metrics.horizontalMargin.toFloat(), metrics.verticalMargin.toFloat())
createLayout(text, style).draw(canvas) createLayout(text, style, metrics.contentWidth).draw(canvas)
canvas.restore() canvas.restore()
val output = ByteArrayOutputStream() val output = ByteArrayOutputStream()
@@ -140,25 +139,46 @@ internal object TextPageRenderer {
return ByteArrayInputStream(output.toByteArray()) 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 (_, textColor) = resolveColors(style)
val paint = TextPaint().apply { val paint = TextPaint().apply {
isAntiAlias = true isAntiAlias = true
color = textColor color = textColor
textSize = style.fontSize * 3f textSize = style.fontSizePx.coerceAtLeast(1f)
typeface = style.fontFamily.androidFamilyName typeface = style.fontFamily.androidFamilyName
?.let { Typeface.create(it, Typeface.NORMAL) } ?.let { Typeface.create(it, Typeface.NORMAL) }
?: Typeface.DEFAULT ?: Typeface.DEFAULT
} }
val styledText = addParagraphSpacing(text, style.paragraphSpacing * 3) val styledText = addParagraphSpacing(text, style.paragraphSpacingPx)
return StaticLayout.Builder return StaticLayout.Builder
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH) .obtain(styledText, 0, styledText.length, paint, contentWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL) .setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(true) .setIncludePad(true)
.setLineSpacing(0f, style.lineSpacingPercent / 100f) .setLineSpacing(0f, style.lineSpacingPercent / 100f)
.build() .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> { private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> {
val useDark = when (style.readerTheme) { val useDark = when (style.readerTheme) {
0 -> false 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( private data class PageSlice(
val start: Int, val start: Int,
val endExclusive: Int, val endExclusive: Int,
@@ -5,9 +5,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
/** /**
* A rendered text page whose style can be changed without rebuilding the chapter. * A rendered text page whose visual style can be refreshed cheaply while layout-affecting
* Page boundaries remain stable during the live preview; a full repagination is done * changes trigger a debounced chapter repagination to rebuild page boundaries.
* once when the settings dialog is dismissed.
*/ */
internal class TextReaderPage( internal class TextReaderPage(
index: Int, index: Int,
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader.setting
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.util.TypedValue
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
@@ -137,7 +138,20 @@ class ReaderPreferences(
} }
} }
fun textReaderStyle(context: Context) = TextReaderStyle( 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(), fontSize = textFontSize.get(),
lineSpacingPercent = textLineSpacing.get(), lineSpacingPercent = textLineSpacing.get(),
paragraphSpacing = textParagraphSpacing.get(), paragraphSpacing = textParagraphSpacing.get(),
@@ -145,7 +159,23 @@ class ReaderPreferences(
readerTheme = readerTheme.get(), readerTheme = readerTheme.get(),
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES, 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 // endregion
@@ -336,6 +366,9 @@ class ReaderPreferences(
const val WEBTOON_PADDING_MIN = 0 const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25 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 const val MILLI_CONVERSION = 100
val TapZones = listOf( val TapZones = listOf(
@@ -399,4 +432,9 @@ data class TextReaderStyle(
val fontFamily: ReaderPreferences.TextFontFamily, val fontFamily: ReaderPreferences.TextFontFamily,
val readerTheme: Int, val readerTheme: Int,
val automaticDark: Boolean, 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) .stateIn(viewModelScope, SharingStarted.Lazily, null)
private var textSettingsReloadJob: Job? = null private var textSettingsReloadJob: Job? = null
private var textAppearanceReloadJob: Job? = null
private var textSettingsDirty = false 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() { fun requestTextSettingsReload() {
textSettingsDirty = true textSettingsDirty = true
textSettingsReloadJob?.cancel() textSettingsReloadJob?.cancel()
textSettingsReloadJob = viewModelScope.launch { textSettingsReloadJob = viewModelScope.launch {
delay(200) delay(250)
onTextSettingsPreview() onTextSettingsPreview()
textSettingsDirty = false
onTextSettingsCommit()
textSettingsReloadJob = null 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() { fun flushTextSettingsReload() {
if (!textSettingsDirty) return val hadPendingAppearanceReload = textAppearanceReloadJob != null
textAppearanceReloadJob?.cancel()
textAppearanceReloadJob = null
if (!textSettingsDirty) {
if (hadPendingAppearanceReload) {
onTextSettingsPreview()
}
return
}
textSettingsReloadJob?.cancel() textSettingsReloadJob?.cancel()
textSettingsReloadJob = null textSettingsReloadJob = null
onTextSettingsPreview() onTextSettingsPreview()