2 Commits

Author SHA1 Message Date
javara999 dcec339477 fix: avoid ANR during live text style updates
Build & Test / Build & Test App (push) Waiting to run
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
15 changed files with 404 additions and 38 deletions
@@ -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 {
@@ -34,7 +34,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
valueRange = 32..72, valueRange = 32..72,
steps = 19, steps = 19,
valueString = fontSize.toString(), valueString = fontSize.toString(),
onChange = fontSizePref::set, onChange = { value ->
fontSizePref.set(value)
viewModel.requestTextSettingsReload()
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
) )
@@ -46,7 +49,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
valueRange = 90..180, valueRange = 90..180,
steps = 17, steps = 17,
valueString = "$lineSpacing%", valueString = "$lineSpacing%",
onChange = lineSpacingPref::set, onChange = { value ->
lineSpacingPref.set(value)
viewModel.requestTextSettingsReload()
},
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
) )
@@ -58,7 +64,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
valueRange = 0..48, valueRange = 0..48,
steps = 11, steps = 11,
valueString = paragraphSpacing.toString(), valueString = paragraphSpacing.toString(),
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()
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
@@ -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(
@@ -112,6 +146,16 @@ class ChapterLoader(
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())
} }
source is HttpSource && source.isKavitaSource() && kavitaChapterId != null -> {
KavitaCompatPageLoader(
context = context,
chapter = chapter,
source = source,
chapterCache = chapterCache,
chapterId = kavitaChapterId,
textStyle = readerPreferences.textReaderStyle(),
)
}
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()))
else -> error(context.stringResource(MR.strings.loader_not_implemented_error)) else -> error(context.stringResource(MR.strings.loader_not_implemented_error))
@@ -26,10 +26,7 @@ internal class EpubPageLoader(
if (textPages.isNotEmpty()) { if (textPages.isNotEmpty()) {
return textPages.mapIndexed { index, text -> return textPages.mapIndexed { index, text ->
ReaderPage(index).apply { TextReaderPage(index, text, style)
stream = { TextPageRenderer.render(text, style) }
status = Page.State.Ready
}
} }
} }
@@ -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)
"txt" -> TextPageLoader(uniFile, textStyle)
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,7 +1,6 @@
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.nio.ByteBuffer
@@ -22,10 +21,7 @@ internal class TextPageLoader(
val bytes = file.openInputStream().use { it.readBytes() } val bytes = file.openInputStream().use { it.readBytes() }
val text = decodeText(bytes) val text = decodeText(bytes)
return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText -> return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText ->
ReaderPage(index).apply { TextReaderPage(index, pageText, style)
stream = { TextPageRenderer.render(pageText, style) }
status = Page.State.Ready
}
} }
} }
@@ -0,0 +1,29 @@
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 pageText: String,
initialStyle: TextReaderStyle,
) : ReaderPage(index) {
@Volatile
private var style: TextReaderStyle = initialStyle
init {
stream = { TextPageRenderer.render(pageText, style) }
status = Page.State.Ready
}
fun updateStyle(newStyle: TextReaderStyle) {
style = newStyle
}
}
@@ -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(100)
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