Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe73aff860 | |||
| a347cd10b8 | |||
| 849148691a | |||
| c0e0ef1575 |
@@ -53,7 +53,7 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
|
|||||||
selected = readerTheme == value,
|
selected = readerTheme == value,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.preferences.readerTheme.set(value)
|
viewModel.preferences.readerTheme.set(value)
|
||||||
viewModel.requestTextSettingsReload()
|
viewModel.requestTextAppearanceReload()
|
||||||
},
|
},
|
||||||
label = { Text(stringResource(labelRes)) },
|
label = { Text(stringResource(labelRes)) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -374,10 +374,13 @@ class DownloadCache(
|
|||||||
when {
|
when {
|
||||||
// Ignore incomplete downloads
|
// Ignore incomplete downloads
|
||||||
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
|
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
|
||||||
|
it.name?.contains(".download.", ignoreCase = true) == true -> null
|
||||||
// Folder of images
|
// Folder of images
|
||||||
it.isDirectory -> it.name
|
it.isDirectory -> it.name
|
||||||
// CBZ files
|
// Archived or original document files
|
||||||
it.isFile && it.extension == "cbz" -> it.nameWithoutExtension
|
it.isFile && it.extension?.lowercase()?.let { extension ->
|
||||||
|
extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS
|
||||||
|
} == true -> it.nameWithoutExtension
|
||||||
// Anything else is irrelevant
|
// Anything else is irrelevant
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,8 +405,12 @@ class DownloadManager(
|
|||||||
.firstOrNull() ?: return
|
.firstOrNull() ?: return
|
||||||
|
|
||||||
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
|
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
|
||||||
if (oldDownload.isFile && oldDownload.extension == "cbz") {
|
if (oldDownload.isFile) {
|
||||||
newName += ".cbz"
|
oldDownload.extension?.lowercase()?.let { extension ->
|
||||||
|
if (extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS) {
|
||||||
|
newName += ".$extension"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldDownload.name == newName) return
|
if (oldDownload.name == newName) return
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import tachiyomi.domain.storage.service.StorageManager
|
|||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import java.io.IOException
|
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.
|
* 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>
|
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
|
||||||
@@ -250,11 +252,17 @@ class DownloadProvider(
|
|||||||
add(chapterDirName)
|
add(chapterDirName)
|
||||||
// Archived chapters
|
// Archived chapters
|
||||||
add("$chapterDirName.cbz")
|
add("$chapterDirName.cbz")
|
||||||
|
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
|
||||||
|
add("$chapterDirName.$extension")
|
||||||
|
}
|
||||||
|
|
||||||
// any legacy names
|
// any legacy names
|
||||||
legacyChapterDirNames.forEach {
|
legacyChapterDirNames.forEach {
|
||||||
add(it)
|
add(it)
|
||||||
add("$it.cbz")
|
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.library.LibraryUpdateNotifier
|
||||||
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
||||||
import eu.kanade.tachiyomi.network.HttpException
|
import eu.kanade.tachiyomi.network.HttpException
|
||||||
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.source.UnmeteredSource
|
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.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.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
|
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
|
||||||
@@ -44,6 +52,7 @@ import kotlinx.coroutines.supervisorScope
|
|||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.core.archive.ZipWriter
|
import mihon.core.archive.ZipWriter
|
||||||
import nl.adaptivity.xmlutil.serialization.XML
|
import nl.adaptivity.xmlutil.serialization.XML
|
||||||
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.core.common.util.lang.launchIO
|
import tachiyomi.core.common.util.lang.launchIO
|
||||||
@@ -339,9 +348,15 @@ class Downloader(
|
|||||||
download.chapter.scanlator,
|
download.chapter.scanlator,
|
||||||
download.chapter.url,
|
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)!!
|
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
|
||||||
|
|
||||||
try {
|
|
||||||
// If the page list already exists, start from the file
|
// If the page list already exists, start from the file
|
||||||
val pageList = download.pages ?: run {
|
val pageList = download.pages ?: run {
|
||||||
// Otherwise, pull page list from network and add them to download object
|
// 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.
|
* 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
|
// Arbitrary minimum required space to start a download: 200 MB
|
||||||
private const val MIN_DISK_SPACE = 200L * 1024 * 1024
|
private const val MIN_DISK_SPACE = 200L * 1024 * 1024
|
||||||
|
|||||||
@@ -21,13 +21,40 @@ internal data class BookOasisChapterRef(
|
|||||||
val format: String,
|
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 {
|
internal fun Source.isBookOasisSource(): Boolean {
|
||||||
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
|
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun String.toBookOasisChapterRef(): BookOasisChapterRef? {
|
internal fun String.toBookOasisChapterRef(): BookOasisChapterRef? {
|
||||||
val path = substringBefore('?')
|
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 params = parseQueryParameters(this)
|
||||||
val format = params["bo_format"]?.lowercase() ?: return null
|
val format = params["bo_format"]?.lowercase() ?: return null
|
||||||
if (format !in DOCUMENT_FORMATS) return null
|
if (format !in DOCUMENT_FORMATS) return null
|
||||||
|
|||||||
@@ -1,12 +1,61 @@
|
|||||||
package eu.kanade.tachiyomi.source.kavita
|
package eu.kanade.tachiyomi.source.kavita
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.source.Source
|
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."
|
private const val KAVITA_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.kavita."
|
||||||
|
|
||||||
internal fun Source.isKavitaSource(): Boolean =
|
internal fun Source.isKavitaSource(): Boolean =
|
||||||
this::class.java.name.startsWith(KAVITA_PACKAGE_PREFIX)
|
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
|
* Kavita extension chapter URLs are normally /Chapter/{id}. Keep compatibility
|
||||||
* with older/alternate forms used by reading lists and previous releases.
|
* 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 eu.kanade.tachiyomi.util.storage.cacheImageDir
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -186,6 +187,12 @@ class ReaderViewModel(
|
|||||||
*/
|
*/
|
||||||
private var loader: ChapterLoader? = null
|
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
|
* The time the chapter was started reading
|
||||||
*/
|
*/
|
||||||
@@ -841,17 +848,26 @@ class ReaderViewModel(
|
|||||||
if (chapter.pageLoader?.usesTextReaderStyle != true) return
|
if (chapter.pageLoader?.usesTextReaderStyle != true) return
|
||||||
if (chapter.pages?.any { it is TextReaderPage } != true) return
|
if (chapter.pages?.any { it is TextReaderPage } != true) return
|
||||||
val chapterLoader = loader ?: return
|
val chapterLoader = loader ?: return
|
||||||
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
|
|
||||||
val 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 {
|
try {
|
||||||
val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
|
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.
|
// Resource cleanup must survive cancellation by the next slider value.
|
||||||
delay(2000)
|
viewModelScope.launchNonCancellable {
|
||||||
|
delay(750)
|
||||||
oldLoader.recycle()
|
oldLoader.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
eventChannel.trySend(Event.ReloadTextViewerChapters)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
|
logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import android.content.Context
|
|||||||
import com.hippo.unifile.UniFile
|
import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.tachiyomi.network.await
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef
|
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.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||||
import mihon.core.archive.epubReader
|
import mihon.core.archive.epubReader
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -68,19 +68,7 @@ internal class BookOasisFilePageLoader(
|
|||||||
val temp = File(cacheDir, "${target.name}.part")
|
val temp = File(cacheDir, "${target.name}.part")
|
||||||
if (temp.exists()) temp.delete()
|
if (temp.exists()) temp.delete()
|
||||||
|
|
||||||
val downloadUrl = buildString {
|
val request = source.bookOasisDocumentDownloadRequest(ref)
|
||||||
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()
|
|
||||||
|
|
||||||
source.client.newCall(request).await().use { response ->
|
source.client.newCall(request).await().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import tachiyomi.domain.source.model.StubSource
|
|||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.source.local.LocalSource
|
import tachiyomi.source.local.LocalSource
|
||||||
import tachiyomi.source.local.io.Format
|
import tachiyomi.source.local.io.Format
|
||||||
import kotlin.math.roundToInt
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loader used to retrieve the [PageLoader] for a given chapter.
|
* 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))
|
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
|
val oldLoader = chapter.pageLoader
|
||||||
chapter.pageLoader = newLoader
|
chapter.pageLoader = newLoader
|
||||||
chapter.requestedPage = (readingProgress * pages.lastIndex)
|
chapter.requestedPage = (readingProgress.coerceIn(0f, 0.999999f) * pages.size)
|
||||||
.roundToInt()
|
.toInt()
|
||||||
.coerceIn(0, pages.lastIndex)
|
.coerceIn(0, pages.lastIndex)
|
||||||
chapter.state = ReaderChapter.State.Loaded(pages)
|
chapter.state = ReaderChapter.State.Loaded(pages)
|
||||||
oldLoader
|
oldLoader
|
||||||
|
|||||||
@@ -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.ReaderChapter
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
import mihon.core.archive.archiveReader
|
import mihon.core.archive.archiveReader
|
||||||
|
import mihon.core.archive.epubReader
|
||||||
|
import tachiyomi.core.common.storage.extension
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,9 +30,12 @@ internal class DownloadPageLoader(
|
|||||||
|
|
||||||
private val context: Context by injectLazy()
|
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 var isLocal: Boolean = true
|
||||||
|
override val usesTextReaderStyle: Boolean
|
||||||
|
get() = pageLoader?.usesTextReaderStyle == true
|
||||||
|
|
||||||
override suspend fun getPages(): List<ReaderPage> {
|
override suspend fun getPages(): List<ReaderPage> {
|
||||||
val dbChapter = chapter.chapter
|
val dbChapter = chapter.chapter
|
||||||
@@ -41,7 +47,11 @@ internal class DownloadPageLoader(
|
|||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
return if (chapterPath?.isFile == true) {
|
return if (chapterPath?.isFile == true) {
|
||||||
getPagesFromArchive(chapterPath)
|
when (chapterPath.extension?.lowercase()) {
|
||||||
|
"epub" -> getPagesFromEpub(chapterPath)
|
||||||
|
"txt" -> getPagesFromText(chapterPath)
|
||||||
|
else -> getPagesFromArchive(chapterPath)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
getPagesFromDirectory()
|
getPagesFromDirectory()
|
||||||
}
|
}
|
||||||
@@ -49,11 +59,30 @@ internal class DownloadPageLoader(
|
|||||||
|
|
||||||
override fun recycle() {
|
override fun recycle() {
|
||||||
super.recycle()
|
super.recycle()
|
||||||
archivePageLoader?.recycle()
|
pageLoader?.recycle()
|
||||||
|
pageLoader = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
|
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()
|
return loader.getPages()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +98,6 @@ internal class DownloadPageLoader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadPage(page: ReaderPage) {
|
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 com.hippo.unifile.UniFile
|
||||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||||
import eu.kanade.tachiyomi.network.await
|
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.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||||
import mihon.core.archive.epubReader
|
import mihon.core.archive.epubReader
|
||||||
import okhttp3.Request
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compatibility loader for Kavita document chapters.
|
* Compatibility loader for Kavita document chapters.
|
||||||
@@ -42,7 +41,7 @@ internal class KavitaCompatPageLoader(
|
|||||||
override suspend fun getPages(): List<ReaderPage> {
|
override suspend fun getPages(): List<ReaderPage> {
|
||||||
check(!isRecycled)
|
check(!isRecycled)
|
||||||
|
|
||||||
val format = detectDocumentFormat()
|
val format = source.detectKavitaDocumentFormat(chapterId)
|
||||||
val loader = when (format) {
|
val loader = when (format) {
|
||||||
"epub", "txt" -> createDocumentLoader(format)
|
"epub", "txt" -> createDocumentLoader(format)
|
||||||
else -> HttpPageLoader(chapter, source, chapterCache)
|
else -> HttpPageLoader(chapter, source, chapterCache)
|
||||||
@@ -67,51 +66,6 @@ internal class KavitaCompatPageLoader(
|
|||||||
super.recycle()
|
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 {
|
private suspend fun createDocumentLoader(format: String): PageLoader {
|
||||||
val file = getOrDownloadDocument(format)
|
val file = getOrDownloadDocument(format)
|
||||||
val uniFile = UniFile.fromFile(file)
|
val uniFile = UniFile.fromFile(file)
|
||||||
@@ -132,12 +86,7 @@ internal class KavitaCompatPageLoader(
|
|||||||
val temp = File(cacheDir, "${target.name}.part")
|
val temp = File(cacheDir, "${target.name}.part")
|
||||||
if (temp.exists()) temp.delete()
|
if (temp.exists()) temp.delete()
|
||||||
|
|
||||||
val apiBase = source.baseUrl.trimEnd('/') + "/api"
|
val request = source.kavitaDocumentDownloadRequest(chapterId)
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$apiBase/Download/chapter?chapterId=$chapterId")
|
|
||||||
.headers(source.headers)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
source.client.newCall(request).await().use { response ->
|
source.client.newCall(request).await().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
|
|||||||
@@ -16,13 +16,10 @@ import java.io.ByteArrayInputStream
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.Reader
|
import java.io.Reader
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
internal object TextPageRenderer {
|
internal object TextPageRenderer {
|
||||||
private const val PAGE_WIDTH = 1440
|
private const val MARGIN_DP = 32f
|
||||||
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 STREAM_BUFFER_CHARS = 64 * 1024
|
private const val STREAM_BUFFER_CHARS = 64 * 1024
|
||||||
private const val STREAM_READ_CHARS = 8 * 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> {
|
private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> {
|
||||||
if (text.isEmpty()) return emptyList()
|
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))
|
if (layout.lineCount == 0) return listOf(PageSlice(0, text.length))
|
||||||
|
|
||||||
val pages = mutableListOf<PageSlice>()
|
val pages = mutableListOf<PageSlice>()
|
||||||
@@ -111,7 +109,7 @@ internal object TextPageRenderer {
|
|||||||
var endLine = startLine
|
var endLine = startLine
|
||||||
while (
|
while (
|
||||||
endLine + 1 < layout.lineCount &&
|
endLine + 1 < layout.lineCount &&
|
||||||
layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT
|
layout.getLineBottom(endLine + 1) - startTop <= metrics.contentHeight
|
||||||
) {
|
) {
|
||||||
endLine++
|
endLine++
|
||||||
}
|
}
|
||||||
@@ -125,13 +123,14 @@ internal object TextPageRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun render(text: String, style: TextReaderStyle): InputStream {
|
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 canvas = Canvas(bitmap)
|
||||||
val (backgroundColor, _) = resolveColors(style)
|
val (backgroundColor, _) = resolveColors(style)
|
||||||
canvas.drawColor(backgroundColor)
|
canvas.drawColor(backgroundColor)
|
||||||
canvas.save()
|
canvas.save()
|
||||||
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat())
|
canvas.translate(metrics.horizontalMargin.toFloat(), metrics.verticalMargin.toFloat())
|
||||||
createLayout(text, style).draw(canvas)
|
createLayout(text, style, metrics.contentWidth).draw(canvas)
|
||||||
canvas.restore()
|
canvas.restore()
|
||||||
|
|
||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
@@ -140,25 +139,46 @@ internal object TextPageRenderer {
|
|||||||
return ByteArrayInputStream(output.toByteArray())
|
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 (_, textColor) = resolveColors(style)
|
||||||
val paint = TextPaint().apply {
|
val paint = TextPaint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
color = textColor
|
color = textColor
|
||||||
textSize = style.fontSize * 3f
|
textSize = style.fontSizePx.coerceAtLeast(1f)
|
||||||
typeface = style.fontFamily.androidFamilyName
|
typeface = style.fontFamily.androidFamilyName
|
||||||
?.let { Typeface.create(it, Typeface.NORMAL) }
|
?.let { Typeface.create(it, Typeface.NORMAL) }
|
||||||
?: Typeface.DEFAULT
|
?: Typeface.DEFAULT
|
||||||
}
|
}
|
||||||
val styledText = addParagraphSpacing(text, style.paragraphSpacing * 3)
|
val styledText = addParagraphSpacing(text, style.paragraphSpacingPx)
|
||||||
return StaticLayout.Builder
|
return StaticLayout.Builder
|
||||||
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
|
.obtain(styledText, 0, styledText.length, paint, contentWidth)
|
||||||
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||||
.setIncludePad(true)
|
.setIncludePad(true)
|
||||||
.setLineSpacing(0f, style.lineSpacingPercent / 100f)
|
.setLineSpacing(0f, style.lineSpacingPercent / 100f)
|
||||||
.build()
|
.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> {
|
private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> {
|
||||||
val useDark = when (style.readerTheme) {
|
val useDark = when (style.readerTheme) {
|
||||||
0 -> false
|
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(
|
private data class PageSlice(
|
||||||
val start: Int,
|
val start: Int,
|
||||||
val endExclusive: Int,
|
val endExclusive: Int,
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
|||||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A rendered text page whose style can be changed without rebuilding the chapter.
|
* A rendered text page whose visual style can be refreshed cheaply while layout-affecting
|
||||||
* Page boundaries remain stable during the live preview; a full repagination is done
|
* changes trigger a debounced chapter repagination to rebuild page boundaries.
|
||||||
* once when the settings dialog is dismissed.
|
|
||||||
*/
|
*/
|
||||||
internal class TextReaderPage(
|
internal class TextReaderPage(
|
||||||
index: Int,
|
index: Int,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader.setting
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.TypedValue
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import dev.icerock.moko.resources.StringResource
|
import dev.icerock.moko.resources.StringResource
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
@@ -137,7 +138,20 @@ class ReaderPreferences(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun textReaderStyle(context: Context) = TextReaderStyle(
|
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(),
|
fontSize = textFontSize.get(),
|
||||||
lineSpacingPercent = textLineSpacing.get(),
|
lineSpacingPercent = textLineSpacing.get(),
|
||||||
paragraphSpacing = textParagraphSpacing.get(),
|
paragraphSpacing = textParagraphSpacing.get(),
|
||||||
@@ -145,7 +159,23 @@ class ReaderPreferences(
|
|||||||
readerTheme = readerTheme.get(),
|
readerTheme = readerTheme.get(),
|
||||||
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
|
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
Configuration.UI_MODE_NIGHT_YES,
|
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
|
// endregion
|
||||||
|
|
||||||
@@ -336,6 +366,9 @@ class ReaderPreferences(
|
|||||||
const val WEBTOON_PADDING_MIN = 0
|
const val WEBTOON_PADDING_MIN = 0
|
||||||
const val WEBTOON_PADDING_MAX = 25
|
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
|
const val MILLI_CONVERSION = 100
|
||||||
|
|
||||||
val TapZones = listOf(
|
val TapZones = listOf(
|
||||||
@@ -399,4 +432,9 @@ data class TextReaderStyle(
|
|||||||
val fontFamily: ReaderPreferences.TextFontFamily,
|
val fontFamily: ReaderPreferences.TextFontFamily,
|
||||||
val readerTheme: Int,
|
val readerTheme: Int,
|
||||||
val automaticDark: Boolean,
|
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)
|
.stateIn(viewModelScope, SharingStarted.Lazily, null)
|
||||||
|
|
||||||
private var textSettingsReloadJob: Job? = null
|
private var textSettingsReloadJob: Job? = null
|
||||||
|
private var textAppearanceReloadJob: Job? = null
|
||||||
private var textSettingsDirty = false
|
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() {
|
fun requestTextSettingsReload() {
|
||||||
textSettingsDirty = true
|
textSettingsDirty = true
|
||||||
textSettingsReloadJob?.cancel()
|
textSettingsReloadJob?.cancel()
|
||||||
textSettingsReloadJob = viewModelScope.launch {
|
textSettingsReloadJob = viewModelScope.launch {
|
||||||
delay(200)
|
delay(250)
|
||||||
onTextSettingsPreview()
|
onTextSettingsPreview()
|
||||||
|
textSettingsDirty = false
|
||||||
|
onTextSettingsCommit()
|
||||||
textSettingsReloadJob = null
|
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() {
|
fun flushTextSettingsReload() {
|
||||||
if (!textSettingsDirty) return
|
val hadPendingAppearanceReload = textAppearanceReloadJob != null
|
||||||
|
textAppearanceReloadJob?.cancel()
|
||||||
|
textAppearanceReloadJob = null
|
||||||
|
if (!textSettingsDirty) {
|
||||||
|
if (hadPendingAppearanceReload) {
|
||||||
|
onTextSettingsPreview()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
textSettingsReloadJob?.cancel()
|
textSettingsReloadJob?.cancel()
|
||||||
textSettingsReloadJob = null
|
textSettingsReloadJob = null
|
||||||
onTextSettingsPreview()
|
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