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.sourceApi)
implementation(projects.sourceLocal)
implementation(libs.jsoup)
implementation(projects.data)
implementation(projects.domain)
implementation(projects.presentationCore)
@@ -51,7 +51,10 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
themes.map { (labelRes, value) ->
FilterChip(
selected = readerTheme == value,
onClick = { viewModel.preferences.readerTheme.set(value) },
onClick = {
viewModel.preferences.readerTheme.set(value)
viewModel.requestTextAppearanceReload()
},
label = { Text(stringResource(labelRes)) },
)
}
@@ -9,7 +9,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
@@ -33,15 +32,11 @@ fun ReaderSettingsDialog(
stringResource(MR.strings.pref_category_text_reader),
)
val pagerState = rememberPagerState { tabTitles.size }
val initialTextStyle = remember { viewModel.preferences.textReaderStyle() }
BoxWithConstraints {
TabbedDialog(
modifier = Modifier.heightIn(max = maxHeight * 0.75f),
onDismissRequest = {
if (viewModel.preferences.textReaderStyle() != initialTextStyle) {
viewModel.onTextSettingsChanged()
}
viewModel.flushTextSettingsReload()
onDismissRequest()
onShowMenus()
},
@@ -51,7 +46,7 @@ fun ReaderSettingsDialog(
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
LaunchedEffect(pagerState.currentPage) {
if (pagerState.currentPage == 2) {
if (pagerState.currentPage == 2 || pagerState.currentPage == 3) {
window?.setDimAmount(0f)
onHideMenus()
} else {
@@ -31,10 +31,13 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
SliderItem(
label = stringResource(MR.strings.pref_text_reader_font_size),
value = fontSize,
valueRange = 32..72,
valueRange = 10..30,
steps = 19,
valueString = fontSize.toString(),
onChange = fontSizePref::set,
valueString = "$fontSize sp",
onChange = { value ->
fontSizePref.set(value)
viewModel.requestTextSettingsReload()
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
@@ -45,8 +48,11 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
value = lineSpacing,
valueRange = 90..180,
steps = 17,
valueString = "$lineSpacing%",
onChange = lineSpacingPref::set,
valueString = String.format("%.2fx", lineSpacing / 100f),
onChange = { value ->
lineSpacingPref.set(value)
viewModel.requestTextSettingsReload()
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
@@ -55,10 +61,13 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
SliderItem(
label = stringResource(MR.strings.pref_text_reader_paragraph_spacing),
value = paragraphSpacing,
valueRange = 0..48,
steps = 11,
valueString = paragraphSpacing.toString(),
onChange = paragraphSpacingPref::set,
valueRange = 0..16,
steps = 15,
valueString = "$paragraphSpacing dp",
onChange = { value ->
paragraphSpacingPref.set(value)
viewModel.requestTextSettingsReload()
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
@@ -68,7 +77,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
fontFamilies.forEach { (label, value) ->
FilterChip(
selected = fontFamily == value,
onClick = { fontFamilyPref.set(value) },
onClick = {
fontFamilyPref.set(value)
viewModel.requestTextSettingsReload()
},
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 -> {
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 -> {
displayRefreshHost.flash()
}
@@ -254,7 +264,8 @@ class ReaderActivity : BaseActivity() {
readerState = viewModel.state,
onChangeReadingMode = viewModel::setMangaReadingMode,
onChangeOrientation = viewModel::setMangaOrientationType,
onTextSettingsChanged = viewModel::reloadTextReaderStyle,
onTextSettingsPreview = viewModel::previewTextReaderStyle,
onTextSettingsCommit = viewModel::commitTextReaderStyle,
preferences = readerPreferences,
)
}
@@ -40,6 +40,7 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
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.ReaderChapter
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -184,6 +187,12 @@ class ReaderViewModel(
*/
private var loader: ChapterLoader? = null
/**
* Only the newest text repagination request is allowed to finish. Slider
* changes can arrive faster than a large TXT/EPUB chapter can be rebuilt.
*/
private var textRepaginationJob: Job? = null
/**
* The time the chapter was started reading
*/
@@ -823,27 +832,45 @@ class ReaderViewModel(
mutableState.update { it.copy(dialog = null) }
}
fun reloadTextReaderStyle() {
fun previewTextReaderStyle() {
val chapter = state.value.currentChapter ?: return
if (chapter.pageLoader?.usesTextReaderStyle != true) return
val chapterLoader = loader ?: return
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex
val style = readerPreferences.textReaderStyle(context)
val textPages = chapter.pages?.filterIsInstance<TextReaderPage>().orEmpty()
if (textPages.isEmpty()) return
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 {
chapter.pageLoader?.recycle()
chapter.pageLoader = null
chapter.state = ReaderChapter.State.Wait
chapter.requestedPage = requestedPage
chapterLoader.loadChapter(chapter)
val newLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
chapter.requestedPage = (readingProgress * newLastIndex).roundToInt().coerceIn(0, newLastIndex)
eventChannel.send(Event.ReloadViewerChapters)
val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
// Resource cleanup must survive cancellation by the next slider value.
viewModelScope.launchNonCancellable {
delay(750)
oldLoader.recycle()
}
eventChannel.trySend(Event.ReloadTextViewerChapters)
} catch (e: Throwable) {
if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) { "Failed to reload text reader style" }
logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
}
}
}
@@ -1037,6 +1064,8 @@ class ReaderViewModel(
sealed interface Event {
data object ReloadViewerChapters : Event
data object RefreshCurrentTextPage : Event
data object ReloadTextViewerChapters : Event
data object PageChanged : Event
data class SetOrientation(val orientation: Int) : Event
data class SetCoverResult(val result: SetAsCoverResult) : Event
@@ -34,9 +34,9 @@ internal class BookOasisFilePageLoader(
val uniFile = UniFile.fromFile(file)
?: error("Unable to open cached BookOasis file: ${file.absolutePath}")
val loader = when (ref.format) {
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle)
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir)
"pdf" -> PdfPageLoader(context, uniFile)
"txt" -> TextPageLoader(uniFile, textStyle)
"txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir)
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.bookoasis.isBookOasisSource
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.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
@@ -20,6 +22,8 @@ import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR
import tachiyomi.source.local.LocalSource
import tachiyomi.source.local.io.Format
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
/**
* 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.
*/
@@ -91,6 +129,7 @@ class ChapterLoader(
source,
)
val bookOasisRef = dbChapter.url.toBookOasisChapterRef()
val kavitaChapterId = dbChapter.url.toKavitaChapterId()
return when {
isDownloaded -> DownloadPageLoader(
@@ -104,13 +143,23 @@ class ChapterLoader(
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
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.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle())
is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle(context), context.cacheDir)
}
}
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 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.setting.TextReaderStyle
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.
*
* Text-based EPUBs are rendered as regular Mihon pages. Image-only EPUBs
* keep the original image extraction behavior.
* Text-based EPUBs are rendered as regular Mihon pages. EPUB spine documents
* 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(
private val reader: EpubReader,
private val style: TextReaderStyle,
private val cacheDir: File,
) : PageLoader() {
override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean = true
private var pageStore: TextPageStore? = null
override suspend fun getPages(): List<ReaderPage> {
val textPages = reader.getTextFromPages()
.filter { it.isNotBlank() }
.flatMap { TextPageRenderer.paginate(it, style) }
check(!isRecycled)
pageStore?.close()
pageStore = null
val store = TextPageStore(cacheDir)
return try {
val textPages = buildTextPages(store)
if (textPages.isNotEmpty()) {
return textPages.mapIndexed { index, text ->
ReaderPage(index).apply {
stream = { TextPageRenderer.render(text, style) }
status = Page.State.Ready
}
}
}
return reader.getImagesFromPages().mapIndexed { index, path ->
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
}
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) {
check(!isRecycled)
}
override fun recycle() {
pageStore?.close()
pageStore = null
super.recycle()
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
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.setting.TextReaderStyle
import java.nio.ByteBuffer
import java.nio.charset.CharacterCodingException
import java.io.File
import java.io.InputStreamReader
import java.io.Reader
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets
@@ -13,39 +13,94 @@ import java.nio.charset.StandardCharsets
internal class TextPageLoader(
private val file: UniFile,
private val style: TextReaderStyle,
private val cacheDir: File,
) : PageLoader() {
override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean = true
private var pageStore: TextPageStore? = null
override suspend fun getPages(): List<ReaderPage> {
val bytes = file.openInputStream().use { it.readBytes() }
val text = decodeText(bytes)
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)
}
pageStore?.close()
pageStore = null
val store = TextPageStore(cacheDir)
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)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString()
} catch (_: CharacterCodingException) {
String(bytes, Charset.forName("MS949"))
return try {
InputStreamReader(file.openInputStream().buffered(), decoder).use { reader ->
val buffer = CharArray(8 * 1024)
while (reader.read(buffer) >= 0) {
// Validation only. Decoded characters are intentionally discarded.
}
}
true
} catch (_: Exception) {
false
}
}
}
@@ -15,47 +15,122 @@ import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.Reader
import kotlin.math.roundToInt
internal object TextPageRenderer {
private const val PAGE_WIDTH = 1440
private const val PAGE_HEIGHT = 2160
private const val MARGIN = 96
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2)
private const val MARGIN_DP = 32f
private const val STREAM_BUFFER_CHARS = 64 * 1024
private const val STREAM_READ_CHARS = 8 * 1024
fun paginate(text: String, style: TextReaderStyle): List<String> {
if (text.isBlank()) return emptyList()
val layout = createLayout(text, style)
if (layout.lineCount == 0) return listOf(text)
return calculatePageSlices(text, style)
.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
while (startLine < layout.lineCount) {
val startTop = layout.getLineTop(startLine)
var endLine = startLine
while (
endLine + 1 < layout.lineCount &&
layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT
layout.getLineBottom(endLine + 1) - startTop <= metrics.contentHeight
) {
endLine++
}
val start = layout.getLineStart(startLine)
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
}
return pages.ifEmpty { listOf(text) }
return pages.ifEmpty { listOf(PageSlice(0, text.length)) }
}
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)
canvas.drawColor(Color.WHITE)
val (backgroundColor, _) = resolveColors(style)
canvas.drawColor(backgroundColor)
canvas.save()
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat())
createLayout(text, style).draw(canvas)
canvas.translate(metrics.horizontalMargin.toFloat(), metrics.verticalMargin.toFloat())
createLayout(text, style, metrics.contentWidth).draw(canvas)
canvas.restore()
val output = ByteArrayOutputStream()
@@ -64,24 +139,62 @@ internal object TextPageRenderer {
return ByteArrayInputStream(output.toByteArray())
}
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout {
private fun createLayout(
text: String,
style: TextReaderStyle,
contentWidth: Int,
): StaticLayout {
val (_, textColor) = resolveColors(style)
val paint = TextPaint().apply {
isAntiAlias = true
color = Color.BLACK
textSize = style.fontSize.toFloat()
color = textColor
textSize = style.fontSizePx.coerceAtLeast(1f)
typeface = style.fontFamily.androidFamilyName
?.let { Typeface.create(it, Typeface.NORMAL) }
?: Typeface.DEFAULT
}
val styledText = addParagraphSpacing(text, style.paragraphSpacing)
val styledText = addParagraphSpacing(text, style.paragraphSpacingPx)
return StaticLayout.Builder
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
.obtain(styledText, 0, styledText.length, paint, contentWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(true)
.setLineSpacing(0f, style.lineSpacingPercent / 100f)
.build()
}
private fun resolveLayoutMetrics(style: TextReaderStyle): LayoutMetrics {
val pageWidth = style.pageWidthPx.coerceAtLeast(1)
val pageHeight = style.pageHeightPx.coerceAtLeast(1)
val requestedMargin = (MARGIN_DP * style.density).roundToInt().coerceAtLeast(1)
val horizontalMargin = requestedMargin.coerceAtMost(((pageWidth - 1) / 2).coerceAtLeast(0))
val verticalMargin = requestedMargin.coerceAtMost(((pageHeight - 1) / 2).coerceAtLeast(0))
return LayoutMetrics(
pageWidth = pageWidth,
pageHeight = pageHeight,
horizontalMargin = horizontalMargin,
verticalMargin = verticalMargin,
contentWidth = (pageWidth - horizontalMargin * 2).coerceAtLeast(1),
contentHeight = (pageHeight - verticalMargin * 2).coerceAtLeast(1),
)
}
private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> {
val useDark = when (style.readerTheme) {
0 -> false
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 {
if (spacing <= 0 || '\n' !in text) return text
val spannable = SpannableString(text)
@@ -111,4 +224,18 @@ internal object TextPageRenderer {
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
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.util.TypedValue
import androidx.compose.ui.graphics.BlendMode
import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope
@@ -11,6 +14,7 @@ import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getEnumSet
import tachiyomi.i18n.MR
import kotlin.math.roundToInt
@Inject
@SingleIn(AppScope::class)
@@ -110,23 +114,68 @@ class ReaderPreferences(
// 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 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(
"reader_text_font_family",
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 {
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
@@ -317,6 +366,9 @@ class ReaderPreferences(
const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25
private const val TEXT_READER_MAX_RENDER_WIDTH = 1440
private const val TEXT_READER_MAX_RENDER_HEIGHT = 2400
const val MILLI_CONVERSION = 100
val TapZones = listOf(
@@ -378,4 +430,11 @@ data class TextReaderStyle(
val lineSpacingPercent: Int,
val paragraphSpacing: Int,
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.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ReaderSettingsViewModel(
readerState: StateFlow<ReaderViewModel.State>,
val onChangeReadingMode: (ReadingMode) -> Unit,
val onChangeOrientation: (ReaderOrientation) -> Unit,
val onTextSettingsChanged: () -> Unit,
private val onTextSettingsPreview: () -> Unit,
private val onTextSettingsCommit: () -> Unit,
val preferences: ReaderPreferences,
) : ViewModel() {
@@ -26,4 +30,55 @@ class ReaderSettingsViewModel(
.map { it.manga }
.distinctUntilChanged()
.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)
/** 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].
*/
@@ -380,6 +380,17 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
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
* 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
* 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>()
// 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)
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))
items = newItems
result.dispatchUpdatesTo(this)
} else {
items = newItems
notifyDataSetChanged()
}
}
fun refresh() {
@@ -353,6 +353,19 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
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