Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0e0ef1575 |
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user