Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de6a8f110 | |||
| d4789c2373 | |||
| 8dc5a0fc0b | |||
| 105551f658 | |||
| 6a57bc601f | |||
| fe73aff860 | |||
| a347cd10b8 | |||
| 849148691a | |||
| c0e0ef1575 |
@@ -22,3 +22,6 @@
|
||||
*.woff binary
|
||||
*.pyc binary
|
||||
*.swp binary
|
||||
|
||||
# Android packages
|
||||
*.apk binary
|
||||
|
||||
@@ -33,8 +33,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "app.mihon"
|
||||
|
||||
versionCode = 30
|
||||
versionName = "0.20.4"
|
||||
versionCode = 31
|
||||
versionName = "0.20.5"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getLatestCommitCount()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"${getLatestCommitSha()}\"")
|
||||
|
||||
@@ -53,7 +53,7 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
|
||||
selected = readerTheme == value,
|
||||
onClick = {
|
||||
viewModel.preferences.readerTheme.set(value)
|
||||
viewModel.requestTextSettingsReload()
|
||||
viewModel.requestTextAppearanceReload()
|
||||
},
|
||||
label = { Text(stringResource(labelRes)) },
|
||||
)
|
||||
|
||||
@@ -374,10 +374,13 @@ class DownloadCache(
|
||||
when {
|
||||
// Ignore incomplete downloads
|
||||
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
|
||||
it.name?.contains(".download.", ignoreCase = true) == 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>
|
||||
@@ -115,6 +117,37 @@ class DownloadProvider(
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a downloaded EPUB/TXT document for a chapter if it exists.
|
||||
*
|
||||
* Document files are checked explicitly before the generic chapter path so
|
||||
* a stale image directory with the same chapter name cannot shadow them.
|
||||
*/
|
||||
fun findChapterDocument(
|
||||
chapterName: String,
|
||||
chapterScanlator: String?,
|
||||
chapterUrl: String,
|
||||
mangaTitle: String,
|
||||
source: Source,
|
||||
): UniFile? {
|
||||
val mangaDir = findMangaDir(mangaTitle, source) ?: return null
|
||||
val chapterDirName = getChapterDirName(chapterName, chapterScanlator, chapterUrl)
|
||||
val legacyChapterDirNames = getLegacyChapterDirNames(chapterName, chapterScanlator, chapterUrl)
|
||||
|
||||
return buildList {
|
||||
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
|
||||
add("$chapterDirName.$extension")
|
||||
}
|
||||
legacyChapterDirNames.forEach { legacyName ->
|
||||
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
|
||||
add("$legacyName.$extension")
|
||||
}
|
||||
}
|
||||
}.asSequence()
|
||||
.mapNotNull { mangaDir.findFile(it) }
|
||||
.firstOrNull { it.isFile }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of downloaded directories for the chapters that exist.
|
||||
*
|
||||
@@ -250,11 +283,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,
|
||||
)
|
||||
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
|
||||
|
||||
try {
|
||||
val remoteDocument = findRemoteDocument(download)
|
||||
if (remoteDocument != null) {
|
||||
downloadDocument(download, mangaDir, chapterDirname, remoteDocument)
|
||||
return
|
||||
}
|
||||
|
||||
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
|
||||
|
||||
// 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,85 @@ 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}"
|
||||
val tempName = "$chapterDirname.download.${document.extension}"
|
||||
mangaDir.findFile(tempName)?.delete()
|
||||
val temp = mangaDir.createFile(tempName)
|
||||
?: 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")
|
||||
}
|
||||
|
||||
mangaDir.findFile(targetName)?.delete()
|
||||
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 +834,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,13 +21,40 @@ internal data class BookOasisChapterRef(
|
||||
val format: String,
|
||||
)
|
||||
|
||||
private fun HttpSource.bookOasisServerBase(): String {
|
||||
val normalized = baseUrl.trimEnd('/')
|
||||
return when {
|
||||
normalized.endsWith("/app-opds-adult", ignoreCase = true) ->
|
||||
normalized.dropLast("/app-opds-adult".length)
|
||||
normalized.endsWith("/app-opds", ignoreCase = true) ->
|
||||
normalized.dropLast("/app-opds".length)
|
||||
else -> normalized
|
||||
}
|
||||
}
|
||||
|
||||
internal fun HttpSource.bookOasisDocumentDownloadRequest(ref: BookOasisChapterRef): Request {
|
||||
val downloadUrl = buildString {
|
||||
append(bookOasisServerBase())
|
||||
append("/app-opds/download/")
|
||||
append(ref.dbType)
|
||||
append('/')
|
||||
append(ref.bookId)
|
||||
}
|
||||
|
||||
return Request.Builder()
|
||||
.url(downloadUrl)
|
||||
.headers(headers)
|
||||
.get()
|
||||
.build()
|
||||
}
|
||||
|
||||
internal fun Source.isBookOasisSource(): Boolean {
|
||||
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
|
||||
}
|
||||
|
||||
internal fun String.toBookOasisChapterRef(): BookOasisChapterRef? {
|
||||
val path = substringBefore('?')
|
||||
val match = Regex("""^/api/media/books/(\d+)/info$""").matchEntire(path) ?: return null
|
||||
val match = Regex("""^/(?:app-opds(?:-adult)?/)?api/media/books/(\d+)/info$""").matchEntire(path) ?: return null
|
||||
val params = parseQueryParameters(this)
|
||||
val format = params["bo_format"]?.lowercase() ?: return null
|
||||
if (format !in DOCUMENT_FORMATS) return null
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
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)
|
||||
|
||||
private fun HttpSource.kavitaApiBase(): String {
|
||||
val normalized = baseUrl.trimEnd('/')
|
||||
return if (normalized.endsWith("/api", ignoreCase = true)) normalized else "$normalized/api"
|
||||
}
|
||||
|
||||
internal suspend fun HttpSource.detectKavitaDocumentFormat(chapterId: Int): String? {
|
||||
val apiBase = kavitaApiBase()
|
||||
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 = kavitaApiBase()
|
||||
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.
|
||||
|
||||
@@ -57,6 +57,7 @@ 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
|
||||
@@ -186,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
|
||||
*/
|
||||
@@ -841,17 +848,26 @@ class ReaderViewModel(
|
||||
if (chapter.pageLoader?.usesTextReaderStyle != true) return
|
||||
if (chapter.pages?.any { it is TextReaderPage } != 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
|
||||
|
||||
viewModelScope.launchIO {
|
||||
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 {
|
||||
val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
|
||||
eventChannel.send(Event.ReloadTextViewerChapters)
|
||||
// Give old visible holders time to detach before closing resources such as EpubReader.
|
||||
delay(2000)
|
||||
oldLoader.recycle()
|
||||
|
||||
// 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 repaginate text reader style" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -22,7 +22,8 @@ import tachiyomi.domain.source.model.StubSource
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.source.local.LocalSource
|
||||
import tachiyomi.source.local.io.Format
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
|
||||
/**
|
||||
* Loader used to retrieve the [PageLoader] for a given chapter.
|
||||
@@ -90,10 +91,14 @@ class ChapterLoader(
|
||||
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 * pages.lastIndex)
|
||||
.roundToInt()
|
||||
chapter.requestedPage = (readingProgress.coerceIn(0f, 0.999999f) * pages.size)
|
||||
.toInt()
|
||||
.coerceIn(0, pages.lastIndex)
|
||||
chapter.state = ReaderChapter.State.Loaded(pages)
|
||||
oldLoader
|
||||
@@ -125,6 +130,33 @@ class ChapterLoader(
|
||||
)
|
||||
val bookOasisRef = dbChapter.url.toBookOasisChapterRef()
|
||||
val kavitaChapterId = dbChapter.url.toKavitaChapterId()
|
||||
val httpSource = source as? HttpSource
|
||||
val documentFallback: (() -> PageLoader)? = when {
|
||||
httpSource != null && httpSource.isBookOasisSource() && bookOasisRef != null &&
|
||||
(bookOasisRef.format == "epub" || bookOasisRef.format == "txt") -> {
|
||||
{
|
||||
BookOasisFilePageLoader(
|
||||
context,
|
||||
httpSource,
|
||||
bookOasisRef,
|
||||
readerPreferences.textReaderStyle(context),
|
||||
)
|
||||
}
|
||||
}
|
||||
httpSource != null && httpSource.isKavitaSource() && kavitaChapterId != null -> {
|
||||
{
|
||||
KavitaCompatPageLoader(
|
||||
context = context,
|
||||
chapter = chapter,
|
||||
source = httpSource,
|
||||
chapterCache = chapterCache,
|
||||
chapterId = kavitaChapterId,
|
||||
textStyle = readerPreferences.textReaderStyle(context),
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
return when {
|
||||
isDownloaded -> DownloadPageLoader(
|
||||
@@ -133,6 +165,7 @@ class ChapterLoader(
|
||||
source,
|
||||
downloadManager,
|
||||
downloadProvider,
|
||||
documentFallback,
|
||||
)
|
||||
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
|
||||
when (format) {
|
||||
|
||||
@@ -10,9 +10,14 @@ import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import kotlinx.coroutines.CancellationException
|
||||
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
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from the downloaded chapters.
|
||||
@@ -23,15 +28,45 @@ internal class DownloadPageLoader(
|
||||
private val source: Source,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val downloadProvider: DownloadProvider,
|
||||
private val documentFallback: (() -> PageLoader)? = null,
|
||||
) : PageLoader() {
|
||||
|
||||
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 || findDownloadedDocument() != null
|
||||
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
check(!isRecycled)
|
||||
|
||||
return try {
|
||||
val pages = getDownloadedPages()
|
||||
if (pages.isNotEmpty() || documentFallback == null) {
|
||||
pages
|
||||
} else {
|
||||
getFallbackPages()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException || documentFallback == null) throw error
|
||||
getFallbackPages()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getDownloadedPages(): List<ReaderPage> {
|
||||
val downloadedDocument = findDownloadedDocument()
|
||||
if (downloadedDocument != null) {
|
||||
val readableDocument = prepareDownloadedDocument(downloadedDocument)
|
||||
return when (readableDocument.extension?.lowercase()) {
|
||||
"epub" -> getPagesFromEpub(readableDocument)
|
||||
"txt" -> getPagesFromText(readableDocument)
|
||||
else -> error("Unsupported downloaded document: ${downloadedDocument.name}")
|
||||
}
|
||||
}
|
||||
|
||||
val dbChapter = chapter.chapter
|
||||
val chapterPath = downloadProvider.findChapterDir(
|
||||
dbChapter.name,
|
||||
@@ -40,6 +75,7 @@ internal class DownloadPageLoader(
|
||||
manga.title,
|
||||
source,
|
||||
)
|
||||
|
||||
return if (chapterPath?.isFile == true) {
|
||||
getPagesFromArchive(chapterPath)
|
||||
} else {
|
||||
@@ -47,13 +83,97 @@ internal class DownloadPageLoader(
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDownloadedDocument(): UniFile? {
|
||||
val dbChapter = chapter.chapter
|
||||
return downloadProvider.findChapterDocument(
|
||||
dbChapter.name,
|
||||
dbChapter.scanlator,
|
||||
dbChapter.url,
|
||||
manga.title,
|
||||
source,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror SAF-backed documents into the app cache before opening them.
|
||||
* EPUB uses mmap internally, which is not guaranteed to work with every
|
||||
* document provider even though regular stream reads/writes succeed.
|
||||
*/
|
||||
private fun prepareDownloadedDocument(file: UniFile): UniFile {
|
||||
val extension = file.extension?.lowercase()
|
||||
?: error("Downloaded document has no extension: ${file.name}")
|
||||
val sourceLength = file.length()
|
||||
if (sourceLength <= 0L) {
|
||||
error("Downloaded document is empty: ${file.name}")
|
||||
}
|
||||
|
||||
val cacheDir = File(context.cacheDir, "downloaded_reader").apply { mkdirs() }
|
||||
val prefix = "${source.id}-${chapter.chapter.id}-"
|
||||
val target = File(
|
||||
cacheDir,
|
||||
"$prefix$sourceLength-${file.lastModified()}.$extension",
|
||||
)
|
||||
|
||||
if (!target.isFile || target.length() != sourceLength) {
|
||||
cacheDir.listFiles()
|
||||
?.filter { it.name.startsWith(prefix) }
|
||||
?.forEach { it.delete() }
|
||||
|
||||
val temp = File(cacheDir, "${target.name}.part")
|
||||
if (temp.exists()) temp.delete()
|
||||
file.openInputStream().use { input ->
|
||||
temp.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
if (temp.length() != sourceLength) {
|
||||
temp.delete()
|
||||
error("Downloaded document cache copy is incomplete: ${file.name}")
|
||||
}
|
||||
if (!temp.renameTo(target)) {
|
||||
temp.copyTo(target, overwrite = true)
|
||||
temp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
return UniFile.fromFile(target)
|
||||
?: error("Unable to open cached downloaded document: ${target.absolutePath}")
|
||||
}
|
||||
|
||||
private suspend fun getFallbackPages(): List<ReaderPage> {
|
||||
pageLoader?.recycle()
|
||||
val fallback = documentFallback?.invoke()
|
||||
?: error("Downloaded document fallback is unavailable")
|
||||
pageLoader = fallback
|
||||
val pages = fallback.getPages()
|
||||
isLocal = fallback.isLocal
|
||||
return pages
|
||||
}
|
||||
|
||||
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 +189,6 @@ internal class DownloadPageLoader(
|
||||
}
|
||||
|
||||
override suspend fun loadPage(page: ReaderPage) {
|
||||
archivePageLoader?.loadPage(page)
|
||||
pageLoader?.loadPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,15 @@ 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 okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Compatibility loader for Kavita document chapters.
|
||||
@@ -42,7 +41,7 @@ internal class KavitaCompatPageLoader(
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
check(!isRecycled)
|
||||
|
||||
val format = detectDocumentFormat()
|
||||
val format = source.detectKavitaDocumentFormat(chapterId)
|
||||
val loader = when (format) {
|
||||
"epub", "txt" -> createDocumentLoader(format)
|
||||
else -> HttpPageLoader(chapter, source, chapterCache)
|
||||
@@ -67,51 +66,6 @@ internal class KavitaCompatPageLoader(
|
||||
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)
|
||||
@@ -132,12 +86,7 @@ internal class KavitaCompatPageLoader(
|
||||
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()
|
||||
val request = source.kavitaDocumentDownloadRequest(chapterId)
|
||||
|
||||
source.client.newCall(request).await().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
|
||||
@@ -16,13 +16,10 @@ 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
|
||||
|
||||
@@ -101,7 +98,8 @@ internal object TextPageRenderer {
|
||||
private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
|
||||
val layout = createLayout(text, style)
|
||||
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>()
|
||||
@@ -111,7 +109,7 @@ internal object TextPageRenderer {
|
||||
var endLine = startLine
|
||||
while (
|
||||
endLine + 1 < layout.lineCount &&
|
||||
layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT
|
||||
layout.getLineBottom(endLine + 1) - startTop <= metrics.contentHeight
|
||||
) {
|
||||
endLine++
|
||||
}
|
||||
@@ -125,13 +123,14 @@ internal object TextPageRenderer {
|
||||
}
|
||||
|
||||
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)
|
||||
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()
|
||||
@@ -140,25 +139,46 @@ 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 = textColor
|
||||
textSize = style.fontSize * 3f
|
||||
textSize = style.fontSizePx.coerceAtLeast(1f)
|
||||
typeface = style.fontFamily.androidFamilyName
|
||||
?.let { Typeface.create(it, Typeface.NORMAL) }
|
||||
?: Typeface.DEFAULT
|
||||
}
|
||||
val styledText = addParagraphSpacing(text, style.paragraphSpacing * 3)
|
||||
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
|
||||
@@ -205,6 +225,15 @@ internal object TextPageRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -5,9 +5,8 @@ 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.
|
||||
* 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,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -137,15 +138,44 @@ class ReaderPreferences(
|
||||
}
|
||||
}
|
||||
|
||||
fun textReaderStyle(context: Context) = 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,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -336,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(
|
||||
@@ -399,4 +432,9 @@ data class TextReaderStyle(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -32,20 +32,49 @@ class ReaderSettingsViewModel(
|
||||
.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(200)
|
||||
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() {
|
||||
if (!textSettingsDirty) return
|
||||
val hadPendingAppearanceReload = textAppearanceReloadJob != null
|
||||
textAppearanceReloadJob?.cancel()
|
||||
textAppearanceReloadJob = null
|
||||
if (!textSettingsDirty) {
|
||||
if (hadPendingAppearanceReload) {
|
||||
onTextSettingsPreview()
|
||||
}
|
||||
return
|
||||
}
|
||||
textSettingsReloadJob?.cancel()
|
||||
textSettingsReloadJob = null
|
||||
onTextSettingsPreview()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user