Preload pages
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,7 @@ open class PageHolder(
|
||||
}
|
||||
|
||||
override fun onRecycled() {
|
||||
delegate.onRecycle()
|
||||
super.onRecycled()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,7 @@ class WebtoonHolder(
|
||||
}
|
||||
|
||||
override fun onRecycled() {
|
||||
delegate.onRecycle()
|
||||
super.onRecycled()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user