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 kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okio.Closeable
import org.koin.core.component.KoinComponent 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.MangaPage
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository 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.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf
import java.io.File import java.io.File
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile import java.util.zip.ZipFile
class PageLoader( class PageLoader : KoinComponent, Closeable {
scope: CoroutineScope,
private val okHttp: OkHttpClient,
private val cache: PagesCache
) : CoroutineScope by scope, KoinComponent {
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 tasks = LongSparseArray<Deferred<File>>()
private val convertLock = Mutex() 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 { suspend fun loadPage(page: MangaPage, force: Boolean): File {
if (!force) { if (!force) {
@@ -42,53 +78,14 @@ class PageLoader(
} else if (task?.isCancelled == false) { } else if (task?.isCancelled == false) {
return task.await() return task.await()
} }
task = loadAsync(page) task = loadPageAsync(page)
tasks[page.id] = task tasks[page.id] = task
return task.await() 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) { suspend fun convertInPlace(file: File) {
convertLock.withLock(Lock) { convertLock.withLock {
withContext(Dispatchers.Default) { runInterruptible(Dispatchers.Default) {
val image = BitmapFactory.decodeFile(file.absolutePath) val image = BitmapFactory.decodeFile(file.absolutePath)
try { try {
file.outputStream().use { out -> 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.ReaderMode
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
import org.koitharu.kotatsu.history.domain.HistoryRepository 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.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.DownloadManagerHelper import org.koitharu.kotatsu.utils.DownloadManagerHelper
@@ -45,6 +46,8 @@ class ReaderViewModel(
private val mangaData = MutableStateFlow(intent.manga) private val mangaData = MutableStateFlow(intent.manga)
private val chapters = LongSparseArray<MangaChapter>() private val chapters = LongSparseArray<MangaChapter>()
val pageLoader = PageLoader()
val readerMode = MutableLiveData<ReaderMode>() val readerMode = MutableLiveData<ReaderMode>()
val onPageSaved = SingleLiveEvent<Uri?>() val onPageSaved = SingleLiveEvent<Uri?>()
val uiState = combine( val uiState = combine(
@@ -126,6 +129,11 @@ class ReaderViewModel(
subscribeToSettings() subscribeToSettings()
} }
override fun onCleared() {
pageLoader.close()
super.onCleared()
}
fun switchMode(newMode: ReaderMode) { fun switchMode(newMode: ReaderMode) {
launchJob { launchJob {
val manga = checkNotNull(mangaData.value) val manga = checkNotNull(mangaData.value)
@@ -206,6 +214,9 @@ class ReaderViewModel(
if (position >= pages.size - BOUNDS_PAGE_OFFSET) { if (position >= pages.size - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, 1) loadPrevNextChapter(pages.last().chapterId, 1)
} }
if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
}
} }
private fun getReaderMode(isWebtoon: Boolean?) = when { private fun getReaderMode(isWebtoon: Boolean?) = when {
@@ -262,10 +273,21 @@ class ReaderViewModel(
.launchIn(viewModelScope + Dispatchers.IO) .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 { private companion object : KoinComponent {
const val BOUNDS_PAGE_OFFSET = 2 const val BOUNDS_PAGE_OFFSET = 2
const val PAGES_TRIM_THRESHOLD = 120 const val PAGES_TRIM_THRESHOLD = 120
const val PREFETCH_LIMIT = 10
fun saveState(manga: Manga, state: ReaderState) { fun saveState(manga: Manga, state: ReaderState) {
processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) { processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) {

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.ui.pager package org.koitharu.kotatsu.reader.ui.pager
import android.content.Context import android.content.Context
import androidx.annotation.CallSuper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@@ -33,5 +34,8 @@ abstract class BasePageHolder<B : ViewBinding>(
protected abstract fun onBind(data: ReaderPage) 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.os.Bundle
import android.view.View import android.view.View
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.base.ui.BaseFragment 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.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() { abstract class BaseReader<B : ViewBinding> : BaseFragment<B>() {
protected val viewModel by sharedViewModel<ReaderViewModel>() protected val viewModel by sharedViewModel<ReaderViewModel>()
protected val loader by lazy(LazyThreadSafetyMode.NONE) {
PageLoader(lifecycleScope, get(), get())
}
private var stateToSave: ReaderState? = null private var stateToSave: ReaderState? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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 android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView 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.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
@@ -20,21 +23,22 @@ class PageHolderDelegate(
private val settings: AppSettings, private val settings: AppSettings,
private val callback: Callback, private val callback: Callback,
private val exceptionResolver: ExceptionResolver 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 state = State.EMPTY
private var job: Job? = null private var job: Job? = null
private var file: File? = null private var file: File? = null
private var error: Throwable? = null private var error: Throwable? = null
fun onBind(page: MangaPage) { fun onBind(page: MangaPage) {
job = launchInstead(job) { job = scope.launchInstead(job) {
doLoad(page, force = false) doLoad(page, force = false)
} }
} }
fun retry(page: MangaPage) { fun retry(page: MangaPage) {
job = launchInstead(job) { job = scope.launchInstead(job) {
(error as? ResolvableException)?.let { (error as? ResolvableException)?.let {
exceptionResolver.resolve(it) exceptionResolver.resolve(it)
} }
@@ -65,7 +69,7 @@ class PageHolderDelegate(
val file = this.file val file = this.file
error = e error = e
if (state == State.LOADED && e is IOException && file != null && file.exists()) { if (state == State.LOADED && e is IOException && file != null && file.exists()) {
job = launchAfter(job) { job = scope.launchAfter(job) {
state = State.CONVERTING state = State.CONVERTING
try { try {
loader.convertInPlace(file) 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.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment 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 import kotlin.math.absoluteValue
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() { class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@@ -27,7 +30,7 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(loader, get(), exceptionResolver) pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, get(), exceptionResolver)
with(binding.pager) { with(binding.pager) {
adapter = pagerAdapter adapter = pagerAdapter
offscreenPageLimit = 2 offscreenPageLimit = 2

View File

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

View File

@@ -29,7 +29,7 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(loader, get(), exceptionResolver) pagesAdapter = PagesAdapter(viewModel.pageLoader, get(), exceptionResolver)
with(binding.pager) { with(binding.pager) {
adapter = pagesAdapter adapter = pagesAdapter
offscreenPageLimit = 2 offscreenPageLimit = 2

View File

@@ -37,7 +37,7 @@ class WebtoonHolder(
} }
override fun onRecycled() { override fun onRecycled() {
delegate.onRecycle() super.onRecycled()
binding.ssiv.recycle() 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.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage 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>() { class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
@@ -26,7 +29,7 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter(loader, get(), exceptionResolver) webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, get(), exceptionResolver)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
adapter = webtoonAdapter adapter = webtoonAdapter

View File

@@ -4,6 +4,8 @@ import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.Bundle
import android.os.Parcelable
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -26,4 +28,10 @@ suspend fun ConnectivityManager.waitForNetwork(): Network {
inline fun buildAlertDialog(context: Context, block: MaterialAlertDialogBuilder.() -> Unit): AlertDialog { inline fun buildAlertDialog(context: Context, block: MaterialAlertDialogBuilder.() -> Unit): AlertDialog {
return MaterialAlertDialogBuilder(context).apply(block).create() return MaterialAlertDialogBuilder(context).apply(block).create()
}
fun <T : Parcelable> Bundle.requireParcelable(key: String): T {
return checkNotNull(getParcelable(key)) {
"Value for key $key not found"
}
} }