Preload pages

This commit is contained in:
Koitharu
2022-03-03 21:00:07 +02:00
parent 28a4d4164e
commit 9588ac8cbd
11 changed files with 168 additions and 69 deletions

View File

@@ -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<OkHttpClient>()
private val cache = get<PagesCache>()
private val tasks = LongSparseArray<Deferred<File>>()
private val convertLock = Mutex()
private var repository: MangaRepository? = null
private var prefetchQueue = LinkedList<MangaPage>()
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<ReaderPage>) {
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<File> {
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<File> {
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)
}
}
}
}
}
}

View File

@@ -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<MangaChapter>()
val pageLoader = PageLoader()
val readerMode = MutableLiveData<ReaderMode>()
val onPageSaved = SingleLiveEvent<Uri?>()
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 <T> List<T>.trySublist(fromIndex: Int, toIndex: Int): List<T> {
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) {

View File

@@ -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<B : ViewBinding>(
protected abstract fun onBind(data: ReaderPage)
open fun onRecycled() = Unit
@CallSuper
open fun onRecycled() {
delegate.onRecycle()
}
}

View File

@@ -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<B : ViewBinding> : BaseFragment<B>() {
protected val viewModel by sharedViewModel<ReaderViewModel>()
protected val loader by lazy(LazyThreadSafetyMode.NONE) {
PageLoader(lifecycleScope, get(), get())
}
private var stateToSave: ReaderState? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -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)

View File

@@ -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<FragmentReaderStandardBinding>() {
@@ -27,7 +30,7 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
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

View File

@@ -37,7 +37,7 @@ open class PageHolder(
}
override fun onRecycled() {
delegate.onRecycle()
super.onRecycled()
binding.ssiv.recycle()
}

View File

@@ -29,7 +29,7 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
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

View File

@@ -37,7 +37,7 @@ class WebtoonHolder(
}
override fun onRecycled() {
delegate.onRecycle()
super.onRecycled()
binding.ssiv.recycle()
}

View File

@@ -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<FragmentReaderWebtoonBinding>() {
@@ -26,7 +29,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
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

View File

@@ -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 <T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelable(key)) {
"Value for key $key not found"
}
}