From 1b01c894addfe3d1848686cf341ab42ee4834d53 Mon Sep 17 00:00:00 2001 From: javara999 Date: Sat, 19 Sep 2026 12:58:13 +0900 Subject: [PATCH] feat: add Kavita document fallback and live text settings --- .../reader/settings/ReaderSettingsDialog.kt | 9 +- .../reader/settings/TextSettingsPage.kt | 20 ++- .../tachiyomi/source/kavita/KavitaCompat.kt | 37 ++++ .../tachiyomi/ui/reader/ReaderViewModel.kt | 15 ++ .../ui/reader/loader/ChapterLoader.kt | 13 ++ .../reader/loader/KavitaCompatPageLoader.kt | 169 ++++++++++++++++++ .../reader/setting/ReaderSettingsViewModel.kt | 23 ++- 7 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/source/kavita/KavitaCompat.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/ReaderSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/ReaderSettingsDialog.kt index 0e08c964d..a6fd38d86 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/ReaderSettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/ReaderSettingsDialog.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt index b19cc4fce..e192278d8 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt @@ -34,7 +34,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) { valueRange = 32..72, steps = 19, valueString = fontSize.toString(), - onChange = fontSizePref::set, + onChange = { value -> + fontSizePref.set(value) + viewModel.requestTextSettingsReload() + }, pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, ) @@ -46,7 +49,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) { valueRange = 90..180, steps = 17, valueString = "$lineSpacing%", - onChange = lineSpacingPref::set, + onChange = { value -> + lineSpacingPref.set(value) + viewModel.requestTextSettingsReload() + }, pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, ) @@ -58,7 +64,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) { valueRange = 0..48, steps = 11, valueString = paragraphSpacing.toString(), - onChange = paragraphSpacingPref::set, + 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)) }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/kavita/KavitaCompat.kt b/app/src/main/java/eu/kanade/tachiyomi/source/kavita/KavitaCompat.kt new file mode 100644 index 000000000..a2616bcc6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/kavita/KavitaCompat.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index fed108460..34d9c64dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -90,6 +90,7 @@ import tachiyomi.domain.source.service.SourceManager import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.isLocal import java.util.Date +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.roundToInt import kotlin.time.Clock @@ -823,10 +824,19 @@ class ReaderViewModel( mutableState.update { it.copy(dialog = null) } } + private val textReaderStyleReloading = AtomicBoolean(false) + private val textReaderStyleReloadPending = AtomicBoolean(false) + fun reloadTextReaderStyle() { val chapter = state.value.currentChapter ?: return if (chapter.pageLoader?.usesTextReaderStyle != true) return val chapterLoader = loader ?: return + + if (!textReaderStyleReloading.compareAndSet(false, true)) { + textReaderStyleReloadPending.set(true) + 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 @@ -844,6 +854,11 @@ class ReaderViewModel( } catch (e: Throwable) { if (e is CancellationException) throw e logcat(LogPriority.ERROR, e) { "Failed to reload text reader style" } + } finally { + textReaderStyleReloading.set(false) + if (textReaderStyleReloadPending.getAndSet(false)) { + reloadTextReaderStyle() + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index cab4e4931..210e200cd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -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 @@ -91,6 +93,7 @@ class ChapterLoader( source, ) val bookOasisRef = dbChapter.url.toBookOasisChapterRef() + val kavitaChapterId = dbChapter.url.toKavitaChapterId() return when { isDownloaded -> DownloadPageLoader( @@ -112,6 +115,16 @@ class ChapterLoader( source is HttpSource && source.isBookOasisSource() && bookOasisRef != null -> { 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 StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString())) else -> error(context.stringResource(MR.strings.loader_not_implemented_error)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt new file mode 100644 index 000000000..c2af325aa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt index 0927f738a..be4ccb8a5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt @@ -6,14 +6,17 @@ 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, val onChangeReadingMode: (ReadingMode) -> Unit, val onChangeOrientation: (ReaderOrientation) -> Unit, - val onTextSettingsChanged: () -> Unit, + private val onTextSettingsChanged: () -> Unit, val preferences: ReaderPreferences, ) : ViewModel() { @@ -26,4 +29,22 @@ class ReaderSettingsViewModel( .map { it.manga } .distinctUntilChanged() .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() + } }