diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt index bec343237..116284e8e 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt @@ -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)) }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 9ef474a14..66255157a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index c839c1c7d..6f96fef64 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageRenderer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageRenderer.kt index f97aab33d..d94fe01a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageRenderer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageRenderer.kt @@ -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 { 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() @@ -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 { 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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextReaderPage.kt index c878128ed..6884f7bfc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextReaderPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextReaderPage.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index 74e122c94..e873f771b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -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, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt index f9c23f1db..c705138dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt @@ -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()