4 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
javara999 6391c9376c Improve text reader streaming and theming
Update website / update_website (release) Waiting to run
Build & Test / Build & Test App (push) Has been cancelled
2026-09-19 19:39:48 +09:00
javara999 dcec339477 fix: avoid ANR during live text style updates
Build & Test / Build & Test App (push) Has been cancelled
2026-09-19 13:42:50 +09:00
javara999 1b01c894ad feat: add Kavita document fallback and live text settings
Build & Test / Build & Test App (push) Has been cancelled
2026-09-19 12:58:13 +09:00
21 changed files with 950 additions and 112 deletions
+1
View File
@@ -216,6 +216,7 @@ dependencies {
implementation(projects.coreMetadata) implementation(projects.coreMetadata)
implementation(projects.sourceApi) implementation(projects.sourceApi)
implementation(projects.sourceLocal) implementation(projects.sourceLocal)
implementation(libs.jsoup)
implementation(projects.data) implementation(projects.data)
implementation(projects.domain) implementation(projects.domain)
implementation(projects.presentationCore) implementation(projects.presentationCore)
@@ -51,7 +51,10 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
themes.map { (labelRes, value) -> themes.map { (labelRes, value) ->
FilterChip( FilterChip(
selected = readerTheme == value, selected = readerTheme == value,
onClick = { viewModel.preferences.readerTheme.set(value) }, onClick = {
viewModel.preferences.readerTheme.set(value)
viewModel.requestTextAppearanceReload()
},
label = { Text(stringResource(labelRes)) }, label = { Text(stringResource(labelRes)) },
) )
} }
@@ -9,7 +9,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
@@ -33,15 +32,11 @@ fun ReaderSettingsDialog(
stringResource(MR.strings.pref_category_text_reader), stringResource(MR.strings.pref_category_text_reader),
) )
val pagerState = rememberPagerState { tabTitles.size } val pagerState = rememberPagerState { tabTitles.size }
val initialTextStyle = remember { viewModel.preferences.textReaderStyle() }
BoxWithConstraints { BoxWithConstraints {
TabbedDialog( TabbedDialog(
modifier = Modifier.heightIn(max = maxHeight * 0.75f), modifier = Modifier.heightIn(max = maxHeight * 0.75f),
onDismissRequest = { onDismissRequest = {
if (viewModel.preferences.textReaderStyle() != initialTextStyle) { viewModel.flushTextSettingsReload()
viewModel.onTextSettingsChanged()
}
onDismissRequest() onDismissRequest()
onShowMenus() onShowMenus()
}, },
@@ -51,7 +46,7 @@ fun ReaderSettingsDialog(
val window = (LocalView.current.parent as? DialogWindowProvider)?.window val window = (LocalView.current.parent as? DialogWindowProvider)?.window
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
if (pagerState.currentPage == 2) { if (pagerState.currentPage == 2 || pagerState.currentPage == 3) {
window?.setDimAmount(0f) window?.setDimAmount(0f)
onHideMenus() onHideMenus()
} else { } else {
@@ -31,10 +31,13 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
SliderItem( SliderItem(
label = stringResource(MR.strings.pref_text_reader_font_size), label = stringResource(MR.strings.pref_text_reader_font_size),
value = fontSize, value = fontSize,
valueRange = 32..72, valueRange = 10..30,
steps = 19, steps = 19,
valueString = fontSize.toString(), valueString = "$fontSize sp",
onChange = fontSizePref::set, onChange = { value ->
fontSizePref.set(value)
viewModel.requestTextSettingsReload()
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
) )
@@ -45,8 +48,11 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
value = lineSpacing, value = lineSpacing,
valueRange = 90..180, valueRange = 90..180,
steps = 17, steps = 17,
valueString = "$lineSpacing%", valueString = String.format("%.2fx", lineSpacing / 100f),
onChange = lineSpacingPref::set, onChange = { value ->
lineSpacingPref.set(value)
viewModel.requestTextSettingsReload()
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
) )
@@ -55,10 +61,13 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
SliderItem( SliderItem(
label = stringResource(MR.strings.pref_text_reader_paragraph_spacing), label = stringResource(MR.strings.pref_text_reader_paragraph_spacing),
value = paragraphSpacing, value = paragraphSpacing,
valueRange = 0..48, valueRange = 0..16,
steps = 11, steps = 15,
valueString = paragraphSpacing.toString(), valueString = "$paragraphSpacing dp",
onChange = paragraphSpacingPref::set, onChange = { value ->
paragraphSpacingPref.set(value)
viewModel.requestTextSettingsReload()
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
) )
@@ -68,7 +77,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
fontFamilies.forEach { (label, value) -> fontFamilies.forEach { (label, value) ->
FilterChip( FilterChip(
selected = fontFamily == value, selected = fontFamily == value,
onClick = { fontFamilyPref.set(value) }, onClick = {
fontFamilyPref.set(value)
viewModel.requestTextSettingsReload()
},
label = { Text(stringResource(label)) }, label = { Text(stringResource(label)) },
) )
} }
@@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.source.kavita
import eu.kanade.tachiyomi.source.Source
private const val KAVITA_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.kavita."
internal fun Source.isKavitaSource(): Boolean =
this::class.java.name.startsWith(KAVITA_PACKAGE_PREFIX)
/**
* Kavita extension chapter URLs are normally /Chapter/{id}. Keep compatibility
* with older/alternate forms used by reading lists and previous releases.
*/
internal fun String.toKavitaChapterId(): Int? {
Regex("""(?:^|/)Chapter/(\d+)""", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
?.let { return it }
Regex("""(?:^|/)chapter_(\d+)""", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
?.let { return it }
Regex("""[?&]chapterId=(\d+)""", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.getOrNull(1)
?.toIntOrNull()
?.let { return it }
return null
}
@@ -223,6 +223,16 @@ class ReaderActivity : BaseActivity() {
ReaderViewModel.Event.ReloadViewerChapters -> { ReaderViewModel.Event.ReloadViewerChapters -> {
viewModel.state.value.viewerChapters?.let(::setChapters) viewModel.state.value.viewerChapters?.let(::setChapters)
} }
ReaderViewModel.Event.RefreshCurrentTextPage -> {
viewModel.state.value.viewer?.refreshCurrentPage()
}
ReaderViewModel.Event.ReloadTextViewerChapters -> {
val state = viewModel.state.value
val chapters = state.viewerChapters
if (chapters != null) {
state.viewer?.refreshTextChapters(chapters)
}
}
ReaderViewModel.Event.PageChanged -> { ReaderViewModel.Event.PageChanged -> {
displayRefreshHost.flash() displayRefreshHost.flash()
} }
@@ -254,7 +264,8 @@ class ReaderActivity : BaseActivity() {
readerState = viewModel.state, readerState = viewModel.state,
onChangeReadingMode = viewModel::setMangaReadingMode, onChangeReadingMode = viewModel::setMangaReadingMode,
onChangeOrientation = viewModel::setMangaOrientationType, onChangeOrientation = viewModel::setMangaOrientationType,
onTextSettingsChanged = viewModel::reloadTextReaderStyle, onTextSettingsPreview = viewModel::previewTextReaderStyle,
onTextSettingsCommit = viewModel::commitTextReaderStyle,
preferences = readerPreferences, preferences = readerPreferences,
) )
} }
@@ -40,6 +40,7 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
import eu.kanade.tachiyomi.ui.reader.loader.TextReaderPage
import eu.kanade.tachiyomi.ui.reader.model.InsertPage import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
@@ -56,6 +57,8 @@ 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.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -184,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
*/ */
@@ -823,27 +832,45 @@ class ReaderViewModel(
mutableState.update { it.copy(dialog = null) } mutableState.update { it.copy(dialog = null) }
} }
fun reloadTextReaderStyle() { fun previewTextReaderStyle() {
val chapter = state.value.currentChapter ?: return val chapter = state.value.currentChapter ?: return
if (chapter.pageLoader?.usesTextReaderStyle != true) return if (chapter.pageLoader?.usesTextReaderStyle != true) return
val chapterLoader = loader ?: return val style = readerPreferences.textReaderStyle(context)
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0) val textPages = chapter.pages?.filterIsInstance<TextReaderPage>().orEmpty()
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0) if (textPages.isEmpty()) return
val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex
viewModelScope.launchIO { textPages.forEach { it.updateStyle(style) }
eventChannel.trySend(Event.RefreshCurrentTextPage)
}
fun commitTextReaderStyle() {
val chapter = state.value.currentChapter ?: return
if (chapter.pageLoader?.usesTextReaderStyle != true) return
if (chapter.pages?.any { it is TextReaderPage } != true) return
val chapterLoader = loader ?: return
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 {
chapter.pageLoader?.recycle() val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
chapter.pageLoader = null
chapter.state = ReaderChapter.State.Wait // Resource cleanup must survive cancellation by the next slider value.
chapter.requestedPage = requestedPage viewModelScope.launchNonCancellable {
chapterLoader.loadChapter(chapter) delay(750)
val newLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0) oldLoader.recycle()
chapter.requestedPage = (readingProgress * newLastIndex).roundToInt().coerceIn(0, newLastIndex) }
eventChannel.send(Event.ReloadViewerChapters)
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 reload text reader style" } logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
} }
} }
} }
@@ -1037,6 +1064,8 @@ class ReaderViewModel(
sealed interface Event { sealed interface Event {
data object ReloadViewerChapters : Event data object ReloadViewerChapters : Event
data object RefreshCurrentTextPage : Event
data object ReloadTextViewerChapters : Event
data object PageChanged : Event data object PageChanged : Event
data class SetOrientation(val orientation: Int) : Event data class SetOrientation(val orientation: Int) : Event
data class SetCoverResult(val result: SetAsCoverResult) : Event data class SetCoverResult(val result: SetAsCoverResult) : Event
@@ -34,9 +34,9 @@ internal class BookOasisFilePageLoader(
val uniFile = UniFile.fromFile(file) val uniFile = UniFile.fromFile(file)
?: error("Unable to open cached BookOasis file: ${file.absolutePath}") ?: error("Unable to open cached BookOasis file: ${file.absolutePath}")
val loader = when (ref.format) { val loader = when (ref.format) {
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle) "epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir)
"pdf" -> PdfPageLoader(context, uniFile) "pdf" -> PdfPageLoader(context, uniFile)
"txt" -> TextPageLoader(uniFile, textStyle) "txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir)
else -> error("Unsupported BookOasis document format: ${ref.format}") else -> error("Unsupported BookOasis document format: ${ref.format}")
} }
@@ -7,6 +7,8 @@ import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef
import eu.kanade.tachiyomi.source.kavita.isKavitaSource
import eu.kanade.tachiyomi.source.kavita.toKavitaChapterId
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
@@ -20,6 +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 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.
@@ -71,6 +75,40 @@ class ChapterLoader(
} }
} }
suspend fun reloadTextChapter(chapter: ReaderChapter, readingProgress: Float): PageLoader? {
return withIOContext {
val newLoader = getPageLoader(chapter)
if (!newLoader.usesTextReaderStyle) {
newLoader.recycle()
return@withIOContext null
}
try {
val pages = newLoader.getPages()
.onEach { it.chapter = chapter }
if (pages.isEmpty()) {
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.coerceIn(0f, 0.999999f) * pages.size)
.toInt()
.coerceIn(0, pages.lastIndex)
chapter.state = ReaderChapter.State.Loaded(pages)
oldLoader
} catch (e: Throwable) {
newLoader.recycle()
throw e
}
}
}
/** /**
* Checks [chapter] to be loaded based on present pages and loader in addition to state. * Checks [chapter] to be loaded based on present pages and loader in addition to state.
*/ */
@@ -91,6 +129,7 @@ class ChapterLoader(
source, source,
) )
val bookOasisRef = dbChapter.url.toBookOasisChapterRef() val bookOasisRef = dbChapter.url.toBookOasisChapterRef()
val kavitaChapterId = dbChapter.url.toKavitaChapterId()
return when { return when {
isDownloaded -> DownloadPageLoader( isDownloaded -> DownloadPageLoader(
@@ -104,13 +143,23 @@ class ChapterLoader(
when (format) { when (format) {
is Format.Directory -> DirectoryPageLoader(format.file) is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context)) is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
is Format.Epub -> EpubPageLoader(format.file.epubReader(context), readerPreferences.textReaderStyle()) is Format.Epub -> EpubPageLoader(format.file.epubReader(context), readerPreferences.textReaderStyle(context), context.cacheDir)
is Format.Pdf -> PdfPageLoader(context, format.file) is Format.Pdf -> PdfPageLoader(context, format.file)
is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle()) is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle(context), context.cacheDir)
} }
} }
source is HttpSource && source.isBookOasisSource() && bookOasisRef != null -> { source is HttpSource && source.isBookOasisSource() && bookOasisRef != null -> {
BookOasisFilePageLoader(context, source, bookOasisRef, readerPreferences.textReaderStyle()) BookOasisFilePageLoader(context, source, bookOasisRef, readerPreferences.textReaderStyle(context))
}
source is HttpSource && source.isKavitaSource() && kavitaChapterId != null -> {
KavitaCompatPageLoader(
context = context,
chapter = chapter,
source = source,
chapterCache = chapterCache,
chapterId = kavitaChapterId,
textStyle = readerPreferences.textReaderStyle(context),
)
} }
source is HttpSource -> HttpPageLoader(chapter, source, chapterCache) source is HttpSource -> HttpPageLoader(chapter, source, chapterCache)
source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString())) source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString()))
@@ -4,41 +4,135 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import mihon.core.archive.EpubReader import mihon.core.archive.EpubReader
import org.jsoup.Jsoup
import java.io.File
import java.io.InputStream
import java.io.StringReader
/** /**
* Loader used to load a chapter from a .epub file. * Loader used to load a chapter from a .epub file.
* *
* Text-based EPUBs are rendered as regular Mihon pages. Image-only EPUBs * Text-based EPUBs are rendered as regular Mihon pages. EPUB spine documents
* keep the original image extraction behavior. * are processed one at a time so the full book text is never retained in heap.
* Image-only EPUBs keep the original image extraction behavior.
*/ */
internal class EpubPageLoader( internal class EpubPageLoader(
private val reader: EpubReader, private val reader: EpubReader,
private val style: TextReaderStyle, private val style: TextReaderStyle,
private val cacheDir: File,
) : PageLoader() { ) : PageLoader() {
override var isLocal: Boolean = true override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean = true override val usesTextReaderStyle: Boolean = true
override suspend fun getPages(): List<ReaderPage> { private var pageStore: TextPageStore? = null
val textPages = reader.getTextFromPages()
.filter { it.isNotBlank() }
.flatMap { TextPageRenderer.paginate(it, style) }
if (textPages.isNotEmpty()) { override suspend fun getPages(): List<ReaderPage> {
return textPages.mapIndexed { index, text -> check(!isRecycled)
ReaderPage(index).apply {
stream = { TextPageRenderer.render(text, style) } pageStore?.close()
status = Page.State.Ready pageStore = null
val store = TextPageStore(cacheDir)
return try {
val textPages = buildTextPages(store)
if (textPages.isNotEmpty()) {
store.finishWriting()
pageStore = store
textPages
} else {
store.close()
reader.getImagesFromPages().mapIndexed { index, path ->
ReaderPage(index).apply {
stream = { reader.getInputStream(path)!! }
status = Page.State.Ready
}
}
}
} catch (e: Throwable) {
store.close()
throw e
}
}
private fun buildTextPages(store: TextPageStore): MutableList<ReaderPage> {
val packageHref = reader.getPackageHref()
val packageDocument = reader.getPackageDocument(packageHref)
val manifest = packageDocument.select("manifest > item")
.filter { item ->
val mediaType = item.attr("media-type")
mediaType == "application/xhtml+xml" || mediaType == "text/html"
}
.associateBy { it.attr("id") }
val spine = packageDocument.select("spine > itemref")
.map { it.attr("idref") }
val result = mutableListOf<ReaderPage>()
spine.forEach { id ->
val href = manifest[id]?.attr("href").orEmpty()
if (href.isBlank()) return@forEach
openEntry(packageHref, href)?.use { input ->
val text = extractText(input)
if (text.isBlank()) return@use
StringReader(text).use { textReader ->
TextPageRenderer.paginate(textReader, style) { pageText ->
val ref = store.append(pageText)
result += TextReaderPage(
index = result.size,
pageTextProvider = { store.read(ref) },
initialStyle = style,
)
}
} }
} }
} }
return result
}
return reader.getImagesFromPages().mapIndexed { index, path -> private fun extractText(input: InputStream): String {
ReaderPage(index).apply { val pageDoc = Jsoup.parse(input, null, "")
stream = { reader.getInputStream(path)!! } pageDoc.select("br").append("\n")
status = Page.State.Ready pageDoc.select("p,div,li,h1,h2,h3,h4,h5,h6,blockquote").append("\n")
return pageDoc.body().wholeText()
?.replace(Regex("[ \\t]+\\n"), "\n")
?.replace(Regex("\\n{3,}"), "\n\n")
?.trim()
.orEmpty()
}
private fun openEntry(packageHref: String, href: String): InputStream? {
val resolved = resolveEntryPath(packageHref, href)
val candidates = linkedSetOf(
resolved,
resolved.replace('\\', '/'),
resolved.replace('/', '\\'),
)
return candidates.firstNotNullOfOrNull { path ->
reader.getInputStream(path)
}
}
private fun resolveEntryPath(packageHref: String, href: String): String {
val relative = href
.substringBefore('#')
.substringBefore('?')
.trimStart('/', '\\')
val separator = if ('\\' in packageHref) '\\' else '/'
val base = packageHref.substringBeforeLast(separator, "")
val combined = if (base.isBlank()) relative else "$base$separator$relative"
val parts = combined.split('/', '\\')
val normalized = ArrayDeque<String>()
parts.forEach { part ->
when (part) {
"", "." -> Unit
".." -> if (normalized.isNotEmpty()) normalized.removeLast()
else -> normalized.addLast(part)
} }
} }
return normalized.joinToString(separator.toString())
} }
override suspend fun loadPage(page: ReaderPage) { override suspend fun loadPage(page: ReaderPage) {
@@ -46,6 +140,8 @@ internal class EpubPageLoader(
} }
override fun recycle() { override fun recycle() {
pageStore?.close()
pageStore = null
super.recycle() super.recycle()
reader.close() reader.close()
} }
@@ -0,0 +1,169 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import mihon.core.archive.epubReader
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.util.Locale
/**
* Compatibility loader for Kavita document chapters.
*
* The Kavita extension renders every chapter through /Reader/image. That works
* for image/archive content, but EPUB/TXT can fail while Kavita is preparing an
* image cache. For document files, download the original chapter and use
* Mihon's native document reader instead. Other formats keep the extension's
* normal HttpPageLoader behavior.
*/
internal class KavitaCompatPageLoader(
private val context: Context,
private val chapter: ReaderChapter,
private val source: HttpSource,
private val chapterCache: ChapterCache,
private val chapterId: Int,
private val textStyle: TextReaderStyle,
) : PageLoader() {
override var isLocal: Boolean = false
override val usesTextReaderStyle: Boolean
get() = delegate?.usesTextReaderStyle == true
private var delegate: PageLoader? = null
override suspend fun getPages(): List<ReaderPage> {
check(!isRecycled)
val format = detectDocumentFormat()
val loader = when (format) {
"epub", "txt" -> createDocumentLoader(format)
else -> HttpPageLoader(chapter, source, chapterCache)
}
delegate = loader
isLocal = loader.isLocal
return loader.getPages()
}
override suspend fun loadPage(page: ReaderPage) {
delegate?.loadPage(page)
}
override fun retryPage(page: ReaderPage) {
delegate?.retryPage(page)
}
override fun recycle() {
delegate?.recycle()
delegate = null
super.recycle()
}
private suspend fun detectDocumentFormat(): String? {
val apiBase = source.baseUrl.trimEnd('/') + "/api"
val request = Request.Builder()
.url("$apiBase/Chapter?chapterId=$chapterId")
.headers(source.headers)
.get()
.build()
source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) {
// Preserve the extension's normal behavior when chapter metadata
// cannot be queried. It may still be readable as image content.
return null
}
val body = response.body.string()
if (body.isBlank()) return null
val chapterJson = JSONObject(body)
val files = chapterJson.optJSONArray("files") ?: return null
for (i in 0 until files.length()) {
val file = files.optJSONObject(i) ?: continue
val extension = normalizeExtension(
file.optString("extension").ifBlank { file.optString("filePath") },
)
if (extension == "epub" || extension == "txt") {
return extension
}
// Current Kavita format enum: EPUB == 3. TXT has no dedicated
// enum value in upstream Kavita, so it is detected by extension.
if (file.optInt("format", -1) == 3) {
return "epub"
}
}
}
return null
}
private fun normalizeExtension(value: String): String =
value.substringAfterLast('.', value)
.trim()
.trimStart('.')
.lowercase(Locale.ROOT)
private suspend fun createDocumentLoader(format: String): PageLoader {
val file = getOrDownloadDocument(format)
val uniFile = UniFile.fromFile(file)
?: throw IOException("Unable to open cached Kavita ${format.uppercase()} file")
return when (format) {
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir)
"txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir)
else -> error("Unsupported Kavita document format: $format")
}
}
private suspend fun getOrDownloadDocument(format: String): File {
val cacheDir = File(context.cacheDir, "kavita_reader").apply { mkdirs() }
val target = File(cacheDir, "${source.id}-$chapterId.$format")
if (target.isFile && target.length() > 0L) return target
val temp = File(cacheDir, "${target.name}.part")
if (temp.exists()) temp.delete()
val apiBase = source.baseUrl.trimEnd('/') + "/api"
val request = Request.Builder()
.url("$apiBase/Download/chapter?chapterId=$chapterId")
.headers(source.headers)
.get()
.build()
source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) {
val message = when (response.code) {
403 -> "Kavita account does not have Download permission"
404 -> "Kavita original file is missing or no longer available"
500 -> "Kavita could not access the original $format file"
else -> "Kavita document download failed: HTTP ${response.code}"
}
throw IOException(message)
}
temp.outputStream().use { output ->
response.body.byteStream().use { input -> input.copyTo(output) }
}
}
if (temp.length() <= 0L) {
temp.delete()
throw IOException("Kavita returned an empty ${format.uppercase()} file")
}
if (!temp.renameTo(target)) {
temp.copyTo(target, overwrite = true)
temp.delete()
}
return target
}
}
@@ -1,11 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import java.nio.ByteBuffer import java.io.File
import java.nio.charset.CharacterCodingException import java.io.InputStreamReader
import java.io.Reader
import java.nio.charset.Charset import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -13,39 +13,94 @@ import java.nio.charset.StandardCharsets
internal class TextPageLoader( internal class TextPageLoader(
private val file: UniFile, private val file: UniFile,
private val style: TextReaderStyle, private val style: TextReaderStyle,
private val cacheDir: File,
) : PageLoader() { ) : PageLoader() {
override var isLocal: Boolean = true override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean = true override val usesTextReaderStyle: Boolean = true
private var pageStore: TextPageStore? = null
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val bytes = file.openInputStream().use { it.readBytes() } pageStore?.close()
val text = decodeText(bytes) pageStore = null
return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText ->
ReaderPage(index).apply { val store = TextPageStore(cacheDir)
stream = { TextPageRenderer.render(pageText, style) } return try {
status = Page.State.Ready val pages = mutableListOf<ReaderPage>()
openDecodedReader().use { reader ->
TextPageRenderer.paginate(reader, style) { pageText ->
val ref = store.append(pageText)
pages += TextReaderPage(
index = pages.size,
pageTextProvider = { store.read(ref) },
initialStyle = style,
)
}
} }
store.finishWriting()
pageStore = store
pages
} catch (e: Throwable) {
store.close()
throw e
} }
} }
private fun decodeText(bytes: ByteArray): String { override fun recycle() {
if (bytes.size >= 3 && pageStore?.close()
bytes[0] == 0xEF.toByte() && pageStore = null
bytes[1] == 0xBB.toByte() && super.recycle()
bytes[2] == 0xBF.toByte() }
) {
return String(bytes, 3, bytes.size - 3, StandardCharsets.UTF_8) private fun openDecodedReader(): Reader {
val charset = detectCharset()
return InputStreamReader(file.openInputStream().buffered(), charset)
}
private fun detectCharset(): Charset {
val prefix = ByteArray(3)
val prefixSize = file.openInputStream().buffered().use { input ->
input.read(prefix)
} }
if (
prefixSize >= 3 &&
prefix[0] == 0xEF.toByte() &&
prefix[1] == 0xBB.toByte() &&
prefix[2] == 0xBF.toByte()
) {
return StandardCharsets.UTF_8
}
if (prefixSize >= 2 && prefix[0] == 0xFF.toByte() && prefix[1] == 0xFE.toByte()) {
return StandardCharsets.UTF_16LE
}
if (prefixSize >= 2 && prefix[0] == 0xFE.toByte() && prefix[1] == 0xFF.toByte()) {
return StandardCharsets.UTF_16BE
}
return if (isValidUtf8()) {
StandardCharsets.UTF_8
} else {
Charset.forName("MS949")
}
}
private fun isValidUtf8(): Boolean {
val decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
return try { return try {
StandardCharsets.UTF_8.newDecoder() InputStreamReader(file.openInputStream().buffered(), decoder).use { reader ->
.onMalformedInput(CodingErrorAction.REPORT) val buffer = CharArray(8 * 1024)
.onUnmappableCharacter(CodingErrorAction.REPORT) while (reader.read(buffer) >= 0) {
.decode(ByteBuffer.wrap(bytes)) // Validation only. Decoded characters are intentionally discarded.
.toString() }
} catch (_: CharacterCodingException) { }
String(bytes, Charset.forName("MS949")) true
} catch (_: Exception) {
false
} }
} }
} }
@@ -15,47 +15,122 @@ import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import java.io.ByteArrayInputStream 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 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 STREAM_BUFFER_CHARS = 64 * 1024
private const val MARGIN = 96 private const val STREAM_READ_CHARS = 8 * 1024
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2)
fun paginate(text: String, style: TextReaderStyle): List<String> { fun paginate(text: String, style: TextReaderStyle): List<String> {
if (text.isBlank()) return emptyList() if (text.isBlank()) return emptyList()
val layout = createLayout(text, style) return calculatePageSlices(text, style)
if (layout.lineCount == 0) return listOf(text) .mapNotNull { slice ->
text.substring(slice.start, slice.endExclusive)
.trim()
.takeIf { it.isNotEmpty() }
}
.ifEmpty { listOf(text) }
}
val pages = mutableListOf<String>() /**
* Incrementally paginates a text stream while keeping only a bounded text
* window in memory. The final incomplete page is retained between windows
* so normal page filling is preserved across chunk boundaries.
*/
fun paginate(
reader: Reader,
style: TextReaderStyle,
onPage: (String) -> Unit,
) {
val working = StringBuilder(STREAM_BUFFER_CHARS + STREAM_READ_CHARS)
val chars = CharArray(STREAM_READ_CHARS)
var firstChunk = true
while (true) {
val read = reader.read(chars)
if (read < 0) break
if (read == 0) continue
var offset = 0
if (firstChunk) {
firstChunk = false
if (chars[0] == '\uFEFF') {
offset = 1
}
}
if (offset < read) {
working.append(chars, offset, read - offset)
}
if (working.length >= STREAM_BUFFER_CHARS) {
emitCompletePages(working, style, onPage)
}
}
if (working.isNotEmpty()) {
paginate(working.toString(), style).forEach(onPage)
}
}
private fun emitCompletePages(
working: StringBuilder,
style: TextReaderStyle,
onPage: (String) -> Unit,
) {
val text = working.toString()
val slices = calculatePageSlices(text, style)
if (slices.size <= 1) return
for (index in 0 until slices.lastIndex) {
val slice = slices[index]
text.substring(slice.start, slice.endExclusive)
.trim()
.takeIf { it.isNotEmpty() }
?.let(onPage)
}
working.delete(0, slices.last().start)
}
private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> {
if (text.isEmpty()) return emptyList()
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>()
var startLine = 0 var startLine = 0
while (startLine < layout.lineCount) { while (startLine < layout.lineCount) {
val startTop = layout.getLineTop(startLine) val startTop = layout.getLineTop(startLine)
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++
} }
val start = layout.getLineStart(startLine) val start = layout.getLineStart(startLine)
val end = layout.getLineEnd(endLine).coerceAtLeast(start + 1).coerceAtMost(text.length) val end = layout.getLineEnd(endLine).coerceAtLeast(start + 1).coerceAtMost(text.length)
text.substring(start, end).trim().takeIf { it.isNotEmpty() }?.let(pages::add) pages += PageSlice(start, end)
startLine = endLine + 1 startLine = endLine + 1
} }
return pages.ifEmpty { listOf(text) } return pages.ifEmpty { listOf(PageSlice(0, text.length)) }
} }
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)
canvas.drawColor(Color.WHITE) val (backgroundColor, _) = resolveColors(style)
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()
@@ -64,24 +139,62 @@ 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 paint = TextPaint().apply { val paint = TextPaint().apply {
isAntiAlias = true isAntiAlias = true
color = Color.BLACK color = textColor
textSize = style.fontSize.toFloat() 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) 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> {
val useDark = when (style.readerTheme) {
0 -> false
1 -> true
2 -> true
3 -> style.automaticDark
else -> style.automaticDark
}
val background = when (style.readerTheme) {
2 -> Color.rgb(48, 48, 48)
else -> if (useDark) Color.BLACK else Color.WHITE
}
val foreground = if (useDark) Color.WHITE else Color.BLACK
return background to foreground
}
private fun addParagraphSpacing(text: String, spacing: Int): CharSequence { private fun addParagraphSpacing(text: String, spacing: Int): CharSequence {
if (spacing <= 0 || '\n' !in text) return text if (spacing <= 0 || '\n' !in text) return text
val spannable = SpannableString(text) val spannable = SpannableString(text)
@@ -111,4 +224,18 @@ internal object TextPageRenderer {
fm.bottom += spacing fm.bottom += spacing
} }
} }
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,
)
} }
@@ -0,0 +1,69 @@
package eu.kanade.tachiyomi.ui.reader.loader
import java.io.Closeable
import java.io.File
import java.io.RandomAccessFile
import java.nio.charset.StandardCharsets
/**
* Disk-backed storage for text reader pages.
*
* Keeping page strings here avoids retaining an entire large TXT/EPUB in the
* Java heap. Individual pages are loaded only when the viewer renders them.
*/
internal class TextPageStore(cacheRoot: File) : Closeable {
private val cacheDir = File(cacheRoot, "text_reader_pages").apply {
mkdirs()
pruneStaleFiles()
}
private val file = File.createTempFile("pages-", ".utf8", cacheDir)
private var writer: RandomAccessFile? = RandomAccessFile(file, "rw")
@Synchronized
fun append(text: String): PageRef {
val output = checkNotNull(writer) { "Text page store is already finalized" }
val bytes = text.toByteArray(StandardCharsets.UTF_8)
val offset = output.filePointer
output.write(bytes)
return PageRef(offset, bytes.size)
}
@Synchronized
fun finishWriting() {
writer?.fd?.sync()
writer?.close()
writer = null
}
fun read(ref: PageRef): String {
val bytes = ByteArray(ref.byteLength)
RandomAccessFile(file, "r").use { input ->
input.seek(ref.offset)
input.readFully(bytes)
}
return String(bytes, StandardCharsets.UTF_8)
}
override fun close() {
writer?.close()
writer = null
file.delete()
}
internal data class PageRef(
val offset: Long,
val byteLength: Int,
)
private fun File.pruneStaleFiles() {
val cutoff = System.currentTimeMillis() - STALE_FILE_AGE_MS
listFiles()
?.filter { it.isFile && it.lastModified() < cutoff }
?.forEach { it.delete() }
}
private companion object {
const val STALE_FILE_AGE_MS = 24L * 60L * 60L * 1000L
}
}
@@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
/**
* 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,
private val pageTextProvider: () -> String,
initialStyle: TextReaderStyle,
) : ReaderPage(index) {
@Volatile
private var style: TextReaderStyle = initialStyle
init {
stream = { TextPageRenderer.render(pageTextProvider(), style) }
status = Page.State.Ready
}
constructor(
index: Int,
pageText: String,
initialStyle: TextReaderStyle,
) : this(index, { pageText }, initialStyle)
fun updateStyle(newStyle: TextReaderStyle) {
style = newStyle
}
}
@@ -1,6 +1,9 @@
package eu.kanade.tachiyomi.ui.reader.setting package eu.kanade.tachiyomi.ui.reader.setting
import android.content.Context
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
@@ -11,6 +14,7 @@ import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getEnumSet import tachiyomi.core.common.preference.getEnumSet
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import kotlin.math.roundToInt
@Inject @Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@@ -110,23 +114,68 @@ class ReaderPreferences(
// region Text reader // region Text reader
val textFontSize: Preference<Int> = preferenceStore.getInt("reader_text_font_size", 44) val textFontSize: Preference<Int> = preferenceStore.getInt("reader_text_font_size", 16)
val textLineSpacing: Preference<Int> = preferenceStore.getInt("reader_text_line_spacing", 115) val textLineSpacing: Preference<Int> = preferenceStore.getInt("reader_text_line_spacing", 115)
val textParagraphSpacing: Preference<Int> = preferenceStore.getInt("reader_text_paragraph_spacing", 12) val textParagraphSpacing: Preference<Int> = preferenceStore.getInt("reader_text_paragraph_spacing", 4)
val textFontFamily: Preference<TextFontFamily> = preferenceStore.getEnum( val textFontFamily: Preference<TextFontFamily> = preferenceStore.getEnum(
"reader_text_font_family", "reader_text_font_family",
TextFontFamily.SYSTEM, TextFontFamily.SYSTEM,
) )
fun textReaderStyle() = TextReaderStyle( private val textUnitsMigrated = preferenceStore.getBoolean("reader_text_units_v2_migrated", false)
fontSize = textFontSize.get(),
lineSpacingPercent = textLineSpacing.get(), init {
paragraphSpacing = textParagraphSpacing.get(), if (!textUnitsMigrated.get()) {
fontFamily = textFontFamily.get(), val legacyFontSize = textFontSize.get()
) if (legacyFontSize > 30) {
textFontSize.set((legacyFontSize / 3f).roundToInt().coerceIn(10, 30))
textParagraphSpacing.set((textParagraphSpacing.get() / 3f).roundToInt().coerceIn(0, 16))
}
textUnitsMigrated.set(true)
}
}
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 // endregion
@@ -317,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(
@@ -378,4 +430,11 @@ data class TextReaderStyle(
val lineSpacingPercent: Int, val lineSpacingPercent: Int,
val paragraphSpacing: Int, val paragraphSpacing: Int,
val fontFamily: ReaderPreferences.TextFontFamily, 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,
) )
@@ -6,14 +6,18 @@ import eu.kanade.tachiyomi.ui.reader.ReaderViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ReaderSettingsViewModel( class ReaderSettingsViewModel(
readerState: StateFlow<ReaderViewModel.State>, readerState: StateFlow<ReaderViewModel.State>,
val onChangeReadingMode: (ReadingMode) -> Unit, val onChangeReadingMode: (ReadingMode) -> Unit,
val onChangeOrientation: (ReaderOrientation) -> Unit, val onChangeOrientation: (ReaderOrientation) -> Unit,
val onTextSettingsChanged: () -> Unit, private val onTextSettingsPreview: () -> Unit,
private val onTextSettingsCommit: () -> Unit,
val preferences: ReaderPreferences, val preferences: ReaderPreferences,
) : ViewModel() { ) : ViewModel() {
@@ -26,4 +30,55 @@ class ReaderSettingsViewModel(
.map { it.manga } .map { it.manga }
.distinctUntilChanged() .distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Lazily, null) .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(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() {
val hadPendingAppearanceReload = textAppearanceReloadJob != null
textAppearanceReloadJob?.cancel()
textAppearanceReloadJob = null
if (!textSettingsDirty) {
if (hadPendingAppearanceReload) {
onTextSettingsPreview()
}
return
}
textSettingsReloadJob?.cancel()
textSettingsReloadJob = null
onTextSettingsPreview()
textSettingsDirty = false
onTextSettingsCommit()
}
} }
@@ -26,6 +26,14 @@ interface Viewer {
*/ */
fun setChapters(chapters: ViewerChapters) fun setChapters(chapters: ViewerChapters)
/** Rebinds only the currently visible page(s), used for lightweight text-style preview. */
fun refreshCurrentPage() {}
/** Replaces repaginated text pages without expensive per-item diffing where possible. */
fun refreshTextChapters(chapters: ViewerChapters) {
setChapters(chapters)
}
/** /**
* Tells this viewer to move to the given [page]. * Tells this viewer to move to the given [page].
*/ */
@@ -380,6 +380,17 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.setCurrentItem(currentItem, false) pager.setCurrentItem(currentItem, false)
} }
override fun refreshCurrentPage() {
refreshAdapter()
}
override fun refreshTextChapters(chapters: ViewerChapters) {
setChaptersInternal(chapters)
val pages = chapters.currChapter.pages ?: return
val page = pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]
moveToPage(page)
}
/** /**
* Called from the containing activity when a key [event] is received. It should return true * Called from the containing activity when a key [event] is received. It should return true
* if the event was handled, false otherwise. * if the event was handled, false otherwise.
@@ -35,7 +35,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
* Updates this adapter with the given [chapters]. It handles setting a few pages of the * Updates this adapter with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions. * next/previous chapter to allow seamless transitions.
*/ */
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) { fun setChapters(chapters: ViewerChapters, forceTransition: Boolean, useDiff: Boolean = true) {
val newItems = mutableListOf<Any>() val newItems = mutableListOf<Any>()
// Forces chapter transition if there is missing chapters // Forces chapter transition if there is missing chapters
@@ -65,13 +65,18 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
chapters.nextChapter?.pages?.let(newItems::addAll) chapters.nextChapter?.pages?.let(newItems::addAll)
updateItems(newItems) updateItems(newItems, useDiff)
} }
private fun updateItems(newItems: List<Any>) { private fun updateItems(newItems: List<Any>, useDiff: Boolean) {
val result = DiffUtil.calculateDiff(Callback(items, newItems)) if (useDiff) {
items = newItems val result = DiffUtil.calculateDiff(Callback(items, newItems))
result.dispatchUpdatesTo(this) items = newItems
result.dispatchUpdatesTo(this)
} else {
items = newItems
notifyDataSetChanged()
}
} }
fun refresh() { fun refresh() {
@@ -353,6 +353,19 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
min(position + 3, adapter.itemCount - 1), min(position + 3, adapter.itemCount - 1),
) )
} }
override fun refreshCurrentPage() {
refreshAdapter()
}
override fun refreshTextChapters(chapters: ViewerChapters) {
val forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition
adapter.setChapters(chapters, forceTransition, useDiff = false)
val pages = chapters.currChapter.pages ?: return
val page = pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]
moveToPage(page)
currentPage = page
}
} }
// Double the cache size to reduce rebinds/recycles incurred by the extra layout space on scroll direction changes // Double the cache size to reduce rebinds/recycles incurred by the extra layout space on scroll direction changes