Convert ReaderPresenter to ReaderViewModel

Also fixed certain sources without a contenttype not loading
Also remove HTTP 103 interceptor
Also upgrade some serialization libraries
Look a lot of stuff got taken from upstream if you're reading this commit and you work on main your code is probably is in this commit

Co-Authored-By: arkon <4098258+arkon@users.noreply.github.com>
This commit is contained in:
Jays2Kings 2023-02-14 03:08:05 -05:00
parent 98319daba4
commit d443da4dcc
36 changed files with 958 additions and 1056 deletions

View file

@ -164,7 +164,7 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion")
implementation("com.squareup.okio:okio:3.0.0")
implementation("com.squareup.okio:okio:3.3.0")
// Chucker
val chuckerVersion = "3.5.2"
@ -175,9 +175,10 @@ dependencies {
implementation(kotlin("reflect", version = AndroidVersions.kotlin))
// JSON
val kotlinSerialization = "1.3.3"
val kotlinSerialization = "1.4.0"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlinSerialization}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:${kotlinSerialization}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:${kotlinSerialization}")
// JavaScript engine
implementation("app.cash.quickjs:quickjs-android:0.9.2")
@ -188,7 +189,7 @@ dependencies {
implementation("com.github.junrar:junrar:7.5.0")
// HTML parser
implementation("org.jsoup:jsoup:1.14.3")
implementation("org.jsoup:jsoup:1.15.3")
// Job scheduling
implementation("androidx.work:work-runtime-ktx:2.6.0")

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
import android.content.Context
import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
@ -13,7 +14,6 @@ import eu.kanade.tachiyomi.source.model.Page
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import rx.Observable
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
@ -177,32 +177,21 @@ class DownloadManager(val context: Context) {
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the downloaded chapter.
* @return an observable containing the list of pages from the chapter.
* @return the list of pages from the chapter.
*/
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): Observable<List<Page>> {
return buildPageList(provider.findChapterDir(chapter, manga, source))
}
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): List<Page> {
val chapterDir = provider.findChapterDir(chapter, manga, source)
val files = chapterDir?.listFiles().orEmpty()
.filter { "image" in it.type.orEmpty() }
/**
* Builds the page list of a downloaded chapter.
*
* @param chapterDir the file where the chapter is downloaded.
* @return an observable containing the list of pages from the chapter.
*/
private fun buildPageList(chapterDir: UniFile?): Observable<List<Page>> {
return Observable.fromCallable {
val files = chapterDir?.listFiles().orEmpty()
.filter { "image" in it.type.orEmpty() }
if (files.isEmpty()) {
throw Exception("Page list is empty")
}
files.sortedBy { it.name }
.mapIndexed { i, file ->
Page(i, uri = file.uri).apply { status = Page.READY }
}
if (files.isEmpty()) {
throw Exception(context.getString(R.string.no_pages_found))
}
return files.sortedBy { it.name }
.mapIndexed { i, file ->
Page(i, uri = file.uri).apply { status = Page.State.READY }
}
}
/**

View file

@ -346,11 +346,14 @@ class Downloader(
val pageListObservable = if (download.pages == null) {
// Pull page list from network and add them to download object
download.source.fetchPageList(download.chapter)
.doOnNext { pages ->
.map { pages ->
if (pages.isEmpty()) {
throw Exception(context.getString(R.string.no_pages_found))
}
download.pages = pages
// Don't trust index from source
val reIndexedPages = pages.mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }
download.pages = reIndexedPages
reIndexedPages
}
} else {
// Or if the page list already exists, start from the file
@ -434,13 +437,13 @@ class Downloader(
page.uri = file.uri
page.progress = 100
download.downloadedImages++
page.status = Page.READY
page.status = Page.State.READY
}
.map { page }
// Mark this page as error and allow to download the remaining
.onErrorReturn {
page.progress = 0
page.status = Page.ERROR
page.status = Page.State.ERROR
notifier.onError(it.message, download.chapter.name, download.manga.title)
page
}
@ -460,13 +463,13 @@ class Downloader(
tmpDir: UniFile,
filename: String,
): Observable<UniFile> {
page.status = Page.DOWNLOAD_IMAGE
page.status = Page.State.DOWNLOAD_IMAGE
page.progress = 0
return source.fetchImage(page)
.map { response ->
val file = tmpDir.createFile("$filename.tmp")
try {
response.body!!.source().saveTo(file.openOutputStream())
response.body.source().saveTo(file.openOutputStream())
val extension = getImageExtension(response, file)
file.renameTo("$filename.$extension")
} catch (e: Exception) {
@ -515,7 +518,7 @@ class Downloader(
*/
private fun getImageExtension(response: Response, file: UniFile): String {
// Read content type if available.
val mime = response.body?.contentType()?.let { ct -> "${ct.type}/${ct.subtype}" }
val mime = response.body.contentType()?.let { ct -> "${ct.type}/${ct.subtype}" }
// Else guess from the uri.
?: context.contentResolver.getType(file.uri)
// Else read magic numbers.

View file

@ -4,7 +4,9 @@ import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadStore
import eu.kanade.tachiyomi.source.model.Page
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import rx.subjects.PublishSubject
import java.util.concurrent.CopyOnWriteArrayList
@ -20,6 +22,8 @@ class DownloadQueue(
private val downloadListeners = mutableListOf<DownloadListener>()
private var scope = MainScope()
fun addAll(downloads: List<Download>) {
downloads.forEach { download ->
download.setStatusSubject(statusSubject)
@ -79,13 +83,15 @@ class DownloadQueue(
if (download.status == Download.State.DOWNLOADING) {
if (download.pages != null) {
for (page in download.pages!!)
page.setStatusCallback {
callListeners(download)
scope.launch {
page.statusFlow.collectLatest {
callListeners(download)
}
}
}
callListeners(download)
} else if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) {
setPagesSubject(download.pages, null)
// setPagesSubject(download.pages, null)
if (download.status == Download.State.ERROR) {
callListeners(download)
}
@ -98,13 +104,13 @@ class DownloadQueue(
downloadListeners.forEach { it.updateDownload(download) }
}
private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) {
if (pages != null) {
for (page in pages) {
page.setStatusSubject(subject)
}
}
}
// private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) {
// if (pages != null) {
// for (page in pages) {
// page.setStatusSubject(subject)
// }
// }
// }
fun addListener(listener: DownloadListener) {
downloadListeners.add(listener)

View file

@ -18,7 +18,7 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.storage.saveTo
import eu.kanade.tachiyomi.util.system.acquireWakeLock
@ -141,7 +141,7 @@ class AppUpdateService : Service() {
try {
// Download the new update.
val call = network.client.newCallWithProgress(GET(url), progressListener)
val call = network.client.newCachelessCallWithProgress(GET(url), progressListener)
runningCall = call
val response = call.await()

View file

@ -6,7 +6,6 @@ import com.chuckerteam.chucker.api.ChuckerInterceptor
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.Http103Interceptor
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
import okhttp3.Cache
import okhttp3.OkHttpClient
@ -25,7 +24,6 @@ class NetworkHelper(val context: Context) {
val cookieManager = AndroidCookieJar()
private val userAgentInterceptor by lazy { UserAgentInterceptor() }
private val http103Interceptor by lazy { Http103Interceptor(context) }
private val cloudflareInterceptor by lazy { CloudflareInterceptor(context) }
private val baseClientBuilder: OkHttpClient.Builder
@ -36,7 +34,6 @@ class NetworkHelper(val context: Context) {
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
.addInterceptor(userAgentInterceptor)
.addNetworkInterceptor(http103Interceptor)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(

View file

@ -1,9 +1,12 @@
package eu.kanade.tachiyomi.network
import coil.network.HttpException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import kotlinx.serialization.serializer
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
@ -14,10 +17,9 @@ import rx.Observable
import rx.Producer
import rx.Subscription
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.fullType
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
val jsonMime = "application/json; charset=utf-8".toMediaType()
@ -38,9 +40,9 @@ fun Call.asObservable(): Observable<Response> {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (error: Exception) {
} catch (e: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(error)
subscriber.onError(e)
}
}
}
@ -60,6 +62,7 @@ fun Call.asObservable(): Observable<Response> {
}
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
return suspendCancellableCoroutine { continuation ->
val callback =
@ -109,18 +112,18 @@ fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw Exception("HTTP error ${response.code}")
throw HttpException(response.code)
}
}
}
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body!!, listener))
.body(ProgressResponseBody(originalResponse.body, listener))
.build()
}
.build()
@ -129,11 +132,13 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene
}
inline fun <reified T> Response.parseAs(): T {
// Avoiding Injekt.get<Json>() due to compiler issues
val json = Injekt.getInstance<Json>(fullType<Json>().type)
this.use {
val responseBody = it.body?.string().orEmpty()
return json.decodeFromString(responseBody)
return decodeFromJsonResponse(serializer(), this)
}
@OptIn(ExperimentalSerializationApi::class)
fun <T> decodeFromJsonResponse(deserializer: DeserializationStrategy<T>, response: Response): T {
return response.body.source().use {
Injekt.get<Json>().decodeFromBufferedSource(deserializer, it)
}
}

View file

@ -15,8 +15,8 @@ class ProgressResponseBody(private val responseBody: ResponseBody, private val p
source(responseBody.source()).buffer()
}
override fun contentType(): MediaType {
return responseBody.contentType()!!
override fun contentType(): MediaType? {
return responseBody.contentType()
}
override fun contentLength(): Long {

View file

@ -1,110 +0,0 @@
package eu.kanade.tachiyomi.network.interceptor
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.util.system.WebViewClientCompat
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import timber.log.Timber
import java.io.IOException
import java.util.concurrent.CountDownLatch
// TODO: Remove when OkHttp can handle HTTP 103 responses
class Http103Interceptor(context: Context) : WebViewInterceptor(context) {
private val executor = ContextCompat.getMainExecutor(context)
override fun shouldIntercept(response: Response): Boolean {
return response.code == 103
}
override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response {
Timber.d("Proceeding with WebView for request $request")
try {
return proceedWithWebView(request, response)
} catch (e: Exception) {
throw IOException(e)
}
}
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
private fun proceedWithWebView(originalRequest: Request, originalResponse: Response): Response {
// We need to lock this thread until the WebView loads the page, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
val jsInterface = JsInterface(latch)
var webview: WebView? = null
var exception: Exception? = null
val requestUrl = originalRequest.url.toString()
val headers = parseHeaders(originalRequest.headers)
executor.execute {
webview = createWebView(originalRequest)
webview?.addJavascriptInterface(jsInterface, "android")
webview?.webViewClient = object : WebViewClientCompat() {
override fun onPageFinished(view: WebView, url: String) {
view.evaluateJavascript(jsScript) {}
}
override fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean,
) {
if (isMainFrame) {
exception = Exception("Error $errorCode - $description")
latch.countDown()
}
}
}
webview?.loadUrl(requestUrl, headers)
}
latch.awaitFor30Seconds()
executor.execute {
webview?.run {
stopLoading()
destroy()
}
}
exception?.let { throw it }
val responseHtml = jsInterface.responseHtml ?: throw Exception("Couldn't fetch site through webview")
return originalResponse.newBuilder()
.code(200)
.protocol(Protocol.HTTP_1_1)
.message("OK")
.body(responseHtml.toResponseBody(htmlMediaType))
.build()
}
}
internal class JsInterface(private val latch: CountDownLatch, var responseHtml: String? = null) {
@Suppress("UNUSED")
@JavascriptInterface
fun passPayload(passedPayload: String) {
responseHtml = passedPayload
latch.countDown()
}
}
private const val jsScript = "window.android.passPayload(document.querySelector('html').outerHTML)"
private val htmlMediaType = "text/html".toMediaType()

View file

@ -121,7 +121,7 @@ open class SourceManager(private val context: Context) {
return name
}
private fun getSourceNotInstalledException(): Exception {
fun getSourceNotInstalledException(): Exception {
return SourceNotFoundException(
context.getString(
R.string.source_not_installed_,

View file

@ -2,8 +2,12 @@ package eu.kanade.tachiyomi.source.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import rx.subjects.Subject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
open class Page(
val index: Int,
val url: String = "",
@ -14,25 +18,28 @@ open class Page(
val number: Int
get() = index + 1
@Transient @Volatile
var status: Int = 0
@Transient
private val _statusFlow = MutableStateFlow(State.QUEUE)
@Transient
val statusFlow = _statusFlow.asStateFlow()
var status: State
get() = _statusFlow.value
set(value) {
field = value
statusSubject?.onNext(value)
statusCallback?.invoke(this)
_statusFlow.value = value
}
@Transient @Volatile
var progress: Int = 0
@Transient
private val _progressFlow = MutableStateFlow(0)
@Transient
val progressFlow = _progressFlow.asStateFlow()
var progress: Int
get() = _progressFlow.value
set(value) {
field = value
statusCallback?.invoke(this)
_progressFlow.value = value
}
@Transient private var statusSubject: Subject<Int, Int>? = null
@Transient private var statusCallback: ((Page) -> Unit)? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
@ -41,19 +48,11 @@ open class Page(
}
}
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
fun setStatusCallback(f: ((Page) -> Unit)?) {
statusCallback = f
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
enum class State {
QUEUE,
LOAD_PAGE,
DOWNLOAD_IMAGE,
READY,
ERROR,
}
}

View file

@ -5,7 +5,8 @@ import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
@ -305,10 +306,21 @@ abstract class HttpSource : CatalogueSource {
* @param page the page whose source image has to be downloaded.
*/
fun fetchImage(page: Page): Observable<Response> {
return client.newCallWithProgress(imageRequest(page), page)
return client.newCachelessCallWithProgress(imageRequest(page), page)
.asObservableSuccess()
}
/**
* Returns the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
suspend fun getImage(page: Page): Response {
// images will be cached or saved manually, so don't take up network cache
return client.newCachelessCallWithProgress(imageRequest(page), page)
.awaitSuccess()
}
/**
* Returns the request for getting the source image. Override only if it's needed to override
* the url, send different headers or request method like POST.

View file

@ -4,9 +4,9 @@ import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE
page.status = Page.State.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.doOnError { page.status = Page.State.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }

View file

@ -30,6 +30,7 @@ import android.view.WindowManager
import android.view.animation.AnimationUtils
import androidx.activity.OnBackPressedCallback
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
@ -64,18 +65,17 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn
import eu.kanade.tachiyomi.data.preference.toggle
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.main.SearchActivity
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.AddToLibraryFirst
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Error
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Success
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.AddToLibraryFirst
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Error
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel.SetAsCoverResult.Success
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
@ -104,12 +104,14 @@ import eu.kanade.tachiyomi.util.system.isInNightMode
import eu.kanade.tachiyomi.util.system.isLTR
import eu.kanade.tachiyomi.util.system.isTablet
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchNonCancellable
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.materialAlertDialog
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.rootWindowInsetsCompat
import eu.kanade.tachiyomi.util.system.spToPx
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.util.view.collapse
import eu.kanade.tachiyomi.util.view.compatToolTipText
import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsetsCompat
@ -123,17 +125,17 @@ import eu.kanade.tachiyomi.widget.doOnStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nucleus.factory.RequiresPresenter
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
@ -144,17 +146,13 @@ import kotlin.math.roundToInt
/**
* Activity containing the reader of Tachiyomi. This activity is mostly a container of the
* viewers, to which calls from the presenter or UI events are delegated.
* viewers, to which calls from the view model or UI events are delegated.
*/
@RequiresPresenter(ReaderPresenter::class)
class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
class ReaderActivity : BaseActivity<ReaderActivityBinding>() {
lateinit var binding: ReaderActivityBinding
val viewModel by viewModels<ReaderViewModel>()
/**
* Preferences helper.
*/
private val preferences by injectLazy<PreferencesHelper>()
val scope = lifecycleScope
/**
* Viewer used to display the pages (pager, webtoon, ...).
@ -263,7 +261,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
/**
* Called when the activity is created. Initializes the presenter and configuration.
* Called when the activity is created. Initializes the view model and configuration.
*/
override fun onCreate(savedInstanceState: Bundle?) {
// Setup shared element transitions
@ -300,7 +298,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
)
backPressedCallback = onBackPressedDispatcher.addCallback { backCallback() }
if (presenter.needsInit()) {
if (viewModel.needsInit()) {
fromUrl = handleIntentAction(intent)
if (!fromUrl) {
val manga = intent.extras!!.getLong("manga", -1)
@ -309,7 +307,15 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
finish()
return
}
presenter.init(manga, chapter)
lifecycleScope.launchNonCancellable {
val initResult = viewModel.init(manga, chapter)
if (!initResult.getOrDefault(false)) {
val exception = initResult.exceptionOrNull() ?: IllegalStateException("Unknown err")
withUIContext {
setInitialChapterError(exception)
}
}
}
} else {
binding.pleaseWait.isVisible = true
}
@ -334,6 +340,58 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
SecureActivityDelegate.setSecure(this)
}
reEnableBackPressedCallBack()
viewModel.state
.map { it.isLoadingAdjacentChapter }
.distinctUntilChanged()
.onEach(::setProgressDialog)
.launchIn(lifecycleScope)
viewModel.state
.map { it.manga }
.distinctUntilChanged()
.filterNotNull()
.onEach(::setManga)
.launchIn(lifecycleScope)
val viewerChapters = viewModel.state.value.viewerChapters
viewModel.state
.map { it.viewerChapters }
.distinctUntilChanged()
// .drop(if (viewerChapters != null) 1 else 0)
.filterNotNull()
.onEach(::setChapters)
.launchIn(lifecycleScope)
viewerChapters?.currChapter?.let { currChapter ->
currChapter.requestedPage = currChapter.chapter.last_page_read
}
viewModel.eventFlow
.onEach { event ->
when (event) {
ReaderViewModel.Event.ReloadMangaAndChapters -> {
viewModel.manga?.let(::setManga)
viewModel.state.value.viewerChapters?.let(::setChapters)
}
ReaderViewModel.Event.ReloadViewerChapters -> {
viewModel.state.value.viewerChapters?.let(::setChapters)
}
is ReaderViewModel.Event.SetOrientation -> {
setOrientation(event.orientation)
}
is ReaderViewModel.Event.SavedImage -> {
onSaveImageResult(event.result)
}
is ReaderViewModel.Event.ShareImage -> {
onShareImageResult(event.file, event.page)
}
is ReaderViewModel.Event.SetCoverResult -> {
onSetAsCoverResult(event.result)
}
}
}
.launchIn(lifecycleScope)
lifecycleScope.launchUI {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
WindowInfoTracker.getOrCreate(this@ReaderActivity).windowLayoutInfo(this@ReaderActivity)
@ -397,7 +455,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
}
if (!isChangingConfigurations) {
presenter.onSaveInstanceStateNonConfigurationChange()
viewModel.onSaveInstanceStateNonConfigurationChange()
} else {
viewModel.onSave()
}
super.onSaveInstanceState(outState)
}
@ -541,7 +601,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
private fun shiftDoublePages() {
(viewer as? PagerViewer)?.config?.let { config ->
config.shiftDoublePage = !config.shiftDoublePage
presenter.viewerChapters?.let {
viewModel.state.value.viewerChapters?.let {
(viewer as? PagerViewer)?.updateShifting()
(viewer as? PagerViewer)?.setChaptersDoubleShift(it)
invalidateOptionsMenu()
@ -570,13 +630,13 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
if (didTransistionFromChapter && visibleChapterRange.isNotEmpty() && MainActivity.chapterIdToExitTo !in visibleChapterRange) {
finish()
} else {
presenter.onBackPressed()
viewModel.onBackPressed()
super.finishAfterTransition()
}
}
override fun finish() {
presenter.onBackPressed()
viewModel.onBackPressed()
super.finish()
}
@ -670,7 +730,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
binding.toolbar.setOnClickListener {
presenter.manga?.id?.let { id ->
viewModel.manga?.id?.let { id ->
val intent = SearchActivity.openMangaIntent(this, id)
startActivity(intent)
}
@ -712,12 +772,12 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
setOnClickListener {
popupMenu(
items = OrientationType.values().map { it.flagValue to it.stringRes },
selectedItemId = presenter.manga?.orientationType
selectedItemId = viewModel.manga?.orientationType
?: preferences.defaultOrientationType().get(),
) {
val newOrientation = OrientationType.fromPreference(itemId)
presenter.setMangaOrientationType(newOrientation.flagValue)
viewModel.setMangaOrientationType(newOrientation.flagValue)
updateOrientationShortcut(newOrientation.flagValue)
}
@ -740,9 +800,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
readingMode.setOnClickListener { readingMode ->
readingMode.popupMenu(
items = ReadingModeType.values().map { it.flagValue to it.stringRes },
selectedItemId = presenter.manga?.readingModeType,
selectedItemId = viewModel.manga?.readingModeType,
) {
presenter.setMangaReadingMode(itemId)
viewModel.setMangaReadingMode(itemId)
}
}
}
@ -757,53 +817,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
binding.chaptersSheet.shiftPageButton.setOnClickListener {
shiftDoublePages()
}
binding.readerNav.leftChapter.setOnClickListener {
if (isLoading) {
return@setOnClickListener
}
isScrollingThroughPagesOrChapters = true
val result = if (viewer is R2LPagerViewer) {
presenter.loadNextChapter()
} else {
presenter.loadPreviousChapter()
}
if (result) {
binding.readerNav.leftChapter.isInvisible = true
binding.readerNav.leftProgress.isVisible = true
} else {
toast(
if (viewer is R2LPagerViewer) {
R.string.theres_no_next_chapter
} else {
R.string.theres_no_previous_chapter
},
)
}
}
binding.readerNav.rightChapter.setOnClickListener {
if (isLoading) {
return@setOnClickListener
}
isScrollingThroughPagesOrChapters = true
val result = if (viewer !is R2LPagerViewer) {
presenter.loadNextChapter()
} else {
presenter.loadPreviousChapter()
}
if (result) {
binding.readerNav.rightChapter.isInvisible = true
binding.readerNav.rightProgress.isVisible = true
} else {
toast(
if (viewer !is R2LPagerViewer) {
R.string.theres_no_next_chapter
} else {
R.string.theres_no_previous_chapter
},
)
}
}
binding.readerNav.leftChapter.setOnClickListener { loadAdjacentChapter(false) }
binding.readerNav.rightChapter.setOnClickListener { loadAdjacentChapter(true) }
binding.touchView.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
@ -874,7 +890,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
val pageNumber = (value + 1).roundToInt()
(viewer as? PagerViewer)?.let {
if (it.config.doublePages || it.config.splitPages) {
if (it.hasExtraPage(value.roundToInt(), presenter.getCurrentChapter())) {
if (it.hasExtraPage(value.roundToInt(), viewModel.getCurrentChapter())) {
val invertDoublePage = (viewer as? PagerViewer)?.config?.invertDoublePages ?: false
return@setLabelFormatter if (!binding.readerNav.pageSeekbar.isRTL.xor(invertDoublePage)) {
"$pageNumber-${pageNumber + 1}"
@ -951,6 +967,47 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
}
private fun loadAdjacentChapter(rightButton: Boolean) {
if (isLoading) {
return
}
isScrollingThroughPagesOrChapters = true
lifecycleScope.launch {
val getNextChapter = (viewer is R2LPagerViewer).xor(rightButton)
val adjChapter = viewModel.adjacentChapter(getNextChapter)
if (adjChapter != null) {
if (rightButton) {
binding.readerNav.rightChapter.isInvisible = true
binding.readerNav.rightProgress.isVisible = true
} else {
binding.readerNav.leftChapter.isInvisible = true
binding.readerNav.leftProgress.isVisible = true
}
loadChapter(adjChapter)
} else {
toast(
if (getNextChapter) {
R.string.theres_no_next_chapter
} else {
R.string.theres_no_previous_chapter
},
)
}
}
}
suspend fun loadChapter(chapter: Chapter) {
loadChapter(ReaderChapter(chapter))
}
private suspend fun loadChapter(chapter: ReaderChapter) {
val lastPage = viewModel.loadChapter(chapter) ?: return
launchUI {
moveToPageIndex(lastPage, false, chapterChange = true)
}
refreshChapters()
}
fun setNavColor(insets: WindowInsetsCompat) {
sheetManageNavColor = when {
isSplitScreen -> {
@ -1079,13 +1136,13 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
/**
* Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer
* Called from the view model when a manga is ready. Used to instantiate the appropriate viewer
* and the binding.toolbar title.
*/
fun setManga(manga: Manga) {
val prevViewer = viewer
val noDefault = manga.viewer_flags == -1
val mangaViewer = presenter.getMangaReadingMode()
val mangaViewer = viewModel.getMangaReadingMode()
val newViewer = when (mangaViewer) {
ReadingModeType.LEFT_TO_RIGHT.flagValue -> L2RPagerViewer(this)
ReadingModeType.VERTICAL.flagValue -> VerticalPagerViewer(this)
@ -1094,8 +1151,8 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
else -> R2LPagerViewer(this)
}
if (noDefault && presenter.manga?.readingModeType!! > 0 &&
presenter.manga?.readingModeType!! != preferences.defaultReadingMode()
if (noDefault && viewModel.manga?.readingModeType!! > 0 &&
viewModel.manga?.readingModeType!! != preferences.defaultReadingMode()
) {
snackbar = binding.readerLayout.snack(
getString(
@ -1112,7 +1169,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
4000,
) {
setAction(R.string.use_default) {
presenter.setMangaReadingMode(0)
viewModel.setMangaReadingMode(0)
}
}
}
@ -1122,10 +1179,10 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
) {
// Wait until transition is complete to avoid crash on API 26
window.sharedElementEnterTransition.addListener(
onEnd = { setOrientation(presenter.getMangaOrientationType()) },
onEnd = { setOrientation(viewModel.getMangaOrientationType()) },
)
} else {
setOrientation(presenter.getMangaOrientationType())
setOrientation(viewModel.getMangaOrientationType())
}
// Destroy previous viewer if there was one
@ -1161,7 +1218,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
},
)
binding.toolbar.title = manga.title
supportActionBar?.title = manga.title
binding.readerNav.pageSeekbar.isRTL = newViewer is R2LPagerViewer
@ -1170,19 +1227,19 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
invalidateOptionsMenu()
updateCropBordersShortcut()
updateBottomShortcuts()
val viewerMode = ReadingModeType.fromPreference(presenter?.manga?.readingModeType ?: 0)
val viewerMode = ReadingModeType.fromPreference(viewModel.state.value.manga?.readingModeType ?: 0)
binding.chaptersSheet.readingMode.setImageResource(viewerMode.iconRes)
startPostponedEnterTransition()
}
override fun onPause() {
presenter.saveCurrentChapterReadingProgress()
viewModel.saveCurrentChapterReadingProgress()
super.onPause()
}
override fun onResume() {
super.onResume()
presenter.setReadStartTime()
viewModel.setReadStartTime()
}
fun reloadChapters(doublePages: Boolean, force: Boolean = false) {
@ -1196,7 +1253,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
pViewer.config.splitPages = preferences.automaticSplitsPage().get() && !pViewer.config.doublePages
}
}
val currentChapter = presenter.getCurrentChapter()
val currentChapter = viewModel.getCurrentChapter()
if (doublePages) {
// If we're moving from singe to double, we want the current page to be the first page
pViewer.config.shiftDoublePage = (
@ -1207,14 +1264,14 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
)
) % 2 != 0
}
presenter.viewerChapters?.let {
viewModel.state.value.viewerChapters?.let {
pViewer.setChaptersDoubleShift(it)
}
invalidateOptionsMenu()
}
/**
* Called from the presenter whenever a new [viewerChapters] have been set. It delegates the
* Called from the view model whenever a new [viewerChapters] have been set. It delegates the
* method to the current viewer, but also set the subtitle on the binding.toolbar.
*/
fun setChapters(viewerChapters: ViewerChapters) {
@ -1246,7 +1303,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
viewer?.setChapters(viewerChapters)
intentPageNumber?.let { moveToPageIndex(it) }
intentPageNumber = null
binding.toolbar.subtitle = if (presenter.manga!!.hideChapterTitle(preferences)) {
binding.toolbar.subtitle = if (viewModel.manga!!.hideChapterTitle(preferences)) {
val number = decimalFormat.format(viewerChapters.currChapter.chapter.chapter_number.toDouble())
getString(R.string.chapter_, number)
} else {
@ -1268,7 +1325,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
/**
* Called from the presenter if the initial load couldn't load the pages of the chapter. In
* Called from the view model if the initial load couldn't load the pages of the chapter. In
* this case the activity is closed and a toast is shown to the user.
*/
fun setInitialChapterError(error: Throwable) {
@ -1278,7 +1335,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
/**
* Called from the presenter whenever it's loading the next or previous chapter. It shows or
* Called from the view model whenever it's loading the next or previous chapter. It shows or
* dismisses a non-cancellable dialog to prevent user interaction according to the value of
* [show]. This is only used when the next/previous buttons on the binding.toolbar are clicked; the
* other cases are handled with chapter transitions on the viewers and chapter preloading.
@ -1308,7 +1365,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
*/
fun moveToPageIndex(index: Int, animated: Boolean = true, chapterChange: Boolean = false) {
val viewer = viewer ?: return
val currentChapter = presenter.getCurrentChapter() ?: return
val currentChapter = viewModel.getCurrentChapter() ?: return
val page = currentChapter.pages?.getOrNull(index) ?: return
viewer.moveToPage(page, animated)
if (chapterChange) {
@ -1322,11 +1379,11 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
/**
* Called from the viewer whenever a [page] is marked as active. It updates the values of the
* bottom menu and delegates the change to the presenter.
* bottom menu and delegates the change to the view model.
*/
@SuppressLint("SetTextI18n")
fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) {
presenter.onPageSelected(page, hasExtraPage)
viewModel.onPageSelected(page, hasExtraPage)
val pages = page.chapter.pages ?: return
val currentPage = if (hasExtraPage) {
@ -1449,9 +1506,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
Color.BLACK
}
if (item == 6) {
presenter.shareImages(page, secondPage, isLTR, bg)
viewModel.shareImages(page, secondPage, isLTR, bg)
} else {
presenter.saveImages(page, secondPage, isLTR, bg)
viewModel.saveImages(page, secondPage, isLTR, bg)
}
}
}
@ -1468,7 +1525,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
* the viewer is reaching the beginning or end of a chapter or the transition page is active.
*/
fun requestPreloadChapter(chapter: ReaderChapter) {
presenter.preloadChapter(chapter)
lifecycleScope.launch {
viewModel.preloadChapter(chapter)
}
}
/**
@ -1489,15 +1548,15 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
/**
* Called from the page sheet. It delegates the call to the presenter to do some IO, which
* Called from the page sheet. It delegates the call to the view model to do some IO, which
* will call [onShareImageResult] with the path the image was saved on when it's ready.
*/
private fun shareImage(page: ReaderPage) {
presenter.shareImage(page)
viewModel.shareImage(page)
}
private fun showSetCoverPrompt(page: ReaderPage) {
if (page.status != Page.READY) return
if (page.status != Page.State.READY) return
materialAlertDialog()
.setMessage(R.string.use_image_as_cover)
@ -1509,11 +1568,11 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
/**
* Called from the presenter when a page is ready to be shared. It shows Android's default
* Called from the view model when a page is ready to be shared. It shows Android's default
* sharing tool.
*/
fun onShareImageResult(file: File, page: ReaderPage, secondPage: ReaderPage? = null) {
val manga = presenter.manga ?: return
val manga = viewModel.manga ?: return
val chapter = page.chapter.chapter
val decimalFormat =
@ -1544,28 +1603,28 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
val chapterUrl = presenter.getChapterUrl() ?: return
val chapterUrl = viewModel.getChapterUrl() ?: return
outContent.webUri = Uri.parse(chapterUrl)
}
/**
* Called from the page sheet. It delegates saving the image of the given [page] on external
* storage to the presenter.
* storage to the viewModel.
*/
private fun saveImage(page: ReaderPage) {
presenter.saveImage(page)
viewModel.saveImage(page)
}
/**
* Called from the presenter when a page is saved or fails. It shows a message or logs the
* Called from the view model when a page is saved or fails. It shows a message or logs the
* event depending on the [result].
*/
fun onSaveImageResult(result: ReaderPresenter.SaveImageResult) {
fun onSaveImageResult(result: ReaderViewModel.SaveImageResult) {
when (result) {
is ReaderPresenter.SaveImageResult.Success -> {
is ReaderViewModel.SaveImageResult.Success -> {
toast(R.string.picture_saved)
}
is ReaderPresenter.SaveImageResult.Error -> {
is ReaderViewModel.SaveImageResult.Error -> {
Timber.e(result.error)
}
}
@ -1573,17 +1632,17 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
/**
* Called from the page sheet. It delegates setting the image of the given [page] as the
* cover to the presenter.
* cover to the viewModel.
*/
private fun setAsCover(page: ReaderPage) {
presenter.setAsCover(page)
viewModel.setAsCover(page)
}
/**
* Called from the presenter when a page is set as cover or fails. It shows a different message
* Called from the view model when a page is set as cover or fails. It shows a different message
* depending on the [result].
*/
fun onSetAsCoverResult(result: ReaderPresenter.SetAsCoverResult) {
fun onSetAsCoverResult(result: ReaderViewModel.SetAsCoverResult) {
toast(
when (result) {
Success -> R.string.cover_updated
@ -1662,7 +1721,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
private fun handleIntentAction(intent: Intent): Boolean {
val uri = intent.data ?: return false
if (!presenter.canLoadUrl(uri)) {
if (!viewModel.canLoadUrl(uri)) {
openInBrowser(intent.data!!.toString(), true)
finishAfterTransition()
return true
@ -1670,8 +1729,8 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
setMenuVisibility(visible = false, animate = true)
scope.launch(Dispatchers.IO) {
try {
intentPageNumber = presenter.intentPageNumber(uri)
presenter.loadChapterURL(uri)
intentPageNumber = viewModel.intentPageNumber(uri)
viewModel.loadChapterURL(uri)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
setInitialChapterError(e)
@ -1682,14 +1741,14 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
private fun openMangaInBrowser() {
val source = presenter.getSource() ?: return
val chapterUrl = presenter.getChapterUrl() ?: return
val source = viewModel.getSource() ?: return
val chapterUrl = viewModel.getChapterUrl() ?: return
val intent = WebViewActivity.newIntent(
applicationContext,
source.id,
chapterUrl,
presenter.manga!!.title,
viewModel.manga!!.title,
)
startActivity(intent)
}
@ -1719,7 +1778,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
.drop(1)
.onEach {
delay(250)
setOrientation(presenter.getMangaOrientationType())
setOrientation(viewModel.getMangaOrientationType())
}
.launchIn(scope)

View file

@ -3,10 +3,11 @@ package eu.kanade.tachiyomi.ui.reader
import android.app.Application
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import androidx.annotation.ColorInt
import com.jakewharton.rxrelay.BehaviorRelay
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
@ -14,6 +15,7 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
@ -23,7 +25,6 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
import eu.kanade.tachiyomi.ui.reader.chapter.ReaderChapterItem
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
@ -43,43 +44,61 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.executeOnIO
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.system.launchNonCancellable
import eu.kanade.tachiyomi.util.system.localeContext
import eu.kanade.tachiyomi.util.system.withIOContext
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import rx.Completable
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.util.Date
import java.util.concurrent.TimeUnit
import java.util.concurrent.CancellationException
/**
* Presenter used by the activity to perform background operations.
*/
class ReaderPresenter(
class ReaderViewModel(
private val savedState: SavedStateHandle = SavedStateHandle(),
private val db: DatabaseHelper = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(),
private val chapterFilter: ChapterFilter = Injekt.get(),
) : BasePresenter<ReaderActivity>() {
) : ViewModel() {
private val mutableState = MutableStateFlow(State())
val state = mutableState.asStateFlow()
private val downloadProvider = DownloadProvider(preferences.context)
private val eventChannel = Channel<Event>()
val eventFlow = eventChannel.receiveAsFlow()
/**
* The manga loaded in the reader. It can be null when instantiated for a short time.
*/
var manga: Manga? = null
private set
val manga: Manga?
get() = state.value.manga
val source: Source?
get() = manga?.source?.let { sourceManager.getOrStub(it) }
@ -87,7 +106,11 @@ class ReaderPresenter(
/**
* The chapter id of the currently loaded chapter. Used to restore from process kill.
*/
private var chapterId = -1L
private var chapterId = savedState.get<Long>("chapter_id") ?: -1L
set(value) {
savedState["chapter_id"] = value
field = value
}
/**
* The chapter loader for the loaded manga. It'll be null until [manga] is set.
@ -99,25 +122,11 @@ class ReaderPresenter(
*/
private var chapterReadStartTime: Long? = null
/**
* Subscription to prevent setting chapters as active from multiple threads.
*/
private var activeChapterSubscription: Subscription? = null
/**
* Relay for currently active viewer chapters.
*/
private val viewerChaptersRelay = BehaviorRelay.create<ViewerChapters>()
val viewerChapters: ViewerChapters?
get() = viewerChaptersRelay.value
/**
* Relay used when loading prev/next chapter needed to lock the UI (with a dialog).
*/
private val isLoadingAdjacentChapterRelay = BehaviorRelay.create<Boolean>()
private var finished = false
private var chapterDownload: Download? = null
private var chapterToDownload: Download? = null
/**
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
@ -147,27 +156,26 @@ class ReaderPresenter(
hasTrackers = tracks.size > 0
}
/**
* Called when the presenter is created. It retrieves the saved active chapter if the process
* was restored.
*/
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
if (savedState != null) {
chapterId = savedState.getLong(::chapterId.name, -1)
}
init {
// To save state
state.map { it.viewerChapters?.currChapter }
.distinctUntilChanged()
.filterNotNull()
.onEach { currentChapter ->
chapterId = currentChapter.chapter.id!!
currentChapter.requestedPage = currentChapter.chapter.last_page_read
}
.launchIn(viewModelScope)
}
/**
* Called when the presenter instance is being saved. It saves the currently active chapter
* id and the last page read.
*/
override fun onSave(state: Bundle) {
super.onSave(state)
fun onSave() {
val currentChapter = getCurrentChapter()
if (currentChapter != null) {
currentChapter.requestedPage = currentChapter.chapter.last_page_read
state.putLong(::chapterId.name, currentChapter.chapter.id!!)
}
}
@ -179,11 +187,11 @@ class ReaderPresenter(
if (finished) return
finished = true
deletePendingChapters()
val currentChapters = viewerChaptersRelay.value
val currentChapters = state.value.viewerChapters
if (currentChapters != null) {
currentChapters.unref()
saveReadingProgress(currentChapters.currChapter)
chapterDownload?.let {
chapterToDownload?.let {
downloadManager.addDownloadsToStartOfQueue(listOf(it))
}
}
@ -209,19 +217,40 @@ class ReaderPresenter(
* Initializes this presenter with the given [mangaId] and [initialChapterId]. This method will
* fetch the manga from the database and initialize the initial chapter.
*/
fun init(mangaId: Long, initialChapterId: Long) {
if (!needsInit()) return
suspend fun init(mangaId: Long, initialChapterId: Long): Result<Boolean> {
if (!needsInit()) return Result.success(true)
return withIOContext {
try {
val manga = db.getManga(mangaId).executeAsBlocking()
if (manga != null) {
mutableState.update { it.copy(manga = manga) }
if (chapterId == -1L) chapterId = initialChapterId
db.getManga(mangaId).asRxObservable()
.first()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { init(it, initialChapterId) }
.subscribeFirst(
{ _, _ ->
// Ignore onNext event
},
ReaderActivity::setInitialChapterError,
)
checkTrackers(manga)
NotificationReceiver.dismissNotification(
preferences.context,
manga.id!!.hashCode(),
Notifications.ID_NEW_CHAPTERS,
)
val source = sourceManager.getOrStub(manga.source)
val context = Injekt.get<Application>()
loader = ChapterLoader(context, downloadManager, downloadProvider, manga, source)
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
Result.success(true)
} else {
// Unlikely but okay
Result.success(false)
}
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
Result.failure(e)
}
}
}
suspend fun getChapters(): List<ReaderChapterItem> {
@ -237,7 +266,7 @@ class ReaderPresenter(
ReaderChapterItem(
it,
manga,
it.id == getCurrentChapter()?.chapter?.id ?: chapterId,
it.id == (getCurrentChapter()?.chapter?.id ?: chapterId),
)
}
}
@ -245,82 +274,6 @@ class ReaderPresenter(
return chapterItems
}
/**
* Initializes this presenter with the given [manga] and [initialChapterId]. This method will
* set the chapter loader, view subscriptions and trigger an initial load.
*/
private fun init(manga: Manga, initialChapterId: Long) {
if (!needsInit()) return
this.manga = manga
if (chapterId == -1L) chapterId = initialChapterId
checkTrackers(manga)
NotificationReceiver.dismissNotification(
preferences.context,
manga.id!!.hashCode(),
Notifications.ID_NEW_CHAPTERS,
)
val source = sourceManager.getOrStub(manga.source)
loader = ChapterLoader(downloadManager, manga, source)
Observable.just(manga).subscribeLatestCache(ReaderActivity::setManga)
viewerChaptersRelay.subscribeLatestCache(ReaderActivity::setChapters)
isLoadingAdjacentChapterRelay.subscribeLatestCache(ReaderActivity::setProgressDialog)
// Read chapterList from an io thread because it's retrieved lazily and would block main.
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = Observable
.fromCallable { chapterList.first { chapterId == it.chapter.id } }
.flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ _, _ ->
// Ignore onNext event
},
ReaderActivity::setInitialChapterError,
)
}
/**
* Returns an observable that loads the given [chapter] with this [loader]. This observable
* handles main thread synchronization and updating the currently active chapters on
* [viewerChaptersRelay], however callers must ensure there won't be more than one
* subscription active by unsubscribing any existing [activeChapterSubscription] before.
* Callers must also handle the onError event.
*/
private fun getLoadObservable(
loader: ChapterLoader,
chapter: ReaderChapter,
): Observable<ViewerChapters> {
return loader.loadChapter(chapter)
.andThen(
Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter)
ViewerChapters(
chapter,
chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1),
)
},
)
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters ->
val oldChapters = viewerChaptersRelay.value
// Add new references first to avoid unnecessary recycling
newChapters.ref()
oldChapters?.unref()
chapterDownload = deleteChapterFromDownloadQueue(newChapters.currChapter)
viewerChaptersRelay.call(newChapters)
}
}
fun canLoadUrl(uri: Uri): Boolean {
val host = uri.host ?: return false
val delegatedSource = sourceManager.getDelegatedSource(host) ?: return false
@ -338,7 +291,7 @@ class ReaderPresenter(
@Suppress("DEPRECATION")
suspend fun loadChapterURL(url: Uri) {
val host = url.host ?: return
val context = view ?: preferences.context
val context = Injekt.get<Application>()
val delegatedSource = sourceManager.getDelegatedSource(host) ?: error(
context.getString(R.string.source_not_installed),
)
@ -357,14 +310,8 @@ class ReaderPresenter(
httpSource?.baseUrl?.contains(domainName) == true
}
}
if (dbChapter?.manga_id != null) {
val dbManga = db.getManga(dbChapter.manga_id!!).executeOnIO()
if (dbManga != null) {
withContext(Dispatchers.Main) {
init(dbManga, dbChapter.id!!)
}
return
}
if (dbChapter?.manga_id?.let { init(it, dbChapter.id!!).isSuccess } == true) {
return
}
}
val info = delegatedSource.fetchMangaFromChapterUrl(url)
@ -377,7 +324,7 @@ class ReaderPresenter(
db.getChapters(manga).executeOnIO().find { it.url == chapter.url }?.id
if (matchingChapterId != null) {
withContext(Dispatchers.Main) {
this@ReaderPresenter.init(manga, matchingChapterId)
this@ReaderViewModel.init(manga.id!!, matchingChapterId)
}
} else {
val chapterId: Long
@ -397,7 +344,7 @@ class ReaderPresenter(
)
}
withContext(Dispatchers.Main) {
init(manga, chapterId)
init(manga.id!!, chapterId)
}
}
} else {
@ -409,42 +356,86 @@ class ReaderPresenter(
* Called when the user changed to the given [chapter] when changing pages from the viewer.
* It's used only to set this chapter as active.
*/
private fun loadNewChapter(chapter: ReaderChapter) {
private suspend fun loadNewChapter(chapter: ReaderChapter) {
val loader = loader ?: return
Timber.d("Loading ${chapter.chapter.url}")
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter)
.toCompletable()
.onErrorComplete()
.subscribe()
.also(::add)
// activeChapterSubscription?.unsubscribe()
// activeChapterSubscription = getLoadObservable(loader, chapter)
// .toCompletable()
// .onErrorComplete()
// .subscribe()
// .also(::add)
withIOContext {
try {
loadChapter(loader, chapter)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
Timber.e(e)
}
}
}
fun loadChapter(chapter: Chapter) {
val loader = loader ?: return
/**
* Loads the given [chapter] with this [loader] and updates the currently active chapters.
* Callers must handle errors.
*/
private suspend fun loadChapter(
loader: ChapterLoader,
chapter: ReaderChapter,
): ViewerChapters {
loader.loadChapter(chapter)
viewerChaptersRelay.value?.currChapter?.let(::saveReadingProgress)
val chapterPos = chapterList.indexOf(chapter)
// chapter.requestedPage = chapter.chapter.last_page_read
val newChapters = ViewerChapters(
chapter,
chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1),
)
Timber.d("Loading ${chapter.url}")
withUIContext {
mutableState.update {
// Add new references first to avoid unnecessary recycling
newChapters.ref()
it.viewerChapters?.unref()
activeChapterSubscription?.unsubscribe()
val lastPage = if (chapter.pages_left <= 1) 0 else chapter.last_page_read
activeChapterSubscription = getLoadObservable(loader, ReaderChapter(chapter))
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
.subscribeFirst(
{ view, _ ->
scope.launchUI {
view.moveToPageIndex(lastPage, false, chapterChange = true)
}
view.refreshChapters()
},
{ _, _ ->
// Ignore onError event, viewers handle that state
},
)
chapterToDownload = deleteChapterFromDownloadQueue(newChapters.currChapter)
it.copy(viewerChapters = newChapters)
}
}
return newChapters
}
/**
* Called when the user is going to load the prev/next chapter through the menu button.
*/
suspend fun loadChapter(chapter: ReaderChapter): Int? {
val loader = loader ?: return -1
Timber.d("Loading adjacent ${chapter.chapter.url}")
var lastPage: Int? = null
mutableState.update { it.copy(isLoadingAdjacentChapter = true) }
try {
withIOContext {
loadChapter(loader, chapter)
lastPage =
if (chapter.chapter.pages_left <= 1) 0 else chapter.chapter.last_page_read
withUIContext {
}
}
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
Timber.e(e)
} finally {
mutableState.update { it.copy(isLoadingAdjacentChapter = false) }
}
return lastPage
}
fun toggleBookmark(chapter: Chapter) {
@ -456,7 +447,7 @@ class ReaderPresenter(
* Called when the viewers decide it's a good time to preload a [chapter] and improve the UX so
* that the user doesn't have to wait too long to continue reading.
*/
private fun preload(chapter: ReaderChapter) {
private suspend fun preload(chapter: ReaderChapter) {
if (chapter.pageLoader is HttpPageLoader) {
val manga = manga ?: return
val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga)
@ -472,32 +463,22 @@ class ReaderPresenter(
Timber.d("Preloading ${chapter.chapter.url}")
val loader = loader ?: return
loader.loadChapter(chapter)
.observeOn(AndroidSchedulers.mainThread())
// Update current chapters whenever a chapter is preloaded
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
.onErrorComplete()
.subscribe()
.also(::add)
withIOContext {
try {
loader.loadChapter(chapter)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
return@withIOContext
}
eventChannel.trySend(Event.ReloadViewerChapters)
}
}
/**
* Called from the activity to load and set the next chapter as active.
*/
fun loadNextChapter(): Boolean {
val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return false
loadChapter(nextChapter.chapter)
return true
}
/**
* Called from the activity to load and set the previous chapter as active.
*/
fun loadPreviousChapter(): Boolean {
val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return false
loadChapter(prevChapter.chapter)
return true
fun adjacentChapter(next: Boolean): ReaderChapter? {
val chapters = state.value.viewerChapters
return if (next) chapters?.nextChapter else chapters?.prevChapter
}
/**
@ -506,7 +487,7 @@ class ReaderPresenter(
* [page]'s chapter is different from the currently active.
*/
fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) {
val currentChapters = viewerChaptersRelay.value ?: return
val currentChapters = state.value.viewerChapters ?: return
val selectedChapter = page.chapter
@ -531,7 +512,7 @@ class ReaderPresenter(
Timber.d("Setting ${selectedChapter.chapter.url} as active")
saveReadingProgress(currentChapters.currChapter)
setReadStartTime()
loadNewChapter(selectedChapter)
scope.launch { loadNewChapter(selectedChapter) }
}
val pages = page.chapter.pages ?: return
val inDownloadRange = page.number.toDouble() / pages.size > 0.2
@ -543,7 +524,7 @@ class ReaderPresenter(
private fun downloadNextChapters() {
val manga = manga ?: return
if (getCurrentChapter()?.pageLoader !is DownloadPageLoader) return
val nextChapter = viewerChaptersRelay.value?.nextChapter?.chapter ?: return
val nextChapter = state.value.viewerChapters?.nextChapter?.chapter ?: return
val chaptersNumberToDownload = preferences.autoDownloadWhileReading().get()
if (chaptersNumberToDownload == 0 || !manga.favorite) return
val isNextChapterDownloaded = downloadManager.isChapterDownloaded(nextChapter, manga)
@ -596,10 +577,10 @@ class ReaderPresenter(
val removeAfterReadSlots = preferences.removeAfterReadSlots()
val chapterToDelete = chapterList.getOrNull(currentChapterPosition - removeAfterReadSlots)
if (removeAfterReadSlots != 0 && chapterDownload != null) {
downloadManager.addDownloadsToStartOfQueue(listOf(chapterDownload!!))
if (removeAfterReadSlots != 0 && chapterToDownload != null) {
downloadManager.addDownloadsToStartOfQueue(listOf(chapterToDownload!!))
} else {
chapterDownload = null
chapterToDownload = null
}
// Check if deleting option is enabled and chapter exists
if (removeAfterReadSlots != -1 && chapterToDelete != null) {
@ -654,7 +635,7 @@ class ReaderPresenter(
/**
* Called from the activity to preload the given [chapter].
*/
fun preloadChapter(chapter: ReaderChapter) {
suspend fun preloadChapter(chapter: ReaderChapter) {
preload(chapter)
}
@ -662,7 +643,7 @@ class ReaderPresenter(
* Returns the currently active chapter.
*/
fun getCurrentChapter(): ReaderChapter? {
return viewerChaptersRelay.value?.currChapter
return state.value.viewerChapters?.currChapter
}
fun getChapterUrl(): String? {
@ -702,22 +683,25 @@ class ReaderPresenter(
*/
fun setMangaReadingMode(readingModeType: Int) {
val manga = manga ?: return
manga.readingModeType = readingModeType
db.updateViewerFlags(manga).executeAsBlocking()
Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
val currChapters = viewerChaptersRelay.value
if (currChapters != null) {
// Save current page
val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read
runBlocking(Dispatchers.IO) {
manga.readingModeType = readingModeType
db.updateViewerFlags(manga).executeAsBlocking()
val currChapters = state.value.viewerChapters
if (currChapters != null) {
// Save current page
val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read
// Emit manga and chapters to the new viewer
view.setManga(manga)
view.setChapters(currChapters)
mutableState.update {
it.copy(
manga = db.getManga(manga.id!!).executeAsBlocking(),
viewerChapters = currChapters,
)
}
},)
eventChannel.send(Event.ReloadMangaAndChapters)
}
}
}
/**
@ -736,18 +720,26 @@ class ReaderPresenter(
*/
fun setMangaOrientationType(rotationType: Int) {
val manga = manga ?: return
manga.orientationType = rotationType
this.manga?.orientationType = rotationType
db.updateViewerFlags(manga).executeAsBlocking()
Timber.i("Manga orientation is ${manga.orientationType}")
Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
val currChapters = viewerChaptersRelay.value
if (currChapters != null) {
view.setOrientation(getMangaOrientationType())
viewModelScope.launchIO {
// delay(250)
db.updateViewerFlags(manga).executeAsBlocking()
val currChapters = state.value.viewerChapters
if (currChapters != null) {
mutableState.update {
it.copy(
manga = db.getManga(manga.id!!).executeAsBlocking(),
viewerChapters = currChapters,
)
}
},)
eventChannel.send(Event.SetOrientation(getMangaOrientationType()))
eventChannel.send(Event.ReloadViewerChapters)
}
}
}
/**
@ -776,7 +768,7 @@ class ReaderPresenter(
}
/**
* Saves the image of this [page] in the given [directory] and returns the file location.
* Saves the image of [page1] and [page2] in the given [directory] and returns the file location.
*/
private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: File, manga: Manga): File {
val stream1 = page1.stream!!
@ -814,7 +806,7 @@ class ReaderPresenter(
* There's also a notification to allow sharing the image somewhere else or deleting it.
*/
fun saveImage(page: ReaderPage) {
if (page.status != Page.READY) return
if (page.status != Page.State.READY) return
val manga = manga ?: return
val context = Injekt.get<Application>()
@ -832,24 +824,23 @@ class ReaderPresenter(
}
// Copy file in background.
Observable.fromCallable { saveImage(page, destDir, manga) }
.doOnNext { file ->
viewModelScope.launchNonCancellable {
try {
val file = saveImage(page, destDir, manga)
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
eventChannel.send(Event.SavedImage(SaveImageResult.Success(file)))
} catch (e: Exception) {
notifier.onError(e.message)
eventChannel.send(Event.SavedImage(SaveImageResult.Error(e)))
}
.doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) },
)
}
}
fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
scope.launch {
if (firstPage.status != Page.READY) return@launch
if (secondPage.status != Page.READY) return@launch
if (firstPage.status != Page.State.READY) return@launch
if (secondPage.status != Page.State.READY) return@launch
val manga = manga ?: return@launch
val context = Injekt.get<Application>()
@ -870,9 +861,9 @@ class ReaderPresenter(
val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga)
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
withUIContext { view?.onSaveImageResult(SaveImageResult.Success(file)) }
eventChannel.send(Event.SavedImage(SaveImageResult.Success(file)))
} catch (e: Exception) {
withUIContext { view?.onSaveImageResult(SaveImageResult.Error(e)) }
eventChannel.send(Event.SavedImage(SaveImageResult.Error(e)))
}
}
}
@ -885,26 +876,23 @@ class ReaderPresenter(
* image will be kept so it won't be taking lots of internal disk space.
*/
fun shareImage(page: ReaderPage) {
if (page.status != Page.READY) return
if (page.status != Page.State.READY) return
val manga = manga ?: return
val context = Injekt.get<Application>()
val destDir = File(context.cacheDir, "shared_image")
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(page, destDir, manga) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onShareImageResult(file, page) },
{ _, _ -> },
)
viewModelScope.launchNonCancellable {
destDir.deleteRecursively() // Keep only the last shared file
val file = saveImage(page, destDir, manga)
eventChannel.send(Event.ShareImage(file, page))
}
}
fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
scope.launch {
if (firstPage.status != Page.READY) return@launch
if (secondPage.status != Page.READY) return@launch
if (firstPage.status != Page.State.READY) return@launch
if (secondPage.status != Page.State.READY) return@launch
val manga = manga ?: return@launch
val context = Injekt.get<Application>()
@ -912,10 +900,8 @@ class ReaderPresenter(
destDir.deleteRecursively()
try {
val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga)
withUIContext {
view?.onShareImageResult(file, firstPage, secondPage)
}
} catch (e: Exception) {
eventChannel.send(Event.ShareImage(file, firstPage, secondPage))
} catch (_: Exception) {
}
}
}
@ -924,12 +910,12 @@ class ReaderPresenter(
* Sets the image of this [page] as cover and notifies the UI of the result.
*/
fun setAsCover(page: ReaderPage) {
if (page.status != Page.READY) return
if (page.status != Page.State.READY) return
val manga = manga ?: return
val stream = page.stream ?: return
Observable
.fromCallable {
viewModelScope.launchNonCancellable {
val result = try {
if (manga.isLocal()) {
val context = Injekt.get<Application>()
coverCache.deleteFromCache(manga)
@ -944,13 +930,11 @@ class ReaderPresenter(
SetAsCoverResult.AddToLibraryFirst
}
}
} catch (e: Exception) {
SetAsCoverResult.Error
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, result -> view.onSetAsCoverResult(result) },
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) },
)
eventChannel.send(Event.SetCoverResult(result))
}
}
/**
@ -1008,4 +992,21 @@ class ReaderPresenter(
.subscribeOn(Schedulers.io())
.subscribe()
}
data class State(
val manga: Manga? = null,
val viewerChapters: ViewerChapters? = null,
val isLoadingAdjacentChapter: Boolean = false,
val lastPage: Int? = null,
)
sealed class Event {
object ReloadViewerChapters : Event()
object ReloadMangaAndChapters : Event()
data class SetOrientation(val orientation: Int) : Event()
data class SetCoverResult(val result: SetAsCoverResult) : Event()
data class SavedImage(val result: SaveImageResult) : Event()
data class ShareImage(val file: File, val page: ReaderPage, val extraPage: ReaderPage? = null) : Event()
}
}

View file

@ -10,6 +10,7 @@ import android.widget.LinearLayout
import androidx.core.graphics.ColorUtils
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -19,7 +20,7 @@ import com.mikepenz.fastadapter.listeners.ClickEventHook
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.ReaderChaptersSheetBinding
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter
import eu.kanade.tachiyomi.ui.reader.ReaderViewModel
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.isInNightMode
@ -28,6 +29,7 @@ import eu.kanade.tachiyomi.util.view.collapse
import eu.kanade.tachiyomi.util.view.expand
import eu.kanade.tachiyomi.util.view.isCollapsed
import eu.kanade.tachiyomi.util.view.isExpanded
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@ -37,7 +39,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
LinearLayout(context, attrs) {
var sheetBehavior: BottomSheetBehavior<View>? = null
lateinit var presenter: ReaderPresenter
lateinit var viewModel: ReaderViewModel
var adapter: FastAdapter<ReaderChapterItem>? = null
private val itemAdapter = ItemAdapter<ReaderChapterItem>()
var selectedChapterId = -1L
@ -51,7 +53,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
}
fun setup(activity: ReaderActivity) {
presenter = activity.presenter
viewModel = activity.viewModel
val fullPrimary = activity.getResourceColor(R.attr.colorSurface)
val primary = ColorUtils.setAlphaComponent(fullPrimary, 200)
@ -78,7 +80,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
binding.chapterRecycler.alpha = if (sheetBehavior.isExpanded()) 1f else 0f
binding.chapterRecycler.isClickable = sheetBehavior.isExpanded()
binding.chapterRecycler.isFocusable = sheetBehavior.isExpanded()
val canShowNav = presenter.getCurrentChapter()?.pages?.size ?: 1 > 1
val canShowNav = viewModel.getCurrentChapter()?.pages?.size ?: 1 > 1
if (canShowNav) {
activity.binding.readerNav.root.isVisible = sheetBehavior.isCollapsed()
}
@ -87,6 +89,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
sheetBehavior?.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, progress: Float) {
binding.root.isVisible = true
binding.pill.alpha = (1 - max(0f, progress)) * 0.25f
val trueProgress = max(progress, 0f)
activity.binding.readerNav.root.alpha = (1 - abs(progress)).coerceIn(0f, 1f)
@ -100,11 +103,11 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
}
override fun onStateChanged(p0: View, state: Int) {
val canShowNav = (presenter.getCurrentChapter()?.pages?.size ?: 1) > 1
val canShowNav = (viewModel.getCurrentChapter()?.pages?.size ?: 1) > 1
if (state == BottomSheetBehavior.STATE_COLLAPSED) {
sheetBehavior?.isHideable = false
(binding.chapterRecycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
adapter?.getPosition(presenter.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0,
adapter?.getPosition(viewModel.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0,
binding.chapterRecycler.height / 2 - 30.dpToPx,
)
if (canShowNav) {
@ -130,6 +133,9 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
if (canShowNav) {
activity.binding.readerNav.root.isInvisible = true
}
binding.root.isInvisible = true
} else if (binding.root.isVisible) {
binding.root.isVisible = true
}
binding.chapterRecycler.isClickable = state == BottomSheetBehavior.STATE_EXPANDED
binding.chapterRecycler.isFocusable = state == BottomSheetBehavior.STATE_EXPANDED
@ -144,16 +150,18 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
if (!sheetBehavior.isExpanded() || activity.isLoading) {
false
} else {
if (item.chapter.id != presenter.getCurrentChapter()?.chapter?.id) {
if (item.chapter.id != viewModel.getCurrentChapter()?.chapter?.id) {
activity.binding.readerNav.leftChapter.isInvisible = true
activity.binding.readerNav.rightChapter.isInvisible = true
activity.isScrollingThroughPagesOrChapters = true
presenter.loadChapter(item.chapter)
loadingPos = position
val itemView = (binding.chapterRecycler.findViewHolderForAdapterPosition(position) as? ReaderChapterItem.ViewHolder)?.binding
itemView?.bookmarkImage?.isVisible = false
itemView?.progress?.isVisible = true
activity.lifecycleScope.launch {
activity.loadChapter(item.chapter)
}
}
true
}
@ -175,7 +183,7 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
item: ReaderChapterItem,
) {
if (!activity.isLoading && sheetBehavior.isExpanded()) {
presenter.toggleBookmark(item.chapter)
viewModel.toggleBookmark(item.chapter)
refreshList()
}
}
@ -217,14 +225,14 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr
fun refreshList() {
launchUI {
val chapters = presenter.getChapters()
val chapters = viewModel.getChapters()
selectedChapterId = chapters.find { it.isCurrent }?.chapter?.id ?: -1L
itemAdapter.clear()
itemAdapter.add(chapters)
(binding.chapterRecycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
adapter?.getPosition(presenter.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0,
adapter?.getPosition(viewModel.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0,
binding.chapterRecycler.height / 2 - 30.dpToPx,
)
}

View file

@ -1,66 +1,64 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import com.github.junrar.exception.UnsupportedRarV5Exception
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import rx.Completable
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import eu.kanade.tachiyomi.util.system.withIOContext
import timber.log.Timber
/**
* Loader used to retrieve the [PageLoader] for a given chapter.
*/
class ChapterLoader(
private val context: Context,
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
private val manga: Manga,
private val source: Source,
) {
/**
* Returns a completable that assigns the page loader and loads the its pages. It just
* completes if the chapter is already loaded.
* Assigns the chapter's page loader and loads the its pages. Returns immediately if the chapter
* is already loaded.
*/
fun loadChapter(chapter: ReaderChapter): Completable {
suspend fun loadChapter(chapter: ReaderChapter) {
if (chapterIsReady(chapter)) {
return Completable.complete()
return
}
return Observable.just(chapter)
.doOnNext { chapter.state = ReaderChapter.State.Loading }
.observeOn(Schedulers.io())
.flatMap { readerChapter ->
Timber.d("Loading pages for ${chapter.chapter.name}")
val loader = getPageLoader(readerChapter)
chapter.state = ReaderChapter.State.Loading
withIOContext {
Timber.d("Loading pages for ${chapter.chapter.name}")
try {
val loader = getPageLoader(chapter)
chapter.pageLoader = loader
loader.getPages().take(1).doOnNext { pages ->
pages.forEach { it.chapter = chapter }
}
}
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { pages ->
if (pages.isEmpty()) {
throw Exception(downloadManager.context.getString(R.string.no_pages_found))
}
val pages = loader.getPages()
.onEach { it.chapter = chapter }
chapter.state = ReaderChapter.State.Loaded(pages)
if (pages.isEmpty()) {
throw Exception(context.getString(R.string.no_pages_found))
}
// If the chapter is partially read, set the starting page to the last the user read
// otherwise use the requested page.
if (!chapter.chapter.read) {
chapter.requestedPage = chapter.chapter.last_page_read
}
chapter.state = ReaderChapter.State.Loaded(pages)
} catch (e: Throwable) {
chapter.state = ReaderChapter.State.Error(e)
throw e
}
.toCompletable()
.doOnError { chapter.state = ReaderChapter.State.Error(it) }
}
}
/**
@ -74,9 +72,10 @@ class ChapterLoader(
* Returns the page loader to use for this [chapter].
*/
private fun getPageLoader(chapter: ReaderChapter): PageLoader {
val isDownloaded = downloadManager.isChapterDownloaded(chapter.chapter, manga, true)
val dbChapter = chapter.chapter
val isDownloaded = downloadManager.isChapterDownloaded(dbChapter, manga, skipCache = true)
return when {
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager)
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider)
source is HttpSource -> HttpPageLoader(chapter, source)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
@ -85,12 +84,12 @@ class ChapterLoader(
is LocalSource.Format.Rar -> try {
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {
error(downloadManager.context.getString(R.string.loader_rar5_error))
error(context.getString(R.string.loader_rar5_error))
}
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
}
}
else -> error(downloadManager.context.getString(R.string.source_not_installed))
else -> error(context.getString(R.string.source_not_installed))
}
}
}

View file

@ -4,7 +4,6 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.system.ImageUtil
import rx.Observable
import java.io.File
import java.io.FileInputStream
@ -14,27 +13,23 @@ import java.io.FileInputStream
class DirectoryPageLoader(val file: File) : PageLoader() {
/**
* Returns an observable containing the pages found on this directory ordered with a natural
* comparator.
* Returns the pages found on this directory ordered with a natural comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
override suspend fun getPages(): List<ReaderPage> {
return file.listFiles()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
.sortedWith(Comparator<File> { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) })
.mapIndexed { i, file ->
?.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.mapIndexed { i, file ->
val streamFn = { FileInputStream(file) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
status = Page.State.READY
}
}
.let { Observable.just(it) }
} ?: emptyList()
}
/**
* Returns an observable that emits a ready state.
* No additional action required to load the page
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(Page.READY)
}
override suspend fun loadPage(page: ReaderPage) {}
}

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import android.net.Uri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
@ -9,12 +10,8 @@ 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 eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.system.ImageUtil
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.util.zip.ZipFile
/**
* Loader used to load a chapter from the downloaded chapters.
@ -24,69 +21,49 @@ class DownloadPageLoader(
private val manga: Manga,
private val source: Source,
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
) : PageLoader() {
/**
* The application context. Needed to open input streams.
*/
private val context by injectLazy<Application>()
private val downloadProvider by lazy { DownloadProvider(context) }
// Needed to open input streams
private val context: Application by injectLazy()
private var zipPageLoader: ZipPageLoader? = null
override fun recycle() {
super.recycle()
zipPageLoader?.recycle()
}
/**
* Returns an observable containing the pages found on this downloaded chapter.
* Returns the pages found on this downloaded chapter.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val chapterPath = downloadProvider.findChapterDir(chapter.chapter, manga, source)
if (chapterPath?.isFile == true) {
val zip = if (!File(chapterPath.filePath!!).canRead()) {
val tmpFile = File.createTempFile(chapterPath.name!!.replace(".cbz", ""), ".cbz")
val buffer = ByteArray(1024)
chapterPath.openInputStream().use { input ->
tmpFile.outputStream().use { fileOut ->
while (true) {
val length = input.read(buffer)
if (length <= 0) break
fileOut.write(buffer, 0, length)
}
fileOut.flush()
}
}
ZipFile(tmpFile.absolutePath)
} else {
ZipFile(chapterPath.filePath)
}
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
.let { Observable.just(it) }
override suspend fun getPages(): List<ReaderPage> {
val dbChapter = chapter.chapter
val chapterPath = downloadProvider.findChapterDir(dbChapter, manga, source)
return if (chapterPath?.isFile == true) {
getPagesFromArchive(chapterPath)
} else {
return downloadManager.buildPageList(source, manga, chapter.chapter)
.map { pages ->
pages.map { page ->
ReaderPage(
page.index,
page.url,
page.imageUrl,
{
context.contentResolver.openInputStream(page.uri ?: Uri.EMPTY)!!
},
).apply {
status = Page.READY
}
}
}
getPagesFromDirectory()
}
}
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(Page.READY) // TODO maybe check if file still exists?
private suspend fun getPagesFromArchive(chapterPath: UniFile): List<ReaderPage> {
val loader = ZipPageLoader(File(chapterPath.filePath!!)).also { zipPageLoader = it }
return loader.getPages()
}
private fun getPagesFromDirectory(): List<ReaderPage> {
val pages = downloadManager.buildPageList(source, manga, chapter.chapter)
return pages.map { page ->
ReaderPage(page.index, page.url, page.imageUrl, stream = {
context.contentResolver.openInputStream(page.uri ?: Uri.EMPTY)!!
},).apply {
status = Page.State.READY
}
}
}
override suspend fun loadPage(page: ReaderPage) {
zipPageLoader?.loadPage(page)
}
}

View file

@ -3,7 +3,6 @@ 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.util.storage.EpubFile
import rx.Observable
import java.io.File
/**
@ -25,31 +24,23 @@ class EpubPageLoader(file: File) : PageLoader() {
}
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
* Returns the pages found on this zip archive ordered with a natural comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
override suspend fun getPages(): List<ReaderPage> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
status = Page.State.READY
}
}
.let { Observable.just(it) }
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
* No additional action required to load the page
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(
if (isRecycled) {
Page.ERROR
} else {
Page.READY
},
)
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
}
}

View file

@ -1,28 +1,24 @@
package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.model.Page
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.util.lang.plusAssign
import eu.kanade.tachiyomi.util.system.awaitSingle
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.withIOContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import rx.Completable
import rx.Observable
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.min
@ -36,38 +32,27 @@ class HttpPageLoader(
private val chapterCache: ChapterCache = Injekt.get(),
) : PageLoader() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* A queue used to manage requests one by one while allowing priorities.
*/
private val queue = PriorityBlockingQueue<PriorityPage>()
private val subscriptions = CompositeSubscription()
private val preloadSize = 4
private val preferences by injectLazy<PreferencesHelper>()
private var preloadSize = preferences.preloadSize().get()
private val scope = CoroutineScope(Job() + Dispatchers.IO)
init {
// Adding flow since we can reach reader settings after this is created
preferences.preloadSize().asFlow()
.onEach {
preloadSize = it
scope.launchIO {
flow {
while (true) {
emit(runInterruptible { queue.take() }.page)
}
}
.launchIn(scope)
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe(
{
},
{ error ->
if (error !is InterruptedException) {
Timber.e(error)
}
},
)
.filter { it.status == Page.State.QUEUE }
.collect {
_loadPage(it)
}
}
}
/**
@ -76,77 +61,77 @@ class HttpPageLoader(
override fun recycle() {
super.recycle()
scope.cancel()
subscriptions.unsubscribe()
queue.clear()
// Cache current page list progress for online chapters to allow a faster reopen
val pages = chapter.pages
if (pages != null) {
Completable
.fromAction {
launchIO {
try {
// Convert to pages without reader information
val pagesToSave = pages.map { Page(it.index, it.url, it.imageUrl) }
chapterCache.putPageListToCache(chapter.chapter, pagesToSave)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
}
/**
* Returns an observable with the page list for a chapter. It tries to return the page list from
* the local cache, otherwise fallbacks to network.
* Returns the page list for a chapter. It tries to return the page list from the local cache,
* otherwise fallbacks to network.
*/
override fun getPages(): Observable<List<ReaderPage>> {
return Observable.fromCallable { chapterCache.getPageListFromCache(chapter.chapter) }
.onErrorResumeNext { source.fetchPageList(chapter.chapter) }
.map { pages ->
pages.mapIndexed { index, page ->
// Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl)
}
override suspend fun getPages(): List<ReaderPage> {
val pages = try {
chapterCache.getPageListFromCache(chapter.chapter)
} catch (e: Throwable) {
if (e is CancellationException) {
throw e
}
source.getPageList(chapter.chapter)
}
return pages.mapIndexed { index, page ->
// Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl)
}
}
/**
* Returns an observable that loads a page through the queue and listens to its result to
* emit new states. It handles re-enqueueing pages if they were evicted from the cache.
* Loads a page through the queue. Handles re-enqueueing pages if they were evicted from the cache.
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.defer {
override suspend fun loadPage(page: ReaderPage) {
withIOContext {
val imageUrl = page.imageUrl
// Check if the image has been deleted
if (page.status == Page.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
page.status = Page.QUEUE
if (page.status == Page.State.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
page.status = Page.State.QUEUE
}
// Automatically retry failed pages when subscribed to this page
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
if (page.status == Page.State.ERROR) {
page.status = Page.State.QUEUE
}
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
val queuedPages = mutableListOf<PriorityPage>()
if (page.status == Page.QUEUE) {
if (page.status == Page.State.QUEUE) {
queuedPages += PriorityPage(page, 1).also { queue.offer(it) }
}
queuedPages += preloadNextPages(page, preloadSize)
statusSubject.startWith(page.status)
.doOnUnsubscribe {
suspendCancellableCoroutine<Nothing> { continuation ->
continuation.invokeOnCancellation {
queuedPages.forEach {
if (it.page.status == Page.QUEUE) {
if (it.page.status == Page.State.QUEUE) {
queue.remove(it)
}
}
}
}
}
.subscribeOn(Schedulers.io())
.unsubscribeOn(Schedulers.io())
}
/**
@ -161,7 +146,7 @@ class HttpPageLoader(
return pages
.subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size))
.mapNotNull {
if (it.status == Page.QUEUE) {
if (it.status == Page.State.QUEUE) {
PriorityPage(it, 0).apply { queue.offer(this) }
} else {
null
@ -173,8 +158,8 @@ class HttpPageLoader(
* Retries a page. This method is only called from user interaction on the viewer.
*/
override fun retryPage(page: ReaderPage) {
if (page.status == Page.ERROR) {
page.status = Page.QUEUE
if (page.status == Page.State.ERROR) {
page.status = Page.State.QUEUE
}
queue.offer(PriorityPage(page, 2))
}
@ -186,7 +171,6 @@ class HttpPageLoader(
val page: ReaderPage,
val priority: Int,
) : Comparable<PriorityPage> {
companion object {
private val idGenerator = AtomicInteger()
}
@ -200,61 +184,32 @@ class HttpPageLoader(
}
/**
* Returns an observable of the page with the downloaded image.
* Loads the page, retrieving the image URL and downloading the image if necessary.
* Downloaded images are stored in the chapter cache.
*
* @param page the page whose source image has to be downloaded.
*/
private fun HttpSource.fetchImageFromCacheThenNet(page: ReaderPage): Observable<ReaderPage> {
return if (page.imageUrl.isNullOrEmpty()) {
getImageUrl(page).flatMap { getCachedImage(it) }
} else {
getCachedImage(page)
private suspend fun _loadPage(page: ReaderPage) {
try {
if (page.imageUrl.isNullOrEmpty()) {
page.status = Page.State.LOAD_PAGE
page.imageUrl = source.fetchImageUrl(page).awaitSingle()
}
val imageUrl = page.imageUrl!!
if (!chapterCache.isImageInCache(imageUrl)) {
page.status = Page.State.DOWNLOAD_IMAGE
val imageResponse = source.getImage(page)
chapterCache.putImageToCache(imageUrl, imageResponse)
}
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
page.status = Page.State.READY
} catch (e: Throwable) {
page.status = Page.State.ERROR
if (e is CancellationException) {
throw e
}
}
}
private fun HttpSource.getImageUrl(page: ReaderPage): Observable<ReaderPage> {
page.status = Page.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
/**
* Returns an observable of the page that gets the image from the chapter or fallbacks to
* network and copies it to the cache calling [cacheImage].
*
* @param page the page.
*/
private fun HttpSource.getCachedImage(page: ReaderPage): Observable<ReaderPage> {
val imageUrl = page.imageUrl ?: return Observable.just(page)
return Observable.just(page)
.flatMap {
if (!chapterCache.isImageInCache(imageUrl)) {
cacheImage(page)
} else {
Observable.just(page)
}
}
.doOnNext {
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
page.status = Page.READY
}
.doOnError { page.status = Page.ERROR }
.onErrorReturn { page }
}
/**
* Returns an observable of the page that downloads the image to [ChapterCache].
*
* @param page the page.
*/
private fun HttpSource.cacheImage(page: ReaderPage): Observable<ReaderPage> {
page.status = Page.DOWNLOAD_IMAGE
return fetchImage(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
}
}

View file

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.reader.loader
import androidx.annotation.CallSuper
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import rx.Observable
/**
* A loader used to load pages into the reader. Any open resources must be cleaned up when the
@ -26,16 +25,16 @@ abstract class PageLoader {
}
/**
* Returns an observable containing the list of pages of a chapter. Only the first emission
* will be used.
* Returns the list of pages of a chapter.
*/
abstract fun getPages(): Observable<List<ReaderPage>>
abstract suspend fun getPages(): List<ReaderPage>
/**
* Returns an observable that should inform of the progress of the page (see the Page class
* for the available states)
* Loads the page. May also preload other pages.
* Progress of the page loading should be followed via [page.statusFlow].
* [loadPage] is not currently guaranteed to complete, so it should be launched asynchronously.
*/
abstract fun getPage(page: ReaderPage): Observable<Int>
abstract suspend fun loadPage(page: ReaderPage)
/**
* Retries the given [page] in case it failed to load. This method only makes sense when an

View file

@ -6,7 +6,6 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.system.ImageUtil
import rx.Observable
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
@ -38,33 +37,27 @@ class RarPageLoader(file: File) : PageLoader() {
}
/**
* Returns an observable containing the pages found on this rar archive ordered with a natural
* Returns an RxJava Single containing the pages found on this rar archive ordered with a natural
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
override suspend fun getPages(): List<ReaderPage> {
return archive.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
ReaderPage(i).apply {
stream = { getStream(header) }
status = Page.READY
status = Page.State.READY
}
}
.let { Observable.just(it.toList()) }
.toList()
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
* No additional action required to load the page
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(
if (isRecycled) {
Page.ERROR
} else {
Page.READY
},
)
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
}
/**

View file

@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.system.ImageUtil
import rx.Observable
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
@ -33,32 +32,25 @@ class ZipPageLoader(file: File) : PageLoader() {
}
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
* Returns the pages found on this zip archive ordered with a natural comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
override suspend fun getPages(): List<ReaderPage> {
return zip.entries().asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
ReaderPage(i).apply {
stream = { zip.getInputStream(entry) }
status = Page.READY
status = Page.State.READY
}
}
.let { Observable.just(it.toList()) }
.toList()
}
/**
* Returns an observable that emits a ready state unless the loader was recycled.
* No additional action required to load the page
*/
override fun getPage(page: ReaderPage): Observable<Int> {
return Observable.just(
if (isRecycled) {
Page.ERROR
} else {
Page.READY
},
)
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
}
}

View file

@ -12,6 +12,6 @@ class InsertPage(parent: ReaderPage) : ReaderPage(
fullPage = true
firstHalf = false
stream = parent.stream
status = READY
status = State.READY
}
}

View file

@ -15,9 +15,9 @@ class ReaderGeneralView @JvmOverloads constructor(context: Context, attrs: Attri
override fun initGeneralPreferences() {
binding.viewerSeries.onItemSelectedListener = { position ->
val readingModeType = ReadingModeType.fromSpinner(position)
(context as ReaderActivity).presenter.setMangaReadingMode(readingModeType.flagValue)
(context as ReaderActivity).viewModel.setMangaReadingMode(readingModeType.flagValue)
val mangaViewer = activity.presenter.getMangaReadingMode()
val mangaViewer = activity.viewModel.getMangaReadingMode()
if (mangaViewer == ReadingModeType.WEBTOON.flagValue || mangaViewer == ReadingModeType.CONTINUOUS_VERTICAL.flagValue) {
initWebtoonPreferences()
} else {
@ -25,16 +25,16 @@ class ReaderGeneralView @JvmOverloads constructor(context: Context, attrs: Attri
}
}
binding.viewerSeries.setSelection(
(context as? ReaderActivity)?.presenter?.manga?.readingModeType?.let {
(context as? ReaderActivity)?.viewModel?.state?.value?.manga?.readingModeType?.let {
ReadingModeType.fromPreference(it).prefValue
} ?: 0,
)
binding.rotationMode.onItemSelectedListener = { position ->
val rotationType = OrientationType.fromSpinner(position)
(context as ReaderActivity).presenter.setMangaOrientationType(rotationType.flagValue)
(context as ReaderActivity).viewModel.setMangaOrientationType(rotationType.flagValue)
}
binding.rotationMode.setSelection(
(context as ReaderActivity).presenter.manga?.orientationType?.let {
(context as ReaderActivity).viewModel.manga?.orientationType?.let {
OrientationType.fromPreference(it).prefValue
} ?: 0,
)

View file

@ -19,7 +19,7 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu
override fun initGeneralPreferences() {
with(binding) {
scaleType.bindToPreference(preferences.imageScaleType(), 1) {
val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaReadingMode() ?: 0
val mangaViewer = (context as? ReaderActivity)?.viewModel?.getMangaReadingMode() ?: 0
val isWebtoonView = ReadingModeType.isWebtoonType(mangaViewer)
updatePagedGroup(!isWebtoonView)
landscapeZoom.isVisible = it == SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE - 1
@ -33,7 +33,7 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu
pagerInvert.bindToPreference(preferences.pagerNavInverted())
extendPastCutout.bindToPreference(preferences.pagerCutoutBehavior())
pageLayout.bindToPreference(preferences.pageLayout()) {
val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaReadingMode() ?: 0
val mangaViewer = (context as? ReaderActivity)?.viewModel?.getMangaReadingMode() ?: 0
val isWebtoonView = ReadingModeType.isWebtoonType(mangaViewer)
updatePagedGroup(!isWebtoonView)
}
@ -42,7 +42,7 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu
pageLayout.title = pageLayout.title.toString().addBetaTag(context)
val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaReadingMode() ?: 0
val mangaViewer = (context as? ReaderActivity)?.viewModel?.getMangaReadingMode() ?: 0
val isWebtoonView = ReadingModeType.isWebtoonType(mangaViewer)
val hasMargins = mangaViewer == ReadingModeType.CONTINUOUS_VERTICAL.flagValue
cropBordersWebtoon.bindToPreference(if (hasMargins) preferences.cropBorders() else preferences.cropBordersWebtoon())
@ -61,7 +61,7 @@ class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: Attribu
}
fun updatePrefs() {
val mangaViewer = activity.presenter.getMangaReadingMode()
val mangaViewer = activity.viewModel.getMangaReadingMode()
val isWebtoonView = ReadingModeType.isWebtoonType(mangaViewer)
val hasMargins = mangaViewer == ReadingModeType.CONTINUOUS_VERTICAL.flagValue
binding.cropBordersWebtoon.bindToPreference(if (hasMargins) preferences.cropBorders() else preferences.cropBordersWebtoon())

View file

@ -38,7 +38,7 @@ class TabbedReaderSettingsSheet(
) as ReaderFilterView
var showWebtoonView: Boolean = run {
val mangaViewer = readerActivity.presenter.getMangaReadingMode()
val mangaViewer = readerActivity.viewModel.getMangaReadingMode()
ReadingModeType.isWebtoonType(mangaViewer)
}

View file

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.content.Context
import android.os.Parcelable
import android.view.HapticFeedbackConstants
import android.view.KeyEvent
import android.view.MotionEvent
@ -27,6 +28,17 @@ open class Pager(
*/
var longTapListener: ((MotionEvent) -> Boolean)? = null
var isRestoring = false
override fun onRestoreInstanceState(state: Parcelable?) {
isRestoring = true
val currentItem = currentItem
super.onRestoreInstanceState(state)
setCurrentItem(currentItem, false)
isRestoring = false
// super.onRestoreInstanceState(state)
}
/**
* Gesture listener that implements tap and long tap events.
*/

View file

@ -38,11 +38,13 @@ import eu.kanade.tachiyomi.util.system.topCutoutInset
import eu.kanade.tachiyomi.util.view.backgroundColor
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.Observable
import rx.Subscription
@ -51,7 +53,6 @@ import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.InputStream
import java.util.concurrent.TimeUnit
import kotlin.math.min
import kotlin.math.roundToInt
@ -87,37 +88,47 @@ class PagerPageHolder(
private var decodeErrorLayout: ViewGroup? = null
/**
* Subscription for status changes of the page.
* Job for loading the page.
*/
private var statusSubscription: Subscription? = null
private var loadJob: Job? = null
/**
* Subscription for progress changes of the page.
* Job for status changes of the page.
*/
private var progressSubscription: Subscription? = null
private var statusJob: Job? = null
/**
* Subscription for status changes of the page.
* Job for progress changes of the page.
*/
private var extraStatusSubscription: Subscription? = null
private var progressJob: Job? = null
/**
* Subscription for progress changes of the page.
* Job for loading the page.
*/
private var extraProgressSubscription: Subscription? = null
private var extraLoadJob: Job? = null
/**
* Job for status changes of the page.
*/
private var extraStatusJob: Job? = null
/**
* Job for progress changes of the page.
*/
private var extraProgressJob: Job? = null
/**
* Subscription used to read the header of the image. This is needed in order to instantiate
* the appropriate image view depending if the image is animated (GIF).
* the appropiate image view depending if the image is animated (GIF).
*/
private var readImageHeaderSubscription: Subscription? = null
private var status: Int = 0
private var extraStatus: Int = 0
private var status = Page.State.READY
private var extraStatus = Page.State.READY
private var progress: Int = 0
private var extraProgress: Int = 0
private var scope: CoroutineScope? = null
private var scope = MainScope()
init {
addView(progressBar)
@ -126,8 +137,7 @@ class PagerPageHolder(
marginStart = ((context.resources.displayMetrics.widthPixels) / 2 + viewer.config.hingeGapSize) / 2
}
}
scope = CoroutineScope(Job() + Default)
observeStatus()
launchLoadJob()
setBackgroundColor(
when (val theme = viewer.config.readerTheme) {
3 -> Color.TRANSPARENT
@ -185,16 +195,64 @@ class PagerPageHolder(
@SuppressLint("ClickableViewAccessibility")
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
unsubscribeProgress(1)
unsubscribeStatus(1)
unsubscribeProgress(2)
unsubscribeStatus(2)
cancelProgressJob(1)
cancelLoadJob(1)
cancelProgressJob(2)
cancelLoadJob(2)
unsubscribeReadImageHeader()
scope?.cancel()
scope = null
(pageView as? SubsamplingScaleImageView)?.setOnImageEventListener(null)
}
/**
* Starts loading the page and processing changes to the page's status.
*
* @see processStatus
*/
private fun launchLoadJob() {
loadJob?.cancel()
statusJob?.cancel()
val loader = page.chapter.pageLoader ?: return
loadJob = scope.launch {
loader.loadPage(page)
}
statusJob = scope.launch {
page.statusFlow.collectLatest { processStatus(it) }
}
val extraPage = extraPage ?: return
extraLoadJob = scope.launch {
loader.loadPage(extraPage)
}
extraStatusJob = scope.launch {
extraPage.statusFlow.collectLatest { processStatus2(it) }
}
}
private fun launchProgressJob() {
progressJob?.cancel()
progressJob = scope.launch {
page.progressFlow.collectLatest { value ->
progress = value
if (extraPage == null) {
progressBar.setProgress(progress)
} else {
progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt())
}
}
}
}
private fun launchProgressJob2() {
val extraPage = extraPage ?: return
extraProgressJob?.cancel()
extraProgressJob = scope.launch {
extraPage.progressFlow.collectLatest { value ->
extraProgress = value
progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt())
}
}
}
fun onPageSelected(forward: Boolean?) {
(pageView as? SubsamplingScaleImageView)?.apply {
if (isReady) {
@ -304,88 +362,28 @@ class PagerPageHolder(
}
}
/**
* Observes the status of the page and notify the changes.
*
* @see processStatus
*/
private fun observeStatus() {
statusSubscription?.unsubscribe()
val loader = page.chapter.pageLoader ?: return
statusSubscription = loader.getPage(page)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
status = it
processStatus(it)
}
val extraPage = extraPage ?: return
val loader2 = extraPage.chapter.pageLoader ?: return
extraStatusSubscription = loader2.getPage(extraPage)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
extraStatus = it
processStatus2(it)
}
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
progressSubscription?.unsubscribe()
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { value ->
progress = value
if (extraPage == null) {
progressBar.setProgress(progress)
} else {
progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt())
}
}
}
private fun observeProgress2() {
extraProgressSubscription?.unsubscribe()
val extraPage = extraPage ?: return
extraProgressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { extraPage.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { value ->
extraProgress = value
progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt())
}
}
/**
* Called when the status of the page changes.
*
* @param status the new status of the page.
*/
private fun processStatus(status: Int) {
private fun processStatus(status: Page.State) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress()
Page.State.QUEUE -> setQueued()
Page.State.LOAD_PAGE -> setLoading()
Page.State.DOWNLOAD_IMAGE -> {
launchProgressJob()
setDownloading()
}
Page.READY -> {
if (extraStatus == Page.READY || extraPage == null) {
Page.State.READY -> {
if (extraStatus == Page.State.READY || extraPage == null) {
setImage()
}
unsubscribeProgress(1)
cancelProgressJob(1)
}
Page.ERROR -> {
Page.State.ERROR -> {
setError()
unsubscribeProgress(1)
cancelProgressJob(1)
}
}
}
@ -395,43 +393,51 @@ class PagerPageHolder(
*
* @param status the new status of the page.
*/
private fun processStatus2(status: Int) {
private fun processStatus2(status: Page.State) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress2()
Page.State.QUEUE -> setQueued()
Page.State.LOAD_PAGE -> setLoading()
Page.State.DOWNLOAD_IMAGE -> {
launchProgressJob2()
setDownloading()
}
Page.READY -> {
if (this.status == Page.READY) {
Page.State.READY -> {
if (this.status == Page.State.READY) {
setImage()
}
unsubscribeProgress(2)
cancelProgressJob(2)
}
Page.ERROR -> {
Page.State.ERROR -> {
setError()
unsubscribeProgress(2)
cancelProgressJob(2)
}
}
}
/**
* Unsubscribes from the status subscription.
* Cancels loading the page and processing changes to the page's status.
*/
private fun unsubscribeStatus(page: Int) {
val subscription = if (page == 1) statusSubscription else extraStatusSubscription
subscription?.unsubscribe()
if (page == 1) statusSubscription = null else extraStatusSubscription = null
private fun cancelLoadJob(page: Int) {
if (page == 1) {
loadJob?.cancel()
loadJob = null
statusJob?.cancel()
statusJob = null
} else {
extraLoadJob?.cancel()
extraLoadJob = null
extraStatusJob?.cancel()
extraStatusJob = null
}
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress(page: Int) {
val subscription = if (page == 1) progressSubscription else extraProgressSubscription
subscription?.unsubscribe()
if (page == 1) progressSubscription = null else extraProgressSubscription = null
private fun cancelProgressJob(page: Int) {
(if (page == 1) progressJob else extraProgressJob)?.cancel()
if (page == 1) {
progressJob = null
} else {
extraProgressJob = null
}
}
/**
@ -515,7 +521,7 @@ class PagerPageHolder(
setImage(bytesStream, false, imageConfig)
bytesStream.close()
scope?.launchUI {
scope.launchUI {
try {
pageView?.background = setBG(bytesArray)
} catch (e: Exception) {

View file

@ -59,7 +59,7 @@ class PagerTransitionHolder(
addView(transitionView)
addView(pagesContainer)
transitionView.bind(transition, viewer.downloadManager, viewer.activity.presenter.manga)
transitionView.bind(transition, viewer.downloadManager, viewer.activity.viewModel.state.value.manga)
transition.to?.let { observeStatus(it) }
if (viewer.config.hingeGapSize > 0) {

View file

@ -89,6 +89,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
private var pagerListener = object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
if (pager.isRestoring) return
val page = adapter.joinedItems.getOrNull(position)
if (!activity.isScrollingThroughPagesOrChapters && page?.first !is ChapterTransition) {
activity.hideMenu()

View file

@ -23,13 +23,16 @@ import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar
import eu.kanade.tachiyomi.util.system.ImageUtil
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import java.io.BufferedInputStream
import java.io.InputStream
import java.util.concurrent.TimeUnit
/**
* Holder of the webtoon reader for a single page of a chapter.
@ -75,19 +78,26 @@ class WebtoonPageHolder(
*/
private var page: ReaderPage? = null
/**
* Subscription for status changes of the page.
*/
private var statusSubscription: Subscription? = null
private val scope = MainScope()
/**
* Subscription for progress changes of the page.
* Job for loading the page.
*/
private var progressSubscription: Subscription? = null
private var loadJob: Job? = null
/**
* Job for status changes of the page.
*/
private var statusJob: Job? = null
/**
* Job for progress changes of the page.
*/
private var progressJob: Job? = null
/**
* Subscription used to read the header of the image. This is needed in order to instantiate
* the appropiate image view depending if the image is animated (GIF).
* the appropriate image view depending if the image is animated (GIF).
*/
private var readImageHeaderSubscription: Subscription? = null
@ -105,7 +115,7 @@ class WebtoonPageHolder(
*/
fun bind(page: ReaderPage) {
this.page = page
observeStatus()
launchLoadJob()
refreshLayoutParams()
}
@ -124,8 +134,8 @@ class WebtoonPageHolder(
* Called when the view is recycled and added to the view pool.
*/
override fun recycle() {
unsubscribeStatus()
unsubscribeProgress()
cancelLoadJob()
cancelProgressJob()
unsubscribeReadImageHeader()
removeDecodeErrorLayout()
@ -138,34 +148,30 @@ class WebtoonPageHolder(
*
* @see processStatus
*/
private fun observeStatus() {
unsubscribeStatus()
private fun launchLoadJob() {
cancelLoadJob()
val page = page ?: return
val loader = page.chapter.pageLoader ?: return
statusSubscription = loader.getPage(page)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
addSubscription(statusSubscription)
loadJob = scope.launch {
loader.loadPage(page)
}
statusJob = scope.launch {
page.statusFlow.collectLatest { processStatus(it) }
}
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
unsubscribeProgress()
private fun launchProgressJob() {
cancelProgressJob()
val page = page ?: return
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { value -> progressBar.setProgress(value) }
addSubscription(progressSubscription)
progressJob = scope.launch {
page.progressFlow.collectLatest { value -> progressBar.setProgress(value) }
}
}
/**
@ -173,39 +179,41 @@ class WebtoonPageHolder(
*
* @param status the new status of the page.
*/
private fun processStatus(status: Int) {
private fun processStatus(status: Page.State) {
when (status) {
Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> {
observeProgress()
Page.State.QUEUE -> setQueued()
Page.State.LOAD_PAGE -> setLoading()
Page.State.DOWNLOAD_IMAGE -> {
launchProgressJob()
setDownloading()
}
Page.READY -> {
Page.State.READY -> {
setImage()
unsubscribeProgress()
cancelProgressJob()
}
Page.ERROR -> {
Page.State.ERROR -> {
setError()
unsubscribeProgress()
cancelProgressJob()
}
}
}
/**
* Unsubscribes from the status subscription.
* Cancels loading the page and processing changes to the page's status.
*/
private fun unsubscribeStatus() {
removeSubscription(statusSubscription)
statusSubscription = null
private fun cancelLoadJob() {
loadJob?.cancel()
loadJob = null
statusJob?.cancel()
statusJob = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
removeSubscription(progressSubscription)
progressSubscription = null
private fun cancelProgressJob() {
progressJob?.cancel()
progressJob = null
}
/**

View file

@ -64,7 +64,7 @@ class WebtoonTransitionHolder(
* Binds the given [transition] with this view holder, subscribing to its state.
*/
fun bind(transition: ChapterTransition) {
transitionView.bind(transition, viewer.downloadManager, viewer.activity.presenter.manga)
transitionView.bind(transition, viewer.downloadManager, viewer.activity.viewModel.state.value.manga)
transition.to?.let { observeStatus(it, transition) }
}

View file

@ -139,7 +139,7 @@ class EpubFile(file: File) : Closeable {
*/
private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") }
.filter { node -> "application/xhtml+xml" == node.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }

View file

@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -23,6 +24,9 @@ fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job =
fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.Main, block = block)
fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job =
launchIO { withContext(NonCancellable, block) }
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block)