feat: add Kavita document fallback and live text settings
Build & Test / Build & Test App (push) Has been cancelled

This commit is contained in:
2026-09-19 12:58:13 +09:00
parent 4900efbca5
commit 1b01c894ad
7 changed files with 274 additions and 12 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
}
@@ -90,6 +90,7 @@ import tachiyomi.domain.source.service.SourceManager
import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.image.LocalCoverManager
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
import java.util.Date import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Clock import kotlin.time.Clock
@@ -823,10 +824,19 @@ class ReaderViewModel(
mutableState.update { it.copy(dialog = null) } mutableState.update { it.copy(dialog = null) }
} }
private val textReaderStyleReloading = AtomicBoolean(false)
private val textReaderStyleReloadPending = AtomicBoolean(false)
fun reloadTextReaderStyle() { fun reloadTextReaderStyle() {
val chapter = state.value.currentChapter ?: return val chapter = state.value.currentChapter ?: return
if (chapter.pageLoader?.usesTextReaderStyle != true) return if (chapter.pageLoader?.usesTextReaderStyle != true) return
val chapterLoader = loader ?: return val chapterLoader = loader ?: return
if (!textReaderStyleReloading.compareAndSet(false, true)) {
textReaderStyleReloadPending.set(true)
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)
val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex
@@ -844,6 +854,11 @@ class ReaderViewModel(
} 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 reload text reader style" }
} finally {
textReaderStyleReloading.set(false)
if (textReaderStyleReloadPending.getAndSet(false)) {
reloadTextReaderStyle()
}
} }
} }
} }
@@ -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
@@ -91,6 +93,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 +115,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))
@@ -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
}
}
@@ -6,14 +6,17 @@ 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 onTextSettingsChanged: () -> Unit,
val preferences: ReaderPreferences, val preferences: ReaderPreferences,
) : ViewModel() { ) : ViewModel() {
@@ -26,4 +29,22 @@ 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
fun requestTextSettingsReload() {
textSettingsReloadJob?.cancel()
textSettingsReloadJob = viewModelScope.launch {
delay(150)
onTextSettingsChanged()
textSettingsReloadJob = null
}
}
fun flushTextSettingsReload() {
if (textSettingsReloadJob == null) return
textSettingsReloadJob?.cancel()
textSettingsReloadJob = null
onTextSettingsChanged()
}
} }