From 9588ac8cbd280d64b9aeb83fd7edee8ae0cfffa0 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 3 Mar 2022 21:00:07 +0200 Subject: [PATCH] Preload pages --- .../kotatsu/reader/domain/PageLoader.kt | 161 ++++++++++++------ .../kotatsu/reader/ui/ReaderViewModel.kt | 22 +++ .../kotatsu/reader/ui/pager/BasePageHolder.kt | 6 +- .../kotatsu/reader/ui/pager/BaseReader.kt | 6 - .../reader/ui/pager/PageHolderDelegate.kt | 14 +- .../pager/reversed/ReversedReaderFragment.kt | 7 +- .../reader/ui/pager/standard/PageHolder.kt | 2 +- .../ui/pager/standard/PagerReaderFragment.kt | 2 +- .../reader/ui/pager/webtoon/WebtoonHolder.kt | 2 +- .../ui/pager/webtoon/WebtoonReaderFragment.kt | 7 +- .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 8 + 11 files changed, 168 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 6eb29088c..0e67c1da6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -10,25 +10,61 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient import okhttp3.Request +import okio.Closeable import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.koitharu.kotatsu.core.model.MangaPage +import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf import java.io.File +import java.util.* +import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipFile -class PageLoader( - scope: CoroutineScope, - private val okHttp: OkHttpClient, - private val cache: PagesCache -) : CoroutineScope by scope, KoinComponent { +class PageLoader : KoinComponent, Closeable { - private var repository: MangaRepository? = null + val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val okHttp = get() + private val cache = get() private val tasks = LongSparseArray>() private val convertLock = Mutex() + private var repository: MangaRepository? = null + private var prefetchQueue = LinkedList() + private val counter = AtomicInteger(0) + private var prefetchQueueLimit = 10 // TODO adaptive + + override fun close() { + loaderScope.cancel() + tasks.clear() + } + + fun isPrefetchApplicable(): Boolean { + return repository is RemoteMangaRepository + } + + fun prefetch(pages: List) { + synchronized(prefetchQueue) { + for (page in pages.asReversed()) { + if (tasks.containsKey(page.id)) { + continue + } + prefetchQueue.offerFirst(page.toMangaPage()) + if (prefetchQueue.size > prefetchQueueLimit) { + prefetchQueue.pollLast() + } + } + } + if (counter.get() == 0) { + onIdle() + } + } suspend fun loadPage(page: MangaPage, force: Boolean): File { if (!force) { @@ -42,53 +78,14 @@ class PageLoader( } else if (task?.isCancelled == false) { return task.await() } - task = loadAsync(page) + task = loadPageAsync(page) tasks[page.id] = task return task.await() } - private fun loadAsync(page: MangaPage): Deferred { - var repo = repository - if (repo?.source != page.source) { - repo = mangaRepositoryOf(page.source) - repository = repo - } - return async(Dispatchers.IO) { - val pageUrl = repo.getPageUrl(page) - check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } - val uri = Uri.parse(pageUrl) - if (uri.scheme == "cbz") { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - zip.getInputStream(entry).use { - cache.put(pageUrl, it) - } - } else { - val request = Request.Builder() - .url(pageUrl) - .get() - .header(CommonHeaders.REFERER, page.referer) - .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") - .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) - .build() - okHttp.newCall(request).await().use { response -> - check(response.isSuccessful) { - "Invalid response: ${response.code} ${response.message}" - } - val body = checkNotNull(response.body) { - "Null response" - } - body.byteStream().use { - cache.put(pageUrl, it) - } - } - } - } - } - suspend fun convertInPlace(file: File) { - convertLock.withLock(Lock) { - withContext(Dispatchers.Default) { + convertLock.withLock { + runInterruptible(Dispatchers.Default) { val image = BitmapFactory.decodeFile(file.absolutePath) try { file.outputStream().use { out -> @@ -101,5 +98,69 @@ class PageLoader( } } - private companion object Lock -} + private fun onIdle() { + synchronized(prefetchQueue) { + val page = prefetchQueue.pollFirst() ?: return + tasks[page.id] = loadPageAsync(page) + } + } + + private fun loadPageAsync(page: MangaPage): Deferred { + return loaderScope.async { + counter.incrementAndGet() + try { + loadPageImpl(page) + } finally { + if (counter.decrementAndGet() == 0) { + onIdle() + } + } + } + } + + @Synchronized + private fun getRepository(source: MangaSource): MangaRepository { + val result = repository + return if (result != null && result.source == source) { + result + } else { + mangaRepositoryOf(source).also { repository = it } + } + } + + private suspend fun loadPageImpl(page: MangaPage): File { + val pageUrl = getRepository(page.source).getPageUrl(page) + check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } + val uri = Uri.parse(pageUrl) + return if (uri.scheme == "cbz") { + runInterruptible(Dispatchers.IO) { + val zip = ZipFile(uri.schemeSpecificPart) + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry).use { + cache.put(pageUrl, it) + } + } + } else { + val request = Request.Builder() + .url(pageUrl) + .get() + .header(CommonHeaders.REFERER, page.referer) + .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") + .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .build() + okHttp.newCall(request).await().use { response -> + check(response.isSuccessful) { + "Invalid response: ${response.code} ${response.message}" + } + val body = checkNotNull(response.body) { + "Null response" + } + runInterruptible(Dispatchers.IO) { + body.byteStream().use { + cache.put(pageUrl, it) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index 2e360594c..974655089 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.utils.DownloadManagerHelper @@ -45,6 +46,8 @@ class ReaderViewModel( private val mangaData = MutableStateFlow(intent.manga) private val chapters = LongSparseArray() + val pageLoader = PageLoader() + val readerMode = MutableLiveData() val onPageSaved = SingleLiveEvent() val uiState = combine( @@ -126,6 +129,11 @@ class ReaderViewModel( subscribeToSettings() } + override fun onCleared() { + pageLoader.close() + super.onCleared() + } + fun switchMode(newMode: ReaderMode) { launchJob { val manga = checkNotNull(mangaData.value) @@ -206,6 +214,9 @@ class ReaderViewModel( if (position >= pages.size - BOUNDS_PAGE_OFFSET) { loadPrevNextChapter(pages.last().chapterId, 1) } + if (pageLoader.isPrefetchApplicable()) { + pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT)) + } } private fun getReaderMode(isWebtoon: Boolean?) = when { @@ -262,10 +273,21 @@ class ReaderViewModel( .launchIn(viewModelScope + Dispatchers.IO) } + private fun List.trySublist(fromIndex: Int, toIndex: Int): List { + val fromIndexBounded = fromIndex.coerceAtMost(lastIndex) + val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex) + return if (fromIndexBounded == toIndexBounded) { + emptyList() + } else { + subList(fromIndexBounded, toIndexBounded) + } + } + private companion object : KoinComponent { const val BOUNDS_PAGE_OFFSET = 2 const val PAGES_TRIM_THRESHOLD = 120 + const val PREFETCH_LIMIT = 10 fun saveState(manga: Manga, state: ReaderState) { processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt index a9cdaf2ad..d538d7f45 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.reader.ui.pager import android.content.Context +import androidx.annotation.CallSuper import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver @@ -33,5 +34,8 @@ abstract class BasePageHolder( protected abstract fun onBind(data: ReaderPage) - open fun onRecycled() = Unit + @CallSuper + open fun onRecycled() { + delegate.onRecycle() + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt index 236603f69..700a261ed 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt @@ -3,21 +3,15 @@ package org.koitharu.kotatsu.reader.ui.pager import android.os.Bundle import android.view.View import androidx.core.graphics.Insets -import androidx.lifecycle.lifecycleScope import androidx.viewbinding.ViewBinding -import org.koin.android.ext.android.get import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.base.ui.BaseFragment -import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderViewModel abstract class BaseReader : BaseFragment() { protected val viewModel by sharedViewModel() - protected val loader by lazy(LazyThreadSafetyMode.NONE) { - PageLoader(lifecycleScope, get(), get()) - } private var stateToSave: ReaderState? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index e9f9598c8..6b7813298 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -3,7 +3,10 @@ package org.koitharu.kotatsu.reader.ui.pager import android.net.Uri import androidx.core.net.toUri import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException import org.koitharu.kotatsu.core.model.MangaPage @@ -20,21 +23,22 @@ class PageHolderDelegate( private val settings: AppSettings, private val callback: Callback, private val exceptionResolver: ExceptionResolver -) : SubsamplingScaleImageView.DefaultOnImageEventListener(), CoroutineScope by loader { +) : SubsamplingScaleImageView.DefaultOnImageEventListener() { + private val scope = loader.loaderScope + Dispatchers.Main.immediate private var state = State.EMPTY private var job: Job? = null private var file: File? = null private var error: Throwable? = null fun onBind(page: MangaPage) { - job = launchInstead(job) { + job = scope.launchInstead(job) { doLoad(page, force = false) } } fun retry(page: MangaPage) { - job = launchInstead(job) { + job = scope.launchInstead(job) { (error as? ResolvableException)?.let { exceptionResolver.resolve(it) } @@ -65,7 +69,7 @@ class PageHolderDelegate( val file = this.file error = e if (state == State.LOADED && e is IOException && file != null && file.exists()) { - job = launchAfter(job) { + job = scope.launchAfter(job) { state = State.CONVERTING try { loader.convertInPlace(file) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt index 9da14c353..cff351d9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt @@ -13,7 +13,10 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.doOnPageChanged +import org.koitharu.kotatsu.utils.ext.recyclerView +import org.koitharu.kotatsu.utils.ext.resetTransformations +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import kotlin.math.absoluteValue class ReversedReaderFragment : BaseReader() { @@ -27,7 +30,7 @@ class ReversedReaderFragment : BaseReader() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - pagerAdapter = ReversedPagesAdapter(loader, get(), exceptionResolver) + pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, get(), exceptionResolver) with(binding.pager) { adapter = pagerAdapter offscreenPageLimit = 2 diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index bd630b8d0..3a5adbb0d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -37,7 +37,7 @@ open class PageHolder( } override fun onRecycled() { - delegate.onRecycle() + super.onRecycled() binding.ssiv.recycle() } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt index d2d2a6b50..a8cd77e17 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt @@ -29,7 +29,7 @@ class PagerReaderFragment : BaseReader() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - pagesAdapter = PagesAdapter(loader, get(), exceptionResolver) + pagesAdapter = PagesAdapter(viewModel.pageLoader, get(), exceptionResolver) with(binding.pager) { adapter = pagesAdapter offscreenPageLimit = 2 diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index fcb81c602..466791aa3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -37,7 +37,7 @@ class WebtoonHolder( } override fun onRecycled() { - delegate.onRecycle() + super.onRecycled() binding.ssiv.recycle() } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt index 1cb0b1079..ddaa63b5e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -12,7 +12,10 @@ import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged +import org.koitharu.kotatsu.utils.ext.findCenterViewPosition +import org.koitharu.kotatsu.utils.ext.firstItem +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope class WebtoonReaderFragment : BaseReader() { @@ -26,7 +29,7 @@ class WebtoonReaderFragment : BaseReader() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - webtoonAdapter = WebtoonAdapter(loader, get(), exceptionResolver) + webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, get(), exceptionResolver) with(binding.recyclerView) { setHasFixedSize(true) adapter = webtoonAdapter diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 215e2d185..9c94b1a6c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -4,6 +4,8 @@ import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest +import android.os.Bundle +import android.os.Parcelable import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.suspendCancellableCoroutine @@ -26,4 +28,10 @@ suspend fun ConnectivityManager.waitForNetwork(): Network { inline fun buildAlertDialog(context: Context, block: MaterialAlertDialogBuilder.() -> Unit): AlertDialog { return MaterialAlertDialogBuilder(context).apply(block).create() +} + +fun Bundle.requireParcelable(key: String): T { + return checkNotNull(getParcelable(key)) { + "Value for key $key not found" + } } \ No newline at end of file