6 Commits

Author SHA1 Message Date
javara999 a347cd10b8 Add release APK artifacts
Build & Test / Build & Test App (push) Waiting to run
Release / Extract tag name (push) Waiting to run
Release / Build (push) Blocked by required conditions
Release / Build (FOSS) (push) Blocked by required conditions
Release / Create GitHub Release (push) Blocked by required conditions
Update website / update_website (release) Waiting to run
2026-09-21 11:11:15 +09:00
javara999 849148691a Support EPUB and TXT downloads from Kavita and BookOasis
Build & Test / Build & Test App (push) Has been cancelled
2026-09-21 11:02:48 +09:00
javara999 c0e0ef1575 Improve live text reader repagination
Build & Test / Build & Test App (push) Has been cancelled
2026-09-19 20:12:09 +09:00
javara999 6391c9376c Improve text reader streaming and theming
Build & Test / Build & Test App (push) Has been cancelled
Update website / update_website (release) 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
32 changed files with 1113 additions and 137 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)) },
)
}
@@ -376,8 +376,10 @@ class DownloadCache(
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
// Folder of images
it.isDirectory -> it.name
// CBZ files
it.isFile && it.extension == "cbz" -> it.nameWithoutExtension
// Archived or original document files
it.isFile && it.extension?.lowercase()?.let { extension ->
extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS
} == true -> it.nameWithoutExtension
// Anything else is irrelevant
else -> null
}
@@ -405,8 +405,12 @@ class DownloadManager(
.firstOrNull() ?: return
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
if (oldDownload.isFile && oldDownload.extension == "cbz") {
newName += ".cbz"
if (oldDownload.isFile) {
oldDownload.extension?.lowercase()?.let { extension ->
if (extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS) {
newName += ".$extension"
}
}
}
if (oldDownload.name == newName) return
@@ -19,6 +19,8 @@ import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR
import java.io.IOException
internal val DOCUMENT_DOWNLOAD_EXTENSIONS = setOf("epub", "txt")
/**
* This class is used to provide the directories where the downloads should be saved.
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
@@ -250,11 +252,17 @@ class DownloadProvider(
add(chapterDirName)
// Archived chapters
add("$chapterDirName.cbz")
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
add("$chapterDirName.$extension")
}
// any legacy names
legacyChapterDirNames.forEach {
add(it)
add("$it.cbz")
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
add("$it.$extension")
}
}
}
}
@@ -12,8 +12,16 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.bookoasis.bookOasisDocumentDownloadRequest
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.kavita.detectKavitaDocumentFormat
import eu.kanade.tachiyomi.source.kavita.isKavitaSource
import eu.kanade.tachiyomi.source.kavita.kavitaDocumentDownloadRequest
import eu.kanade.tachiyomi.source.kavita.toKavitaChapterId
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
@@ -44,6 +52,7 @@ import kotlinx.coroutines.supervisorScope
import logcat.LogPriority
import mihon.core.archive.ZipWriter
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Request
import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
@@ -339,9 +348,15 @@ class Downloader(
download.chapter.scanlator,
download.chapter.url,
)
try {
val remoteDocument = findRemoteDocument(download)
if (remoteDocument != null) {
downloadDocument(download, mangaDir, chapterDirname, remoteDocument)
return
}
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
try {
// If the page list already exists, start from the file
val pageList = download.pages ?: run {
// Otherwise, pull page list from network and add them to download object
@@ -415,6 +430,84 @@ class Downloader(
}
}
private suspend fun findRemoteDocument(download: Download): RemoteDocument? {
val source = download.source
return when {
source.isBookOasisSource() -> download.chapter.url
.toBookOasisChapterRef()
?.takeIf { it.format in DOCUMENT_DOWNLOAD_EXTENSIONS }
?.let { ref -> RemoteDocument(ref.format, source.bookOasisDocumentDownloadRequest(ref)) }
source.isKavitaSource() -> download.chapter.url
.toKavitaChapterId()
?.let { chapterId ->
source.detectKavitaDocumentFormat(chapterId)?.let { format ->
RemoteDocument(format, source.kavitaDocumentDownloadRequest(chapterId))
}
}
else -> null
}
}
private suspend fun downloadDocument(
download: Download,
mangaDir: UniFile,
chapterDirname: String,
document: RemoteDocument,
) {
val targetName = "$chapterDirname.${document.extension}"
mangaDir.findFile(targetName)?.delete()
mangaDir.findFile("$targetName.tmp")?.delete()
val temp = mangaDir.createFile("$targetName.tmp")
?: error("Failed to create temporary document file")
val page = Page(0).apply {
status = Page.State.DownloadImage
}
download.pages = listOf(page)
download.status = Download.State.DOWNLOADING
notifier.onProgressChange(download)
try {
download.source.client.newCall(document.request).await().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException(
"Document download failed: HTTP ${response.code}",
)
}
temp.openOutputStream().use { output ->
response.body.byteStream().use { input -> input.copyTo(output) }
}
}
if (temp.length() <= 0L) {
throw IllegalStateException("Document download returned an empty file")
}
if (!temp.renameTo(targetName)) {
val target = mangaDir.createFile(targetName)
?: error("Failed to create downloaded document file")
temp.openInputStream().use { input ->
target.openOutputStream().use { output ->
input.copyTo(output)
}
}
temp.delete()
}
} catch (error: Throwable) {
temp.delete()
throw error
}
val target = mangaDir.findFile(targetName)
?: error("Downloaded document file is missing")
page.uri = target.uri
page.status = Page.State.Ready
page.progress = 100
notifier.onProgressChange(download)
cache.addChapter(chapterDirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(mangaDir, context)
download.status = Download.State.DOWNLOADED
}
/**
* Gets the image from the filesystem if it exists or downloads it otherwise.
*
@@ -740,5 +833,10 @@ class Downloader(
}
}
private data class RemoteDocument(
val extension: String,
val request: Request,
)
// Arbitrary minimum required space to start a download: 200 MB
private const val MIN_DISK_SPACE = 200L * 1024 * 1024
@@ -21,6 +21,22 @@ internal data class BookOasisChapterRef(
val format: String,
)
internal fun HttpSource.bookOasisDocumentDownloadRequest(ref: BookOasisChapterRef): Request {
val downloadUrl = buildString {
append(baseUrl.trimEnd('/'))
append("/api/media/books/")
append(ref.bookId)
append("/download?type=")
append(ref.dbType)
}
return Request.Builder()
.url(downloadUrl)
.headers(headers)
.get()
.build()
}
internal fun Source.isBookOasisSource(): Boolean {
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
}
@@ -0,0 +1,81 @@
package eu.kanade.tachiyomi.source.kavita
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.Request
import org.json.JSONObject
import java.util.Locale
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)
internal suspend fun HttpSource.detectKavitaDocumentFormat(chapterId: Int): String? {
val apiBase = baseUrl.trimEnd('/') + "/api"
val request = Request.Builder()
.url("$apiBase/Chapter?chapterId=$chapterId")
.headers(headers)
.get()
.build()
client.newCall(request).await().use { response ->
if (!response.isSuccessful) return null
val body = response.body.string()
if (body.isBlank()) return null
val files = JSONObject(body).optJSONArray("files") ?: return null
for (i in 0 until files.length()) {
val file = files.optJSONObject(i) ?: continue
val extension = file.optString("extension")
.ifBlank { file.optString("filePath") }
.substringAfterLast('.')
.trim()
.trimStart('.')
.lowercase(Locale.ROOT)
if (extension == "epub" || extension == "txt") return extension
if (file.optInt("format", -1) == 3) return "epub"
}
}
return null
}
internal fun HttpSource.kavitaDocumentDownloadRequest(chapterId: Int): Request {
val apiBase = baseUrl.trimEnd('/') + "/api"
return Request.Builder()
.url("$apiBase/Download/chapter?chapterId=$chapterId")
.headers(headers)
.get()
.build()
}
/**
* 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
@@ -4,11 +4,11 @@ import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef
import eu.kanade.tachiyomi.source.bookoasis.bookOasisDocumentDownloadRequest
import eu.kanade.tachiyomi.source.online.HttpSource
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 java.io.File
import java.io.IOException
@@ -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}")
}
@@ -68,19 +68,7 @@ internal class BookOasisFilePageLoader(
val temp = File(cacheDir, "${target.name}.part")
if (temp.exists()) temp.delete()
val downloadUrl = buildString {
append(source.baseUrl.trimEnd('/'))
append("/api/media/books/")
append(ref.bookId)
append("/download?type=")
append(ref.dbType)
}
val request = Request.Builder()
.url(downloadUrl)
.headers(source.headers)
.get()
.build()
val request = source.bookOasisDocumentDownloadRequest(ref)
source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) {
@@ -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()))
@@ -11,7 +11,10 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import mihon.core.archive.archiveReader
import mihon.core.archive.epubReader
import tachiyomi.core.common.storage.extension
import tachiyomi.domain.manga.model.Manga
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import uy.kohesive.injekt.injectLazy
/**
@@ -27,9 +30,12 @@ internal class DownloadPageLoader(
private val context: Context by injectLazy()
private var archivePageLoader: ArchivePageLoader? = null
private var pageLoader: PageLoader? = null
private val readerPreferences: ReaderPreferences by injectLazy()
override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean
get() = pageLoader?.usesTextReaderStyle == true
override suspend fun getPages(): List<ReaderPage> {
val dbChapter = chapter.chapter
@@ -41,7 +47,11 @@ internal class DownloadPageLoader(
source,
)
return if (chapterPath?.isFile == true) {
getPagesFromArchive(chapterPath)
when (chapterPath.extension?.lowercase()) {
"epub" -> getPagesFromEpub(chapterPath)
"txt" -> getPagesFromText(chapterPath)
else -> getPagesFromArchive(chapterPath)
}
} else {
getPagesFromDirectory()
}
@@ -49,11 +59,30 @@ internal class DownloadPageLoader(
override fun recycle() {
super.recycle()
archivePageLoader?.recycle()
pageLoader?.recycle()
pageLoader = null
}
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
val loader = ArchivePageLoader(file.archiveReader(context)).also { archivePageLoader = it }
val loader = ArchivePageLoader(file.archiveReader(context)).also { pageLoader = it }
return loader.getPages()
}
private suspend fun getPagesFromEpub(file: UniFile): List<ReaderPage> {
val loader = EpubPageLoader(
file.epubReader(context),
readerPreferences.textReaderStyle(context),
context.cacheDir,
).also { pageLoader = it }
return loader.getPages()
}
private suspend fun getPagesFromText(file: UniFile): List<ReaderPage> {
val loader = TextPageLoader(
file,
readerPreferences.textReaderStyle(context),
context.cacheDir,
).also { pageLoader = it }
return loader.getPages()
}
@@ -69,6 +98,6 @@ internal class DownloadPageLoader(
}
override suspend fun loadPage(page: ReaderPage) {
archivePageLoader?.loadPage(page)
pageLoader?.loadPage(page)
}
}
@@ -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,118 @@
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.kavita.detectKavitaDocumentFormat
import eu.kanade.tachiyomi.source.kavita.kavitaDocumentDownloadRequest
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 java.io.File
import java.io.IOException
/**
* 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 = source.detectKavitaDocumentFormat(chapterId)
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 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 request = source.kavitaDocumentDownloadRequest(chapterId)
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.