3 Commits

Author SHA1 Message Date
javara999 6391c9376c Improve text reader streaming and theming
Build & Test / Build & Test App (push) Waiting to run
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 813 additions and 91 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.requestTextSettingsReload()
},
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,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.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
@@ -823,9 +825,21 @@ 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 style = readerPreferences.textReaderStyle(context)
val textPages = chapter.pages?.filterIsInstance<TextReaderPage>().orEmpty()
if (textPages.isEmpty()) return
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 chapterLoader = loader ?: return
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0) val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0) val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
@@ -833,17 +847,14 @@ class ReaderViewModel(
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
chapter.pageLoader?.recycle() val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
chapter.pageLoader = null eventChannel.send(Event.ReloadTextViewerChapters)
chapter.state = ReaderChapter.State.Wait // Give old visible holders time to detach before closing resources such as EpubReader.
chapter.requestedPage = requestedPage delay(2000)
chapterLoader.loadChapter(chapter) oldLoader.recycle()
val newLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
chapter.requestedPage = (readingProgress * newLastIndex).roundToInt().coerceIn(0, newLastIndex)
eventChannel.send(Event.ReloadViewerChapters)
} 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 +1048,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,7 @@ 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
/** /**
* Loader used to retrieve the [PageLoader] for a given chapter. * Loader used to retrieve the [PageLoader] for a given chapter.
@@ -71,6 +74,36 @@ 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))
}
val oldLoader = chapter.pageLoader
chapter.pageLoader = newLoader
chapter.requestedPage = (readingProgress * pages.lastIndex)
.roundToInt()
.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 +124,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 +138,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,48 +4,144 @@ 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
private var pageStore: TextPageStore? = null
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val textPages = reader.getTextFromPages() check(!isRecycled)
.filter { it.isNotBlank() }
.flatMap { TextPageRenderer.paginate(it, style) }
pageStore?.close()
pageStore = null
val store = TextPageStore(cacheDir)
return try {
val textPages = buildTextPages(store)
if (textPages.isNotEmpty()) { if (textPages.isNotEmpty()) {
return textPages.mapIndexed { index, text -> store.finishWriting()
ReaderPage(index).apply { pageStore = store
stream = { TextPageRenderer.render(text, style) } textPages
status = Page.State.Ready } else {
} store.close()
} reader.getImagesFromPages().mapIndexed { index, path ->
}
return reader.getImagesFromPages().mapIndexed { index, path ->
ReaderPage(index).apply { ReaderPage(index).apply {
stream = { reader.getInputStream(path)!! } stream = { reader.getInputStream(path)!! }
status = Page.State.Ready 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
}
private fun extractText(input: InputStream): String {
val pageDoc = Jsoup.parse(input, null, "")
pageDoc.select("br").append("\n")
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) {
check(!isRecycled) check(!isRecycled)
} }
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 {
stream = { TextPageRenderer.render(pageText, style) }
status = Page.State.Ready
}
}
}
private fun decodeText(bytes: ByteArray): String {
if (bytes.size >= 3 &&
bytes[0] == 0xEF.toByte() &&
bytes[1] == 0xBB.toByte() &&
bytes[2] == 0xBF.toByte()
) {
return String(bytes, 3, bytes.size - 3, StandardCharsets.UTF_8)
}
val store = TextPageStore(cacheDir)
return try { return try {
StandardCharsets.UTF_8.newDecoder() 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
}
}
override fun recycle() {
pageStore?.close()
pageStore = null
super.recycle()
}
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) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString() return try {
} catch (_: CharacterCodingException) { InputStreamReader(file.openInputStream().buffered(), decoder).use { reader ->
String(bytes, Charset.forName("MS949")) val buffer = CharArray(8 * 1024)
while (reader.read(buffer) >= 0) {
// Validation only. Decoded characters are intentionally discarded.
}
}
true
} catch (_: Exception) {
false
} }
} }
} }
@@ -15,6 +15,7 @@ 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
internal object TextPageRenderer { internal object TextPageRenderer {
private const val PAGE_WIDTH = 1440 private const val PAGE_WIDTH = 1440
@@ -22,14 +23,88 @@ internal object TextPageRenderer {
private const val MARGIN = 96 private const val MARGIN = 96
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2) private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2) private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2)
private const val STREAM_BUFFER_CHARS = 64 * 1024
private const val STREAM_READ_CHARS = 8 * 1024
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 layout = createLayout(text, style)
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)
@@ -43,16 +118,17 @@ internal object TextPageRenderer {
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 bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, 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(MARGIN.toFloat(), MARGIN.toFloat())
createLayout(text, style).draw(canvas) createLayout(text, style).draw(canvas)
@@ -65,15 +141,16 @@ internal object TextPageRenderer {
} }
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout { private fun createLayout(text: String, style: TextReaderStyle): 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.fontSize * 3f
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.paragraphSpacing * 3)
return StaticLayout.Builder return StaticLayout.Builder
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH) .obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
.setAlignment(Layout.Alignment.ALIGN_NORMAL) .setAlignment(Layout.Alignment.ALIGN_NORMAL)
@@ -82,6 +159,22 @@ internal object TextPageRenderer {
.build() .build()
} }
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 +204,9 @@ internal object TextPageRenderer {
fm.bottom += spacing fm.bottom += spacing
} }
} }
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,35 @@
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 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.
*/
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,5 +1,7 @@
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 androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
@@ -11,6 +13,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,22 +113,38 @@ 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)
init {
if (!textUnitsMigrated.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(
fontSize = textFontSize.get(), fontSize = textFontSize.get(),
lineSpacingPercent = textLineSpacing.get(), lineSpacingPercent = textLineSpacing.get(),
paragraphSpacing = textParagraphSpacing.get(), paragraphSpacing = textParagraphSpacing.get(),
fontFamily = textFontFamily.get(), fontFamily = textFontFamily.get(),
readerTheme = readerTheme.get(),
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES,
) )
// endregion // endregion
@@ -378,4 +397,6 @@ 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,
) )
@@ -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,26 @@ 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 textSettingsDirty = false
fun requestTextSettingsReload() {
textSettingsDirty = true
textSettingsReloadJob?.cancel()
textSettingsReloadJob = viewModelScope.launch {
delay(200)
onTextSettingsPreview()
textSettingsReloadJob = null
}
}
fun flushTextSettingsReload() {
if (!textSettingsDirty) 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) {
if (useDiff) {
val result = DiffUtil.calculateDiff(Callback(items, newItems)) val result = DiffUtil.calculateDiff(Callback(items, newItems))
items = newItems items = newItems
result.dispatchUpdatesTo(this) 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